b25f5ae6e9
Keep manual editing while making save the only user action and applying changes immediately without a separate publish workflow.
511 lines
16 KiB
TypeScript
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;
|
|
}
|