Files
ftb-project-management/apps/web/app/admin/governance/page.tsx
2026-07-08 16:44:57 +08:00

153 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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';
import { useMemberStore } from '@/stores/useMemberStore';
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 role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
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 permissions = role?.permissions ?? [];
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, permissions, 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, permissions });
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, permissions, 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>
);
}