merge: 集成V2.7 企业协作与管理治理
# Conflicts: # apps/server/src/app.module.ts # apps/web/components/layout/Sidebar.tsx # apps/web/lib/permissions.ts # docs/architecture.md # docs/decisions.md # docs/roadmap.md
This commit is contained in:
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
||||
import { BugStatusBadge } from './BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
@@ -228,6 +229,13 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
)}
|
||||
|
||||
<ActivityLogPanel sourceType="bug" sourceId={bug.id} legacyEntries={legacyLogEntries} />
|
||||
<CommentPanel
|
||||
entityType="bug"
|
||||
entityId={bug.id}
|
||||
entityVersionId={bug.versionId}
|
||||
versionId={bug.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
@@ -507,6 +508,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
|
||||
<ActivityLogPanel sourceType="dev_task" sourceId={task.id} />
|
||||
|
||||
<CommentPanel
|
||||
entityType="dev_task"
|
||||
entityId={task.id}
|
||||
entityVersionId={task.versionId}
|
||||
versionId={task.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{predecessors.length > 0 && (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity, BarChart3, SlidersHorizontal } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { NotificationBell } from '@/components/notification/NotificationBell';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
@@ -48,6 +49,8 @@ const NAV_GROUPS = [
|
||||
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' },
|
||||
{ label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' },
|
||||
{ label: '管理驾驶舱', path: '/admin/management', icon: BarChart3, permission: 'management:view' },
|
||||
{ label: '治理设置', path: '/admin/governance', icon: SlidersHorizontal, permission: 'governance:manage' },
|
||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||
],
|
||||
},
|
||||
@@ -119,6 +122,7 @@ function UserBlock() {
|
||||
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
|
||||
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
|
||||
78
apps/web/components/notification/NotificationBell.tsx
Normal file
78
apps/web/components/notification/NotificationBell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, CheckCheck } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
assignment: '分配',
|
||||
mention: '提及',
|
||||
risk_alert: '风险',
|
||||
overdue_item: '逾期',
|
||||
};
|
||||
|
||||
export function NotificationBell() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) void fetchNotifications(user.id).catch(() => {});
|
||||
}, [fetchNotifications, user?.id]);
|
||||
|
||||
if (!user?.id) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" strokeWidth={1.8} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[9px] font-semibold leading-none text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-9 right-0 z-50 w-80 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-3">
|
||||
<span className="text-[12px] font-semibold text-[var(--ink)]">通知</span>
|
||||
<button
|
||||
onClick={() => markAllRead(user.id).catch(() => {})}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" /> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无通知</div>
|
||||
) : (
|
||||
notifications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !item.readAt && markRead(item.id, user.id).catch(() => {})}
|
||||
className={`block w-full border-b border-[var(--line)] px-3 py-2.5 text-left last:border-b-0 hover:bg-[var(--bg-subtle)] ${item.readAt ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${item.readAt ? 'bg-zinc-300' : 'bg-blue-600'}`} />
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="ml-auto text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--ink)]">{item.title}</div>
|
||||
{item.body && <div className="mt-0.5 line-clamp-2 text-[11px] leading-4 text-[var(--ink-muted)]">{item.body}</div>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type ProjectRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
interface ProjectMemberRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role: ProjectRole;
|
||||
user?: { id: string; name: string; email?: string };
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<ProjectRole, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
member: 'Member',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
export function ProjectMemberPanel({ projectId }: { projectId: string }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { members, fetchMembers } = useMemberStore();
|
||||
const [rows, setRows] = useState<ProjectMemberRow[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ProjectRole>('member');
|
||||
const actorId = user?.id ?? '';
|
||||
|
||||
const reload = async () => {
|
||||
if (!projectId) return;
|
||||
const data = await api.get<ProjectMemberRow[]>(`/projects/${projectId}/members`);
|
||||
setRows(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMembers().catch(() => {});
|
||||
void reload().catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const add = async () => {
|
||||
if (!actorId || !selectedUserId) return;
|
||||
await api.post(`/projects/${projectId}/members`, { actorId, userId: selectedUserId, role: selectedRole });
|
||||
setSelectedUserId('');
|
||||
await reload();
|
||||
};
|
||||
|
||||
const updateRole = async (member: ProjectMemberRow, role: ProjectRole) => {
|
||||
if (!actorId) return;
|
||||
await api.patch(`/projects/${projectId}/members/${member.userId}/role`, { actorId, role });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const remove = async (member: ProjectMemberRow) => {
|
||||
if (!actorId) return;
|
||||
await api.deleteWithBody(`/projects/${projectId}/members/${member.userId}`, { actorId });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const existingUserIds = new Set(rows.map((row) => row.userId));
|
||||
const candidates = members.filter((member) => !existingUserIds.has(member.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-[12px] font-semibold text-[var(--ink)]">
|
||||
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" /> 项目成员治理
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-[1fr_108px_auto] gap-2">
|
||||
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
<option value="">选择成员</option>
|
||||
{candidates.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}
|
||||
</select>
|
||||
<select value={selectedRole} onChange={(event) => setSelectedRole(event.target.value as ProjectRole)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={add} disabled={!selectedUserId} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<UserPlus className="h-3 w-3" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--line)] rounded-md border border-[var(--line)]">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[12px] text-[var(--ink-muted)]">暂无项目成员</div>
|
||||
) : rows.map((row) => (
|
||||
<div key={row.id} className="grid grid-cols-[1fr_112px_32px] items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--ink)]">{row.user?.name ?? row.userId}</div>
|
||||
<div className="truncate text-[10px] text-[var(--ink-muted)]">{row.user?.email ?? row.userId}</div>
|
||||
</div>
|
||||
<select value={row.role} onChange={(event) => updateRole(row, event.target.value as ProjectRole).catch(() => {})} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[11px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => remove(row).catch(() => {})} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="移除成员">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
|
||||
@@ -114,6 +115,16 @@ export function RequirementDetail({
|
||||
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
|
||||
<CommentPanel
|
||||
entityType="requirement"
|
||||
entityId={req.id}
|
||||
entityVersionId={req.versionId}
|
||||
projectId={req.projectId}
|
||||
versionId={req.versionId}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRig
|
||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
@@ -328,6 +329,13 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
|
||||
<ActivityLogPanel sourceType="test_case" sourceId={tc.id} />
|
||||
<CommentPanel
|
||||
entityType="test_case"
|
||||
entityId={tc.id}
|
||||
entityVersionId={tc.versionId}
|
||||
versionId={tc.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import {
|
||||
getResearchDirectionProgressSummary,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -242,6 +243,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = fal
|
||||
|
||||
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
|
||||
|
||||
<CommentPanel
|
||||
entityType="version_plan"
|
||||
entityId={plan.id}
|
||||
entityVersionId={plan.versionId}
|
||||
versionId={plan.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Result */}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||
|
||||
Reference in New Issue
Block a user