Files

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>
);
}