127 lines
5.4 KiB
TypeScript
127 lines
5.4 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { MessageSquare, Send, Trash2 } from 'lucide-react';
|
|
import { useAuthStore } from '@/stores/useAuthStore';
|
|
import { useCommentStore, commentKey } from '@/stores/useCommentStore';
|
|
import { useMemberStore } from '@/stores/useMemberStore';
|
|
import { extractMentionNames, mergeMentionMemberIds } from '@/lib/comment-mentions';
|
|
import { formatDateTime } from '@/lib/format';
|
|
|
|
export type CommentEntityType = 'dev_task' | 'test_case' | 'bug' | 'requirement' | 'version_plan';
|
|
|
|
interface Props {
|
|
entityType: CommentEntityType;
|
|
entityId: string;
|
|
entityVersionId?: string | null;
|
|
productId?: string | null;
|
|
projectId?: string | null;
|
|
versionId?: string | null;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export function CommentPanel({ entityType, entityId, entityVersionId, productId, projectId, versionId, readOnly = false }: Props) {
|
|
const user = useAuthStore((s) => s.user);
|
|
const { members, fetchMembers } = useMemberStore();
|
|
const { commentsByKey, fetchComments, createComment, deleteComment } = useCommentStore();
|
|
const [content, setContent] = useState('');
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const key = commentKey(entityType, entityId);
|
|
const comments = commentsByKey[key] ?? [];
|
|
|
|
useEffect(() => {
|
|
void fetchComments(entityType, entityId).catch(() => {});
|
|
void fetchMembers().catch(() => {});
|
|
}, [entityId, entityType, fetchComments, fetchMembers]);
|
|
|
|
const mentionNames = useMemo(() => extractMentionNames(content), [content]);
|
|
const mentionMemberIds = useMemo(() => mergeMentionMemberIds({
|
|
content,
|
|
explicitMemberIds: selectedIds,
|
|
members: members.map((member) => ({ id: member.id, name: member.name })),
|
|
}), [content, members, selectedIds]);
|
|
|
|
const submit = async () => {
|
|
if (readOnly || !user?.id || !content.trim()) return;
|
|
await createComment({
|
|
actorId: user.id,
|
|
entityType,
|
|
entityId,
|
|
entityVersionId,
|
|
productId,
|
|
projectId,
|
|
versionId,
|
|
content: content.trim(),
|
|
mentionMemberIds,
|
|
});
|
|
setContent('');
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
const toggleMember = (id: string) => {
|
|
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
|
<div className="mb-3 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-[var(--ink-muted)]">
|
|
<MessageSquare className="h-3 w-3" /> 评论
|
|
</div>
|
|
<div className="space-y-3">
|
|
{comments.length === 0 ? (
|
|
<p className="text-[12px] text-[var(--ink-muted)]">暂无评论</p>
|
|
) : (
|
|
comments.map((comment) => {
|
|
const author = members.find((member) => member.id === comment.authorId)?.name ?? comment.authorId;
|
|
return (
|
|
<div key={comment.id} className="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<span className="text-[12px] font-medium text-[var(--ink)]">{author}</span>
|
|
<span className="text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(comment.createdAt)}</span>
|
|
{!readOnly && user?.id === comment.authorId && (
|
|
<button onClick={() => deleteComment(entityType, entityId, comment.id, user.id).catch(() => {})} className="ml-auto rounded p-1 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="删除评论">
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="whitespace-pre-wrap text-[12px] leading-5 text-[var(--ink-soft)]">{comment.content}</p>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{!readOnly && (
|
|
<div className="mt-3 space-y-2 border-t border-[var(--line)] pt-3">
|
|
<textarea
|
|
value={content}
|
|
onChange={(event) => setContent(event.target.value)}
|
|
rows={3}
|
|
placeholder="写评论,输入 @成员名 可提及"
|
|
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
|
/>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{members.slice(0, 8).map((member) => (
|
|
<button
|
|
key={member.id}
|
|
onClick={() => toggleMember(member.id)}
|
|
className={`h-6 rounded-md border px-2 text-[11px] ${selectedIds.includes(member.id) ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]'}`}
|
|
>
|
|
@{member.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-[var(--ink-muted)]">
|
|
{mentionNames.length > 0 || selectedIds.length > 0 ? `将通知 ${mentionMemberIds.length} 人` : '未提及成员'}
|
|
</span>
|
|
<button onClick={submit} disabled={!content.trim() || !user?.id} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
|
<Send className="h-3 w-3" /> 发送
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|