feat(v2.7): 接入协作治理前端体验
This commit is contained in:
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 { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||
@@ -451,6 +452,8 @@ export default function ProjectDetailPage() {
|
||||
|
||||
<TeamSection teamByRole={teamByRole} />
|
||||
|
||||
<ProjectMemberPanel projectId={projectId} />
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user