feat(v2.7): 接入协作治理前端体验
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { NOTIFICATION_TYPES, type NotificationType } from '../notification.service';
|
||||||
|
|
||||||
|
export class CreateNotificationDto {
|
||||||
|
@IsString()
|
||||||
|
recipientId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
actorId?: string;
|
||||||
|
|
||||||
|
@IsIn(NOTIFICATION_TYPES)
|
||||||
|
type!: NotificationType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
body?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
resourceType!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
resourceId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
resourceVersionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||||
import { MarkNotificationReadDto } from './dto/mark-notification-read.dto';
|
import { MarkNotificationReadDto } from './dto/mark-notification-read.dto';
|
||||||
import { NotificationService } from './notification.service';
|
import { NotificationService } from './notification.service';
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ export class NotificationController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateNotificationDto) {
|
||||||
|
return this.notificationService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/read')
|
@Patch(':id/read')
|
||||||
markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) {
|
markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) {
|
||||||
return this.notificationService.markRead(id, dto.recipientId);
|
return this.notificationService.markRead(id, dto.recipientId);
|
||||||
|
|||||||
149
apps/web/app/admin/governance/page.tsx
Normal file
149
apps/web/app/admin/governance/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
|
||||||
|
type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
||||||
|
|
||||||
|
interface GovernanceItem {
|
||||||
|
id: string;
|
||||||
|
kind?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
group?: string | null;
|
||||||
|
isSystem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<GovernanceKind, string> = {
|
||||||
|
task_category: '任务类型',
|
||||||
|
requirement_type: '需求类型',
|
||||||
|
requirement_platform: '支持端',
|
||||||
|
requirement_source: '需求来源',
|
||||||
|
};
|
||||||
|
|
||||||
|
function GovernancePageInner() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
||||||
|
const [items, setItems] = useState<GovernanceItem[]>([]);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [group, setGroup] = useState('other');
|
||||||
|
const [exportText, setExportText] = useState('');
|
||||||
|
const actorId = user?.id ?? '';
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
const rows = await api.get<GovernanceItem[]>(`/governance/dictionaries?kind=${kind}`);
|
||||||
|
setItems(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload().catch(() => setItems([]));
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!actorId || !name.trim()) return;
|
||||||
|
await api.post('/governance/dictionaries', { actorId, kind, name: name.trim(), group });
|
||||||
|
setName('');
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: GovernanceItem) => {
|
||||||
|
if (!actorId) return;
|
||||||
|
await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId });
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportAll = async () => {
|
||||||
|
const data = await api.get('/governance/export');
|
||||||
|
setExportText(JSON.stringify(data, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAll = async () => {
|
||||||
|
if (!actorId || !exportText.trim()) return;
|
||||||
|
const parsed = JSON.parse(exportText);
|
||||||
|
const sourceItems = Array.isArray(parsed.items)
|
||||||
|
? parsed.items
|
||||||
|
: [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))];
|
||||||
|
await api.post('/governance/import', { actorId, items: sourceItems });
|
||||||
|
await reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[18px] font-semibold text-[var(--ink)]">治理设置</h1>
|
||||||
|
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">统一维护任务类型、需求类型、支持端与来源字典。</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={reload} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Object.entries(KIND_LABEL).map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setKind(key as GovernanceKind)}
|
||||||
|
className={`h-8 rounded-md px-3 text-[12px] font-medium ${kind === key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)]'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||||
|
<button onClick={create} disabled={!name.trim()} 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">
|
||||||
|
<Plus className="h-3.5 w-3.5" /> 添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="grid grid-cols-[1fr_160px_80px_32px] items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="truncate text-[13px] text-[var(--ink)]">{item.name}</span>
|
||||||
|
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.code ?? '-'}</span>
|
||||||
|
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.group ?? '-'}</span>
|
||||||
|
<button onClick={() => remove(item).catch(() => {})} disabled={item.isSystem} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600 disabled:opacity-30" title="删除">
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无字典项</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<button onClick={exportAll} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||||
|
<Download className="h-3.5 w-3.5" /> 导出
|
||||||
|
</button>
|
||||||
|
<button onClick={importAll} disabled={!exportText.trim()} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)] disabled:opacity-50">
|
||||||
|
<Upload className="h-3.5 w-3.5" /> 导入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={exportText}
|
||||||
|
onChange={(event) => setExportText(event.target.value)}
|
||||||
|
rows={10}
|
||||||
|
className="w-full rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 py-2 font-mono text-[11px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
||||||
|
placeholder="点击导出生成 JSON,也可以粘贴 JSON 后导入"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GovernancePage() {
|
||||||
|
return (
|
||||||
|
<RouteGuard permission="governance:manage">
|
||||||
|
<GovernancePageInner />
|
||||||
|
</RouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
apps/web/app/admin/management/page.tsx
Normal file
132
apps/web/app/admin/management/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Activity, AlertTriangle, Blocks, Gauge, Users } from 'lucide-react';
|
||||||
|
import { RouteGuard } from '@/components/auth/Guard';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
|
|
||||||
|
interface ManagementOverview {
|
||||||
|
activeVersionCount: number;
|
||||||
|
overdueItemCount: number;
|
||||||
|
blockedItemCount: number;
|
||||||
|
riskCounts: Record<string, number>;
|
||||||
|
memberLoads: Array<{ memberId: string; openItemCount: number }>;
|
||||||
|
activeVersions: Array<{ id: string; name: string; projectId?: string | null; releaseDate?: string | null }>;
|
||||||
|
overdueItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null; dueAt?: string | null }>;
|
||||||
|
blockedItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null }>;
|
||||||
|
highRiskVersions: Array<{ versionId: string; riskLevel: string; riskScore: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
version_plan: '计划',
|
||||||
|
dev_task: '开发',
|
||||||
|
test_case: '测试',
|
||||||
|
bug: 'Bug',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ManagementPageInner() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||||
|
const [overview, setOverview] = useState<ManagementOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const permissions = useMemo(() => role?.permissions ?? [], [role?.permissions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({ actorId: user.id });
|
||||||
|
if (permissions.length > 0) params.set('permissions', permissions.join(','));
|
||||||
|
api.get<ManagementOverview>(`/management/overview?${params.toString()}`)
|
||||||
|
.then(setOverview)
|
||||||
|
.catch(() => setOverview(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [permissions, user?.id]);
|
||||||
|
|
||||||
|
const riskTotal = overview ? Object.values(overview.riskCounts).reduce((sum, count) => sum + count, 0) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[18px] font-semibold text-[var(--ink)]">管理驾驶舱</h1>
|
||||||
|
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">关系表实时聚合,不读取 AppData。</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 py-1.5 text-[11px] text-[var(--ink-muted)]">
|
||||||
|
{loading ? '刷新中' : '已同步'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<Metric icon={Activity} label="活跃版本" value={overview?.activeVersionCount ?? 0} />
|
||||||
|
<Metric icon={AlertTriangle} label="逾期事项" value={overview?.overdueItemCount ?? 0} tone="warn" />
|
||||||
|
<Metric icon={Blocks} label="阻塞事项" value={overview?.blockedItemCount ?? 0} tone="danger" />
|
||||||
|
<Metric icon={Gauge} label="风险版本" value={riskTotal} tone="risk" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1.15fr_0.85fr] gap-4">
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">逾期与阻塞</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{[...(overview?.overdueItems ?? []), ...(overview?.blockedItems ?? [])].slice(0, 12).map((item) => (
|
||||||
|
<div key={`${item.type}-${item.id}`} className="grid grid-cols-[72px_1fr_120px] gap-3 px-4 py-2.5 text-[12px]">
|
||||||
|
<span className="rounded bg-[var(--bg-subtle)] px-2 py-1 text-center text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||||
|
<span className="min-w-0 truncate text-[var(--ink)]">{item.title}</span>
|
||||||
|
<span className="truncate text-right text-[var(--ink-muted)]">{item.ownerId ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!overview || (overview.overdueItems.length + overview.blockedItems.length) === 0) && (
|
||||||
|
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无逾期或阻塞事项</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">
|
||||||
|
<Users className="h-4 w-4 text-[var(--accent)]" /> 成员负载
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[var(--line)]">
|
||||||
|
{(overview?.memberLoads ?? []).slice(0, 10).map((item) => (
|
||||||
|
<div key={item.memberId} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.memberId}</span>
|
||||||
|
<span className="rounded bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">{item.openItemCount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!overview || overview.memberLoads.length === 0) && (
|
||||||
|
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无负载数据</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ icon: Icon, label, value, tone = 'neutral' }: { icon: any; label: string; value: number; tone?: 'neutral' | 'warn' | 'danger' | 'risk' }) {
|
||||||
|
const toneClass = {
|
||||||
|
neutral: 'text-[var(--accent)] bg-blue-50',
|
||||||
|
warn: 'text-orange-700 bg-orange-50',
|
||||||
|
danger: 'text-red-700 bg-red-50',
|
||||||
|
risk: 'text-purple-700 bg-purple-50',
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
|
||||||
|
<span className={`rounded-md p-1.5 ${toneClass}`}><Icon className="h-4 w-4" /></span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[26px] font-semibold tabular-nums text-[var(--ink)]">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManagementPage() {
|
||||||
|
return (
|
||||||
|
<RouteGuard permission="management:view">
|
||||||
|
<ManagementPageInner />
|
||||||
|
</RouteGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/ve
|
|||||||
import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
|
import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
|
||||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
|
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
||||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||||
@@ -451,6 +452,8 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
<TeamSection teamByRole={teamByRole} />
|
<TeamSection teamByRole={teamByRole} />
|
||||||
|
|
||||||
|
<ProjectMemberPanel projectId={projectId} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
||||||
import { BugStatusBadge } from './BugStatusBadge';
|
import { BugStatusBadge } from './BugStatusBadge';
|
||||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||||
|
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
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} />
|
<ActivityLogPanel sourceType="bug" sourceId={bug.id} legacyEntries={legacyLogEntries} />
|
||||||
|
<CommentPanel
|
||||||
|
entityType="bug"
|
||||||
|
entityId={bug.id}
|
||||||
|
entityVersionId={bug.versionId}
|
||||||
|
versionId={bug.versionId}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 { StatusBadge } from './StatusBadge';
|
||||||
import { CategoryChip } from './CategoryChip';
|
import { CategoryChip } from './CategoryChip';
|
||||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||||
|
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
@@ -507,6 +508,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
|||||||
|
|
||||||
<ActivityLogPanel sourceType="dev_task" sourceId={task.id} />
|
<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 && (
|
{predecessors.length > 0 && (
|
||||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
<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>
|
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark } from 'lucide-react';
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, BarChart3, SlidersHorizontal } from 'lucide-react';
|
||||||
import { useHasPermission } from '@/components/auth/Guard';
|
import { useHasPermission } from '@/components/auth/Guard';
|
||||||
|
import { NotificationBell } from '@/components/notification/NotificationBell';
|
||||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
@@ -45,6 +46,8 @@ const NAV_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
||||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role: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: '*' },
|
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -116,6 +119,7 @@ function UserBlock() {
|
|||||||
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
|
<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>
|
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/profile')}
|
onClick={() => router.push('/profile')}
|
||||||
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
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';
|
'use client';
|
||||||
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } 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} />
|
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||||
</div>
|
</div>
|
||||||
</DetailSection>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRig
|
|||||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||||
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
|
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
|
||||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||||
|
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||||
import { useBugStore } from '@/stores/useBugStore';
|
import { useBugStore } from '@/stores/useBugStore';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
@@ -328,6 +329,13 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ActivityLogPanel sourceType="test_case" sourceId={tc.id} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
|||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
import { FilterSelect } from '@/components/FilterSelect';
|
import { FilterSelect } from '@/components/FilterSelect';
|
||||||
|
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||||
import {
|
import {
|
||||||
getResearchDirectionProgressSummary,
|
getResearchDirectionProgressSummary,
|
||||||
getRequirementCoverageSummary,
|
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" />
|
<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 */}
|
{/* Result */}
|
||||||
{plan.status === 'completed' && plan.resultUrl && (
|
{plan.status === 'completed' && plan.resultUrl && (
|
||||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const api = {
|
|||||||
patch: <T>(path: string, data: unknown) =>
|
patch: <T>(path: string, data: unknown) =>
|
||||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
deleteWithBody: <T>(path: string, data: unknown) =>
|
||||||
|
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
|
||||||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||||||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
20
apps/web/lib/comment-mentions.test.ts
Normal file
20
apps/web/lib/comment-mentions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { extractMentionNames, mergeMentionMemberIds } from './comment-mentions';
|
||||||
|
|
||||||
|
test('extractMentionNames reads @memberName mentions from comment content', () => {
|
||||||
|
assert.deepEqual(extractMentionNames('请 @Alice 和 @张三 看一下'), ['Alice', '张三']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeMentionMemberIds keeps text mention matches before explicit selection and dedupes', () => {
|
||||||
|
const ids = mergeMentionMemberIds({
|
||||||
|
content: '请 @Alice 看一下',
|
||||||
|
explicitMemberIds: ['m-bob', 'm-alice'],
|
||||||
|
members: [
|
||||||
|
{ id: 'm-alice', name: 'Alice' },
|
||||||
|
{ id: 'm-bob', name: 'Bob' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(ids, ['m-alice', 'm-bob']);
|
||||||
|
});
|
||||||
36
apps/web/lib/comment-mentions.ts
Normal file
36
apps/web/lib/comment-mentions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface CommentMentionMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeMentionMemberIdsInput {
|
||||||
|
content: string;
|
||||||
|
explicitMemberIds: string[];
|
||||||
|
members: CommentMentionMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMentionNames(content: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
|
||||||
|
for (const match of content.matchAll(pattern)) {
|
||||||
|
if (match[1]) names.push(match[1]);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMentionMemberIds(input: MergeMentionMemberIdsInput): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const names = new Set(extractMentionNames(input.content).map(normalizeName));
|
||||||
|
for (const member of input.members) {
|
||||||
|
if (names.has(normalizeName(member.name))) ids.add(member.id);
|
||||||
|
}
|
||||||
|
for (const id of input.explicitMemberIds) {
|
||||||
|
const normalized = id.trim();
|
||||||
|
if (normalized) ids.add(normalized);
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(name: string): string {
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
20
apps/web/lib/notification.test.ts
Normal file
20
apps/web/lib/notification.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { countUnreadNotifications, sortNotificationsNewestFirst } from './notification';
|
||||||
|
|
||||||
|
test('countUnreadNotifications counts only rows without readAt', () => {
|
||||||
|
assert.equal(countUnreadNotifications([
|
||||||
|
{ id: 'n-1', readAt: null } as any,
|
||||||
|
{ id: 'n-2', readAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||||
|
{ id: 'n-3' } as any,
|
||||||
|
]), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sortNotificationsNewestFirst keeps newest createdAt first', () => {
|
||||||
|
const sorted = sortNotificationsNewestFirst([
|
||||||
|
{ id: 'old', createdAt: '2026-07-07T08:00:00.000Z' } as any,
|
||||||
|
{ id: 'new', createdAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(sorted.map((item) => item.id), ['new', 'old']);
|
||||||
|
});
|
||||||
32
apps/web/lib/notification.ts
Normal file
32
apps/web/lib/notification.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type NotificationType = 'assignment' | 'mention' | 'risk_alert' | 'overdue_item';
|
||||||
|
|
||||||
|
export interface NotificationRecord {
|
||||||
|
id: string;
|
||||||
|
recipientId: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceVersionId?: string | null;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
readAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countUnreadNotifications(items: Pick<NotificationRecord, 'readAt'>[]): number {
|
||||||
|
return items.filter((item) => !item.readAt).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortNotificationsNewestFirst<T extends Pick<NotificationRecord, 'createdAt'>>(items: T[]): T[] {
|
||||||
|
return [...items].sort((a, b) => dateValue(b.createdAt) - dateValue(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateValue(value: string): number {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
@@ -54,6 +54,22 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
},
|
},
|
||||||
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||||
|
{
|
||||||
|
module: 'management',
|
||||||
|
moduleLabel: '管理驾驶舱',
|
||||||
|
category: 'main',
|
||||||
|
actions: [
|
||||||
|
{ action: 'view', label: '查看', permission: 'management:view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: 'governance',
|
||||||
|
moduleLabel: '治理设置',
|
||||||
|
category: 'main',
|
||||||
|
actions: [
|
||||||
|
{ action: 'manage', label: '管理', permission: 'governance:manage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||||
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
||||||
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
||||||
@@ -76,6 +92,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
|||||||
...std4('requirement').map((a) => a.permission),
|
...std4('requirement').map((a) => a.permission),
|
||||||
'version.req:view', 'version.req:manage',
|
'version.req:view', 'version.req:manage',
|
||||||
'version.product_plan:view', 'version.product_plan:manage',
|
'version.product_plan:view', 'version.product_plan:manage',
|
||||||
|
'management:view',
|
||||||
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||||
'overtime:view', 'member:view', 'role:view',
|
'overtime:view', 'member:view', 'role:view',
|
||||||
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||||
|
|||||||
79
apps/web/stores/useCommentStore.ts
Normal file
79
apps/web/stores/useCommentStore.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { CommentEntityType } from '@/components/comment/CommentPanel';
|
||||||
|
|
||||||
|
export interface CommentRecord {
|
||||||
|
id: string;
|
||||||
|
entityType: CommentEntityType;
|
||||||
|
entityId: string;
|
||||||
|
entityVersionId?: string | null;
|
||||||
|
authorId: string;
|
||||||
|
content: string;
|
||||||
|
mentionedMemberIds?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateCommentInput {
|
||||||
|
actorId: string;
|
||||||
|
entityType: CommentEntityType;
|
||||||
|
entityId: string;
|
||||||
|
entityVersionId?: string | null;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
content: string;
|
||||||
|
mentionMemberIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentState {
|
||||||
|
commentsByKey: Record<string, CommentRecord[]>;
|
||||||
|
loadingKeys: string[];
|
||||||
|
fetchComments: (entityType: CommentEntityType, entityId: string) => Promise<void>;
|
||||||
|
createComment: (input: CreateCommentInput) => Promise<void>;
|
||||||
|
deleteComment: (entityType: CommentEntityType, entityId: string, id: string, actorId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentKey(entityType: CommentEntityType, entityId: string) {
|
||||||
|
return `${entityType}:${entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommentStore = create<CommentState>((set, get) => ({
|
||||||
|
commentsByKey: {},
|
||||||
|
loadingKeys: [],
|
||||||
|
|
||||||
|
fetchComments: async (entityType, entityId) => {
|
||||||
|
const key = commentKey(entityType, entityId);
|
||||||
|
set({ loadingKeys: [...get().loadingKeys.filter((item) => item !== key), key] });
|
||||||
|
const params = new URLSearchParams({ entityType, entityId });
|
||||||
|
const rows = await api.get<CommentRecord[]>(`/comments?${params.toString()}`);
|
||||||
|
set({
|
||||||
|
commentsByKey: { ...get().commentsByKey, [key]: rows },
|
||||||
|
loadingKeys: get().loadingKeys.filter((item) => item !== key),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
createComment: async (input) => {
|
||||||
|
const row = await api.post<CommentRecord>('/comments', input);
|
||||||
|
const key = commentKey(input.entityType, input.entityId);
|
||||||
|
set({
|
||||||
|
commentsByKey: {
|
||||||
|
...get().commentsByKey,
|
||||||
|
[key]: [...(get().commentsByKey[key] ?? []), row],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteComment: async (entityType, entityId, id, actorId) => {
|
||||||
|
const row = await api.deleteWithBody<CommentRecord>(`/comments/${id}`, { actorId });
|
||||||
|
const key = commentKey(entityType, entityId);
|
||||||
|
set({
|
||||||
|
commentsByKey: {
|
||||||
|
...get().commentsByKey,
|
||||||
|
[key]: (get().commentsByKey[key] ?? []).map((item) => item.id === id ? row : item).filter((item) => !item.deletedAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
52
apps/web/stores/useNotificationStore.ts
Normal file
52
apps/web/stores/useNotificationStore.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { countUnreadNotifications, sortNotificationsNewestFirst, type NotificationRecord } from '@/lib/notification';
|
||||||
|
|
||||||
|
interface NotificationState {
|
||||||
|
notifications: NotificationRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
loadedFor?: string;
|
||||||
|
unreadCount: number;
|
||||||
|
fetchNotifications: (recipientId: string, options?: { unreadOnly?: boolean }) => Promise<void>;
|
||||||
|
markRead: (id: string, recipientId: string) => Promise<void>;
|
||||||
|
markAllRead: (recipientId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||||
|
notifications: [],
|
||||||
|
loading: false,
|
||||||
|
loadedFor: undefined,
|
||||||
|
unreadCount: 0,
|
||||||
|
|
||||||
|
fetchNotifications: async (recipientId, options) => {
|
||||||
|
if (!recipientId) return;
|
||||||
|
set({ loading: true });
|
||||||
|
const params = new URLSearchParams({ recipientId });
|
||||||
|
if (options?.unreadOnly) params.set('unreadOnly', 'true');
|
||||||
|
const rows = await api.get<NotificationRecord[]>(`/notifications?${params.toString()}`);
|
||||||
|
const notifications = sortNotificationsNewestFirst(rows);
|
||||||
|
set({
|
||||||
|
notifications,
|
||||||
|
unreadCount: countUnreadNotifications(notifications),
|
||||||
|
loadedFor: recipientId,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markRead: async (id, recipientId) => {
|
||||||
|
await api.patch(`/notifications/${id}/read`, { recipientId });
|
||||||
|
const notifications = get().notifications.map((item) =>
|
||||||
|
item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item,
|
||||||
|
);
|
||||||
|
set({ notifications, unreadCount: countUnreadNotifications(notifications) });
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllRead: async (recipientId) => {
|
||||||
|
await api.patch('/notifications/read-all', { recipientId });
|
||||||
|
const readAt = new Date().toISOString();
|
||||||
|
const notifications = get().notifications.map((item) => item.readAt ? item : { ...item, readAt });
|
||||||
|
set({ notifications, unreadCount: 0 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { useMemberStore } from './useMemberStore';
|
||||||
import {
|
import {
|
||||||
mergeDailySnapshotCacheForSave,
|
mergeDailySnapshotCacheForSave,
|
||||||
mergeInsightCacheForSave,
|
mergeInsightCacheForSave,
|
||||||
@@ -74,6 +76,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||||
set({ snapshots, error: undefined });
|
set({ snapshots, error: undefined });
|
||||||
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||||
|
void notifyRiskManagers(item).catch(() => {});
|
||||||
});
|
});
|
||||||
snapshotSaveQueue = task.catch(() => undefined);
|
snapshotSaveQueue = task.catch(() => undefined);
|
||||||
try {
|
try {
|
||||||
@@ -119,3 +122,32 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
async function notifyRiskManagers(item: XiaobaoRiskSnapshot) {
|
||||||
|
if (!['at_risk', 'likely_delayed', 'blocked'].includes(item.riskLevel)) return;
|
||||||
|
const memberStore = useMemberStore.getState();
|
||||||
|
if (!memberStore.loaded) {
|
||||||
|
await memberStore.fetchMembers().catch(() => undefined);
|
||||||
|
}
|
||||||
|
const state = useMemberStore.getState();
|
||||||
|
const roleMap = new Map(state.roles.map((role) => [role.id, role]));
|
||||||
|
const recipients = state.members.filter((member) => {
|
||||||
|
const permissions = roleMap.get(member.roleId)?.permissions ?? [];
|
||||||
|
return permissions.includes('*') || permissions.includes('xiaobao.warning:manage');
|
||||||
|
});
|
||||||
|
await Promise.all(recipients.map((member) => api.post('/notifications', {
|
||||||
|
recipientId: member.id,
|
||||||
|
actorId: 'xiaobao',
|
||||||
|
type: 'risk_alert',
|
||||||
|
title: '小宝预警更新',
|
||||||
|
body: `版本 ${item.versionId} 当前风险 ${item.riskLevel},风险分 ${item.riskScore}`,
|
||||||
|
resourceType: 'version',
|
||||||
|
resourceId: item.versionId,
|
||||||
|
versionId: item.versionId,
|
||||||
|
metadata: {
|
||||||
|
riskLevel: item.riskLevel,
|
||||||
|
riskScore: item.riskScore,
|
||||||
|
riskSignature: `${item.versionId}:${item.date}:${item.riskScore}:${item.riskLevel}`,
|
||||||
|
},
|
||||||
|
}).catch(() => undefined)));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user