feat(v2.7): 接入协作治理前端体验
This commit is contained in:
126
apps/web/components/comment/CommentPanel.tsx
Normal file
126
apps/web/components/comment/CommentPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user