Files
OSGAccountServer/admin-web/src/features/content/content-page.tsx
T
Rocky b25f5ae6e9 Clarify Hint pack save behavior
Keep manual editing while making save the only user action and applying changes immediately without a separate publish workflow.
2026-08-21 15:37:13 +08:00

511 lines
16 KiB
TypeScript

import { FileJson, Pencil, Plus, Power, RefreshCw, Sparkles } from "lucide-react";
import {
useCallback,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
AdminHintPack,
CreateOfficialSkillRequest,
OfficialSkill,
OfficialSkillCatalog,
SkillLocalization,
UpdateHintPackRequest,
} from "../../api/types";
import {
Badge,
Button,
Card,
Dialog,
EmptyState,
ErrorState,
Input,
LoadingState,
PageHeader,
Textarea,
} from "../../components/primitives";
import { useAuth } from "../auth/auth-context";
import { HintAutoSection } from "./hint-auto-section";
type HintLocale = "zh" | "en";
export function ContentPage() {
const { auth } = useAuth();
const canEdit = auth.status === "authenticated" && auth.role === "SUPER_ADMIN";
const [catalog, setCatalog] = useState<OfficialSkillCatalog>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
const [editingSkill, setEditingSkill] = useState<OfficialSkill | "new">();
const [busySkillId, setBusySkillId] = useState<string>();
const [locale, setLocale] = useState<HintLocale>("zh");
const [hintText, setHintText] = useState("");
const [hintVersion, setHintVersion] = useState(0);
const [hintLoading, setHintLoading] = useState(true);
const [hintSaving, setHintSaving] = useState(false);
const loadSkills = useCallback(async () => {
setError(undefined);
try {
setCatalog(await adminApi.contentSkills());
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
const loadHint = useCallback(async (nextLocale: HintLocale) => {
setHintLoading(true);
try {
const pack = await adminApi.contentHintPack(nextLocale);
setHintVersion(pack.version);
setHintText(formatHintPack(pack));
} catch (requestError) {
toast.error(errorMessage(requestError, "Hint pack 加载失败"));
} finally {
setHintLoading(false);
}
}, []);
useEffect(() => {
void loadSkills();
}, [loadSkills]);
useEffect(() => {
void loadHint(locale);
}, [loadHint, locale]);
async function setSkillEnabled(skill: OfficialSkill) {
setBusySkillId(skill.id);
try {
await adminApi.setContentSkillEnabled(skill.id, !skill.enabled);
toast.success(skill.enabled ? "Skill 已停用" : "Skill 已启用");
await loadSkills();
} catch (requestError) {
toast.error(errorMessage(requestError, "Skill 状态更新失败"));
} finally {
setBusySkillId(undefined);
}
}
async function saveHint() {
let payload: UpdateHintPackRequest;
try {
const parsed = JSON.parse(hintText) as Partial<UpdateHintPackRequest>;
if (!Array.isArray(parsed.cards)) throw new Error("cards 必须是数组");
payload = {
generatedAt: optionalString(parsed.generatedAt),
expiresAt: optionalString(parsed.expiresAt),
intervalHours:
parsed.intervalHours === undefined ? undefined : Number(parsed.intervalHours),
cards: parsed.cards,
};
} catch (parseError) {
toast.error(parseError instanceof Error ? parseError.message : "JSON 格式无效");
return;
}
setHintSaving(true);
try {
const saved = await adminApi.updateContentHintPack(locale, payload);
setHintVersion(saved.version);
setHintText(formatHintPack(saved));
toast.success(`${locale} Hint pack 已保存并生效`);
} catch (requestError) {
toast.error(errorMessage(requestError, "Hint pack 保存失败"));
} finally {
setHintSaving(false);
}
}
if (error) return <ErrorState error={error} retry={() => void loadSkills()} />;
if (loading || !catalog) return <LoadingState label="加载内容管理" />;
return (
<div className="space-y-8">
<PageHeader
eyebrow="Official Content"
title="内容管理"
description="维护客户端官方 Skill、AI Hint 自动生成与 zh/en 内容。所有变更都会写入审计。"
actions={
canEdit ? (
<Button onClick={() => setEditingSkill("new")}>
<Plus className="size-4" aria-hidden />
新增 Skill
</Button>
) : (
<Badge tone="info">只读访问</Badge>
)
}
/>
<section aria-labelledby="skill-heading" className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="skill-heading" className="text-xl font-bold">
官方 Skill
</h2>
<p className="mt-1 text-sm text-muted">
当前 revision {catalog.revision} · {catalog.skills.length}
</p>
</div>
<Button variant="secondary" onClick={() => void loadSkills()}>
<RefreshCw className="size-4" aria-hidden />
刷新
</Button>
</div>
{catalog.skills.length === 0 ? (
<Card>
<EmptyState title="暂无官方 Skill" description="新增后可单独启用发布。" />
</Card>
) : (
<div className="grid gap-4 xl:grid-cols-2">
{catalog.skills.map((skill) => (
<Card key={skill.id} className="p-5">
<div className="flex items-start gap-4">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary-soft text-primary">
<Sparkles className="size-5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{skill.localizations["zh-Hans"].name}</h3>
<Badge tone={skill.enabled ? "success" : "neutral"}>
{skill.enabled ? "已启用" : "已停用"}
</Badge>
{skill.thinkingEnabled ? <Badge tone="violet">思考</Badge> : null}
</div>
<p className="mt-1 text-sm text-muted">
{skill.localizations["zh-Hans"].summary}
</p>
<p className="mt-3 break-all font-mono text-[11px] text-muted">
{skill.id} · {skill.systemImage} · 排序 {skill.sortOrder}
</p>
{canEdit ? (
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => setEditingSkill(skill)}
>
<Pencil className="size-3.5" aria-hidden />
编辑
</Button>
<Button
size="sm"
variant={skill.enabled ? "danger" : "primary"}
loading={busySkillId === skill.id}
onClick={() => void setSkillEnabled(skill)}
>
<Power className="size-3.5" aria-hidden />
{skill.enabled ? "停用" : "启用"}
</Button>
</div>
) : null}
</div>
</div>
</Card>
))}
</div>
)}
</section>
<HintAutoSection canEdit={canEdit} />
<section aria-labelledby="hint-heading" className="space-y-4">
<div>
<h2 id="hint-heading" className="text-xl font-bold">
AI Hint packs
</h2>
<p className="mt-1 text-sm text-muted">
可查看并编辑当前生效的 AIHintPack;保存后立即生效,服务端自动递增 version
</p>
</div>
<Card className="overflow-hidden">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-4">
{(["zh", "en"] as HintLocale[]).map((item) => (
<Button
key={item}
size="sm"
variant={locale === item ? "primary" : "secondary"}
onClick={() => setLocale(item)}
aria-pressed={locale === item}
>
{item}
</Button>
))}
<Badge className="ml-auto" tone="info">
version {hintVersion}
</Badge>
</div>
<div className="p-4 sm:p-6">
{hintLoading ? (
<LoadingState label={`加载 ${locale} Hint pack`} />
) : (
<>
<label className="block text-sm font-semibold" htmlFor="hint-pack-json">
<span className="mb-2 flex items-center gap-2">
<FileJson className="size-4 text-primary" aria-hidden />
{locale} JSON
</span>
<Textarea
id="hint-pack-json"
className="min-h-[420px] font-mono text-xs leading-5"
value={hintText}
onChange={(event) => setHintText(event.target.value)}
readOnly={!canEdit}
spellCheck={false}
/>
</label>
{canEdit ? (
<div className="mt-4 flex justify-end">
<Button loading={hintSaving} onClick={() => void saveHint()}>
保存
</Button>
</div>
) : null}
</>
)}
</div>
</Card>
</section>
<SkillDialog
skill={editingSkill}
onClose={() => setEditingSkill(undefined)}
onSaved={async () => {
setEditingSkill(undefined);
await loadSkills();
}}
/>
</div>
);
}
function SkillDialog({
skill,
onClose,
onSaved,
}: {
skill?: OfficialSkill | "new";
onClose: () => void;
onSaved: () => Promise<void>;
}) {
const [form, setForm] = useState<CreateOfficialSkillRequest>(() => emptySkill());
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(skill && skill !== "new" ? skillToRequest(skill) : emptySkill());
}, [skill]);
async function submit(event: FormEvent) {
event.preventDefault();
setSaving(true);
try {
if (skill === "new") {
await adminApi.createContentSkill(form);
} else if (skill) {
const { id: _id, ...payload } = form;
await adminApi.updateContentSkill(skill.id, payload);
}
toast.success(skill === "new" ? "Skill 已创建(默认停用)" : "Skill 已更新");
await onSaved();
} catch (requestError) {
toast.error(errorMessage(requestError, "Skill 保存失败"));
} finally {
setSaving(false);
}
}
return (
<Dialog
open={Boolean(skill)}
onOpenChange={(open) => {
if (!open) onClose();
}}
title={skill === "new" ? "新增官方 Skill" : "编辑官方 Skill"}
description="ID 创建后不可修改;新 Skill 默认停用,确认内容后再启用。"
preventClose={saving}
className="max-w-3xl"
>
<form className="space-y-5" onSubmit={(event) => void submit(event)}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Skill ID">
<Input
required
pattern={"official\\.[a-z0-9._-]+"}
minLength={10}
maxLength={100}
value={form.id}
disabled={skill !== "new"}
onChange={(event) => setForm({ ...form, id: event.target.value })}
placeholder="official.polish"
/>
</Field>
<Field label="SF Symbol">
<Input
required
minLength={1}
maxLength={100}
value={form.systemImage}
onChange={(event) => setForm({ ...form, systemImage: event.target.value })}
placeholder="wand.and.sparkles"
/>
</Field>
<Field label="排序">
<Input
required
type="number"
min={0}
max={100000}
value={form.sortOrder}
onChange={(event) =>
setForm({ ...form, sortOrder: Number(event.target.value) })
}
/>
</Field>
<label className="flex min-h-11 items-center gap-3 self-end rounded-xl border border-border px-4 text-sm">
<input
type="checkbox"
checked={form.thinkingEnabled}
onChange={(event) =>
setForm({ ...form, thinkingEnabled: event.target.checked })
}
/>
启用思考模式
</label>
</div>
<LocalizationFields
title="简体中文"
value={form.localizations["zh-Hans"]}
onChange={(value) =>
setForm({
...form,
localizations: { ...form.localizations, "zh-Hans": value },
})
}
/>
<LocalizationFields
title="English"
value={form.localizations.en}
onChange={(value) =>
setForm({
...form,
localizations: { ...form.localizations, en: value },
})
}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
取消
</Button>
<Button type="submit" loading={saving}>
保存
</Button>
</div>
</form>
</Dialog>
);
}
function LocalizationFields({
title,
value,
onChange,
}: {
title: string;
value: SkillLocalization;
onChange: (value: SkillLocalization) => void;
}) {
return (
<fieldset className="space-y-3 rounded-2xl border border-border p-4">
<legend className="px-2 text-sm font-semibold">{title}</legend>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="名称">
<Input
required
minLength={1}
maxLength={40}
value={value.name}
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</Field>
<Field label="摘要">
<Input
required
minLength={1}
maxLength={200}
value={value.summary}
onChange={(event) => onChange({ ...value, summary: event.target.value })}
/>
</Field>
</div>
<Field label="Prompt">
<Textarea
required
minLength={1}
maxLength={6000}
value={value.prompt}
onChange={(event) => onChange({ ...value, prompt: event.target.value })}
/>
</Field>
</fieldset>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-sm font-medium">
<span className="mb-1.5 block">{label}</span>
{children}
</label>
);
}
function emptySkill(): CreateOfficialSkillRequest {
return {
id: "official.",
systemImage: "sparkles",
sortOrder: 0,
thinkingEnabled: false,
localizations: {
"zh-Hans": { name: "", summary: "", prompt: "" },
en: { name: "", summary: "", prompt: "" },
},
};
}
function skillToRequest(skill: OfficialSkill): CreateOfficialSkillRequest {
return {
id: skill.id,
systemImage: skill.systemImage,
sortOrder: skill.sortOrder,
thinkingEnabled: skill.thinkingEnabled,
localizations: skill.localizations,
};
}
function formatHintPack(pack: AdminHintPack): string {
return JSON.stringify(
{
generatedAt: pack.generatedAt,
expiresAt: pack.expiresAt,
intervalHours: pack.intervalHours,
cards: pack.cards,
},
null,
2,
);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}