194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
import type { LucideIcon } from "lucide-react";
|
||
import { Blocks, Database, Pencil, ShieldCheck } from "lucide-react";
|
||
|
||
import { AppShell } from "@/components/AppShell";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Card } from "@/components/ui/Card";
|
||
import { EmptyState } from "@/components/ui/EmptyState";
|
||
import { PageHeader } from "@/components/ui/PageHeader";
|
||
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||
import { StatusNote } from "@/components/ui/StatusNote";
|
||
import type { ProjectResponse, SkillView } from "@/lib/api/types";
|
||
import {
|
||
groupByScope,
|
||
scopeLabel,
|
||
summarizeSkills,
|
||
tierLabel,
|
||
} from "@/lib/skills/skills";
|
||
|
||
interface SkillsPageProps {
|
||
project: ProjectResponse;
|
||
skills: SkillView[];
|
||
}
|
||
|
||
// 技能库(UX §7):只读注册表视图(name/scope/tier/reads/writes)。Server Component(纯读)。
|
||
export function SkillsPage({ project, skills }: SkillsPageProps) {
|
||
const groups = groupByScope(skills);
|
||
const summary = summarizeSkills(skills);
|
||
|
||
return (
|
||
<AppShell
|
||
title={`〈${project.title}〉`}
|
||
subtitle="技能库"
|
||
projectId={project.id}
|
||
activeNav="skills"
|
||
>
|
||
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
|
||
<PageHeader
|
||
title="技能库"
|
||
description="声明式 Skill 注册表:每个技能声明能力档位与可读/写的表,越权产出会被丢弃并审计。"
|
||
/>
|
||
<div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||
<aside className="flex flex-col gap-4 lg:sticky lg:top-[8rem] lg:self-start">
|
||
<Card as="section" className="p-4">
|
||
<SectionHeader
|
||
title="注册表概览"
|
||
description="按来源、档位和表权限汇总当前可用技能。"
|
||
/>
|
||
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||
<div className="rounded border border-line bg-paper p-3">
|
||
<dt className="text-xs text-ink-soft">总数</dt>
|
||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||
{summary.total}
|
||
</dd>
|
||
</div>
|
||
<div className="rounded border border-line bg-paper p-3">
|
||
<dt className="text-xs text-ink-soft">可写</dt>
|
||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||
{summary.writableCount}
|
||
</dd>
|
||
</div>
|
||
<div className="rounded border border-line bg-paper p-3">
|
||
<dt className="text-xs text-ink-soft">只读</dt>
|
||
<dd className="mt-1 font-serif text-2xl text-ink">
|
||
{summary.readonlyCount}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{summary.scopes.length > 0 ? (
|
||
summary.scopes.map((item) => (
|
||
<Badge key={item.key} variant="info">
|
||
{scopeLabel(item.key)} {item.count}
|
||
</Badge>
|
||
))
|
||
) : (
|
||
<span className="text-xs text-ink-soft">暂无来源</span>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card as="section" className="p-4">
|
||
<SectionHeader title="能力档位" />
|
||
{summary.tiers.length > 0 ? (
|
||
<ul className="mt-3 flex flex-col gap-2 text-sm">
|
||
{summary.tiers.map((item) => (
|
||
<li
|
||
key={item.key}
|
||
className="flex items-center justify-between border-b border-line/70 py-2 last:border-b-0"
|
||
>
|
||
<span className="text-ink">{tierLabel(item.key)}</span>
|
||
<span className="font-mono text-xs text-ink-soft">
|
||
{item.count}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="mt-3 text-sm text-ink-soft">暂无档位</p>
|
||
)}
|
||
</Card>
|
||
|
||
<StatusNote title="写入边界" variant="info">
|
||
<p className="text-sm leading-6">
|
||
可写表:{formatTables(summary.writableTables)}
|
||
</p>
|
||
<p className="mt-1 text-xs leading-5 text-ink-soft">
|
||
技能产出仍会经过表权限白名单;审稿与验收相关写入继续由人工确认流程收口。
|
||
</p>
|
||
</StatusNote>
|
||
</aside>
|
||
|
||
<div className="flex min-w-0 flex-col gap-5">
|
||
{groups.length === 0 ? (
|
||
<EmptyState
|
||
icon={Blocks}
|
||
title="暂无已注册技能"
|
||
description="技能注册后会在这里按作用域展示能力档位、可读表与可写表。"
|
||
/>
|
||
) : (
|
||
<>
|
||
{groups.map((group) => (
|
||
<section key={group.scope}>
|
||
<SectionHeader
|
||
title={scopeLabel(group.scope)}
|
||
description={`${group.skills.length} 个技能`}
|
||
/>
|
||
<ul className="mt-3 grid gap-3">
|
||
{group.skills.map((skill) => (
|
||
<SkillListItem key={skill.name} skill={skill} />
|
||
))}
|
||
</ul>
|
||
</section>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
function SkillListItem({ skill }: { skill: SkillView }) {
|
||
return (
|
||
<li className="rounded border border-line bg-panel p-4 text-sm shadow-paper">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="break-all font-mono text-ink">{skill.name}</span>
|
||
<Badge variant="info">{tierLabel(skill.tier)}</Badge>
|
||
{skill.genre ? <Badge>{skill.genre}</Badge> : null}
|
||
</div>
|
||
<div className="mt-3 grid gap-2 text-xs text-ink-soft md:grid-cols-2">
|
||
<TableLine
|
||
icon={Database}
|
||
label="读"
|
||
tables={skill.reads ?? []}
|
||
/>
|
||
<TableLine icon={Pencil} label="写" tables={skill.writes ?? []} />
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2 text-xs text-ink-soft">
|
||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||
<span>
|
||
{skill.writes && skill.writes.length > 0 ? "受控写入" : "只读"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function TableLine({
|
||
icon: Icon,
|
||
label,
|
||
tables,
|
||
}: {
|
||
icon: LucideIcon;
|
||
label: string;
|
||
tables: readonly string[];
|
||
}) {
|
||
return (
|
||
<span className="flex min-w-0 items-start gap-1.5">
|
||
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||
<span className="shrink-0">{label}:</span>
|
||
<span className="min-w-0 break-words">{formatTables(tables)}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function formatTables(tables: readonly string[]): string {
|
||
return tables.length > 0 ? tables.join("、") : "—";
|
||
}
|