fix(upload): link attachments to comments via client-side ID tracking

Instead of regex-parsing markdown content to find attachment URLs
(fragile), the frontend now tracks uploaded attachment IDs and sends
them with the comment creation request. The backend links them by ID.

Frontend: upload returns attachment ID, comment/reply inputs collect
IDs during editing session, pass as attachment_ids on submit.
Backend: CreateComment accepts attachment_ids, links by ID+issue scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yushen 2026-03-31 16:47:27 +08:00
parent acba0b8139
commit 79cd2a3a5d
11 changed files with 85 additions and 35 deletions

View file

@ -32,7 +32,7 @@ interface CommentCardProps {
entry: TimelineEntry;
allReplies: Map<string, TimelineEntry[]>;
currentUserId?: string;
onReply: (parentId: string, content: string) => Promise<void>;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
@ -374,7 +374,7 @@ function CommentCard({
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content) => onReply(entry.id, content)}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>
</CollapsibleContent>

View file

@ -8,17 +8,22 @@ import { useFileUpload } from "@/shared/hooks/use-file-upload";
interface CommentInputProps {
issueId: string;
onSubmit: (content: string) => Promise<void>;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -35,8 +40,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);

View file

@ -16,7 +16,7 @@ interface ReplyInputProps {
placeholder?: string;
avatarType: string;
avatarId: string;
onSubmit: (content: string) => Promise<void>;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
}
@ -34,11 +34,16 @@ function ReplyInput({
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -55,8 +60,10 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);