Add managed content and keyboard usage insights
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
This commit is contained in:
Rocky
2026-08-21 13:34:03 +08:00
parent b5212dcdc2
commit d0abe27623
55 changed files with 4690 additions and 111 deletions
+41
View File
@@ -3,6 +3,7 @@ import type {
AdminOperatorCreateRequest,
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminHintPack,
AdminLoginResponse,
AuditQuery,
AuditLogEntry,
@@ -14,12 +15,17 @@ import type {
Overview,
PageResult,
ProductAnalyticsOverview,
CreateOfficialSkillRequest,
OfficialSkill,
OfficialSkillCatalog,
ReferralsQuery,
ReferralOverview,
SessionResponse,
UserDetail,
UsersQuery,
UserSummary,
UpdateHintPackRequest,
UpdateOfficialSkillRequest,
} from "./types";
const API_BASE = "/v1/admin";
@@ -73,6 +79,9 @@ function safeMessage(status: number, code?: string): string {
ADMIN_USERNAME_CONFLICT: "该管理员用户名已存在",
CANNOT_DISABLE_SELF: "不能停用当前登录的管理员",
LAST_SUPER_ADMIN_REQUIRED: "必须至少保留一名启用的超级管理员",
CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill",
CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在",
CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
@@ -187,6 +196,38 @@ export const adminApi = {
productAnalytics: (range: string) =>
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
contentSkills: () => request<OfficialSkillCatalog>("/content/skills"),
createContentSkill: (payload: CreateOfficialSkillRequest) =>
request<OfficialSkill>("/content/skills", {
method: "POST",
body: JSON.stringify(payload),
}),
updateContentSkill: (id: string, payload: UpdateOfficialSkillRequest) =>
request<OfficialSkill>(`/content/skills/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
setContentSkillEnabled: (id: string, enabled: boolean) =>
request<void>(
`/content/skills/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`,
{ method: "POST" },
),
contentHintPack: (locale: "zh" | "en") =>
request<AdminHintPack>(`/content/hints/${locale}`),
updateContentHintPack: (
locale: "zh" | "en",
payload: UpdateHintPackRequest,
) =>
request<AdminHintPack>(`/content/hints/${locale}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
const params =
typeof value === "string"
+89 -1
View File
@@ -11,7 +11,76 @@ export type AdminAuditAction =
| "OPERATOR_UNLOCKED"
| "OPERATOR_CREDENTIALS_RESET"
| "OPERATOR_SESSIONS_REVOKED"
| "MANUAL_CREDIT_GRANTED";
| "MANUAL_CREDIT_GRANTED"
| "CONTENT_SKILL_CREATED"
| "CONTENT_SKILL_UPDATED"
| "CONTENT_SKILL_ENABLED"
| "CONTENT_SKILL_DISABLED"
| "CONTENT_HINT_PACK_PUBLISHED";
export interface SkillLocalization {
name: string;
summary: string;
prompt: string;
}
export interface OfficialSkill {
id: string;
systemImage: string;
sortOrder: number;
kind: "transform";
thinkingEnabled: boolean;
enabled: boolean;
localizations: {
"zh-Hans": SkillLocalization;
en: SkillLocalization;
};
}
export interface OfficialSkillCatalog {
revision: number;
generatedAt?: string;
skills: OfficialSkill[];
}
export type CreateOfficialSkillRequest = Omit<
OfficialSkill,
"kind" | "enabled"
>;
export type UpdateOfficialSkillRequest = Omit<
CreateOfficialSkillRequest,
"id"
>;
export interface AIHintCard {
id: string;
displayText?: string;
text?: string;
prompt: string;
category: string;
priority: number;
source: string;
locale: "zh" | "en";
conditions: string[];
metadata?: Record<string, unknown>;
}
export interface AdminHintPack {
locale: "zh" | "en";
generatedAt?: string;
expiresAt?: string;
intervalHours?: number;
version: number;
cards: AIHintCard[];
}
export interface UpdateHintPackRequest {
generatedAt?: string;
expiresAt?: string;
intervalHours?: number;
cards: AIHintCard[];
}
export interface CursorPageQuery {
cursor?: string;
@@ -176,6 +245,25 @@ export interface ProductAnalyticsOverview {
growthFunnel: FunnelStep[];
retention: AnalyticsCohort[];
aiFeatures: AnalyticsFeatureUsage[];
keyboardUsage: {
activeUsers: number;
activationToInput: AnalyticsRate;
chineseActiveUsers: number;
englishActiveUsers: number;
bilingualActiveUsers: number;
totalCharacters: number;
chineseCharacters: number;
englishCharacters: number;
otherCharacters: number;
chineseSharePercent?: number;
englishSharePercent?: number;
inputSessions: number;
averageCharactersPerInputSession?: number;
chineseOnlySessions: number;
englishOnlySessions: number;
mixedLanguageSessions: number;
otherOnlySessions: number;
};
referralFunnel: FunnelStep[];
guardrails: {
clientAiSuccessRate: AnalyticsRate;
+14
View File
@@ -5,6 +5,7 @@ import {
ChevronRight,
Coins,
GitBranch,
LibraryBig,
LogOut,
Menu,
Moon,
@@ -65,6 +66,11 @@ const CreditsPage = lazy(() =>
default: module.CreditsPage,
})),
);
const ContentPage = lazy(() =>
import("./features/content/content-page").then((module) => ({
default: module.ContentPage,
})),
);
const AuditPage = lazy(() =>
import("./features/audit/audit-page").then((module) => ({
default: module.AuditPage,
@@ -123,6 +129,13 @@ const navigation: NavItem[] = [
icon: Coins,
roles: supportRoles,
},
{
path: "/content",
label: "内容管理",
description: "Skill 与 Hint",
icon: LibraryBig,
roles: supportRoles,
},
{
path: "/audit",
label: "审计日志",
@@ -204,6 +217,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
<>
<Route path="/users" element={<UsersPage />} />
<Route path="/credits" element={<CreditsPage />} />
<Route path="/content" element={<ContentPage />} />
</>
) : null}
{role === "SUPER_ADMIN" ? (
@@ -4,6 +4,7 @@ import {
BrainCircuit,
ChartNoAxesCombined,
Gauge,
Keyboard,
Repeat2,
Sparkles,
Target,
@@ -264,6 +265,74 @@ export function AnalyticsPage() {
</Card>
</section>
<section className="grid gap-6 xl:grid-cols-2" aria-label="键盘输入统计">
<Card className="overflow-hidden">
<SectionHeader
title="键盘中英文输入"
description="仅统计 OSGKeyboard 本地聚合后的手动提交字符,不包含输入内容"
icon={Keyboard}
/>
<ComparisonBarChart
items={[
{
label: "中文",
value: data.keyboardUsage.chineseCharacters,
hint: `占中英文 ${optionalPercent(data.keyboardUsage.chineseSharePercent)}`,
},
{
label: "英文",
value: data.keyboardUsage.englishCharacters,
hint: `占中英文 ${optionalPercent(data.keyboardUsage.englishSharePercent)}`,
},
{
label: "其他",
value: data.keyboardUsage.otherCharacters,
hint: "数字、标点与 Emoji",
},
]}
primaryLabel="提交字符数"
primaryTone="primary"
/>
<MetricRows
rows={[
["输入活跃用户", formatNumber(data.keyboardUsage.activeUsers)],
["激活到输入转化", rateLabel(data.keyboardUsage.activationToInput)],
["中文活跃用户", formatNumber(data.keyboardUsage.chineseActiveUsers)],
["英文活跃用户", formatNumber(data.keyboardUsage.englishActiveUsers)],
["中英双语用户", formatNumber(data.keyboardUsage.bilingualActiveUsers)],
["总提交字符", formatNumber(data.keyboardUsage.totalCharacters)],
]}
/>
</Card>
<Card className="overflow-hidden">
<SectionHeader
title="输入会话结构"
description="一次键盘激活期间至少提交一个字符才计为输入会话"
icon={Activity}
/>
<ComparisonBarChart
items={[
{ label: "仅中文", value: data.keyboardUsage.chineseOnlySessions },
{ label: "仅英文", value: data.keyboardUsage.englishOnlySessions },
{ label: "中英混合", value: data.keyboardUsage.mixedLanguageSessions },
{ label: "仅其他", value: data.keyboardUsage.otherOnlySessions },
]}
primaryLabel="输入会话"
primaryTone="success"
/>
<MetricRows
rows={[
["输入会话总数", formatNumber(data.keyboardUsage.inputSessions)],
[
"平均每会话字符",
optionalDecimal(data.keyboardUsage.averageCharactersPerInputSession),
],
]}
/>
</Card>
</section>
<section className="grid gap-6 xl:grid-cols-3">
<Card className="overflow-hidden">
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
@@ -0,0 +1,507 @@
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";
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 目录与 zh/en AI Hint 发布包。每次保存都会立即发布并写入审计。"
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>
<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">
JSON 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;
}
+1 -1
View File
@@ -250,7 +250,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
options={[
{ value: "", label: "全部状态" },
{ value: "active", label: "正常" },
{ value: "suspended", label: "已暂停" },
{ value: "suspended", label: "反滥用受限" },
]}
onChange={setStatus}
/>
+1 -1
View File
@@ -35,7 +35,7 @@ export function escapeHtml(value: unknown): string {
export function statusLabel(status: string): string {
const labels: Record<string, string> = {
active: "正常",
suspended: "已停用",
suspended: "反滥用受限",
closed: "已关闭",
success: "成功",
rejected: "已拒绝",
+17
View File
@@ -57,6 +57,23 @@ describe("adminApi", () => {
expect(request.credentials).toBe("include");
});
it("内容发布请求携带 CSRF 并编码 Skill ID", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, { status: 204 }),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-content");
await adminApi.setContentSkillEnabled("official.skill/with space", true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/content/skills/official.skill%2Fwith%20space/enable",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-content");
expect(request.method).toBe("POST");
});
it("登录请求不依赖已有会话 CSRF", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
+121
View File
@@ -0,0 +1,121 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type { AdminRole, OfficialSkillCatalog } from "../api/types";
import { App } from "../app";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.location.hash = "";
});
describe("内容管理", () => {
it("SUPPORT 可查看但不能修改 Skill 与 Hint", async () => {
mockSession("SUPPORT");
mockContent();
window.location.hash = "#/content";
render(<App />);
expect(await screen.findByRole("heading", { name: "内容管理" })).toBeTruthy();
expect(screen.getByRole("link", { name: /内容管理/ })).toBeTruthy();
expect(await screen.findByText("润色")).toBeTruthy();
expect(screen.getByText("只读访问")).toBeTruthy();
expect(screen.queryByRole("button", { name: "新增 Skill" })).toBeNull();
const editor = await screen.findByLabelText("zh JSON");
expect(editor).toHaveProperty("readOnly", true);
expect(screen.queryByRole("button", { name: "保存并立即发布" })).toBeNull();
});
it("SUPER_ADMIN 可启停官方 Skill", async () => {
mockSession("SUPER_ADMIN");
mockContent();
const mutation = vi
.spyOn(adminApi, "setContentSkillEnabled")
.mockResolvedValue(undefined);
window.location.hash = "#/content";
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "停用" }));
await waitFor(() => {
expect(mutation).toHaveBeenCalledWith("official.polish", false);
});
expect(screen.getByRole("button", { name: "新增 Skill" })).toBeTruthy();
});
it("Skill 编辑器约束与客户端边界一致", async () => {
mockSession("SUPER_ADMIN");
mockContent();
window.location.hash = "#/content";
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "新增 Skill" }));
expect(screen.getByLabelText("Skill ID").getAttribute("maxlength")).toBe("100");
expect(screen.getByLabelText("SF Symbol").getAttribute("maxlength")).toBe("100");
const sortOrder = screen.getByLabelText("排序");
expect(sortOrder.getAttribute("min")).toBe("0");
expect(sortOrder.getAttribute("max")).toBe("100000");
screen.getAllByLabelText("名称").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("40");
});
screen.getAllByLabelText("摘要").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("200");
});
screen.getAllByLabelText("Prompt").forEach((input) => {
expect(input.getAttribute("maxlength")).toBe("6000");
});
});
});
function mockSession(role: AdminRole) {
vi.spyOn(adminApi, "session").mockResolvedValue({
authenticated: true,
operatorName: "owner",
role,
});
}
function mockContent() {
vi.spyOn(adminApi, "contentSkills").mockResolvedValue(catalog());
vi.spyOn(adminApi, "contentHintPack").mockResolvedValue({
locale: "zh",
generatedAt: "2026-08-21T04:00:00Z",
intervalHours: 12,
version: 2,
cards: [],
});
}
function catalog(): OfficialSkillCatalog {
return {
revision: 3,
generatedAt: "2026-08-21T04:00:00Z",
skills: [
{
id: "official.polish",
systemImage: "wand.and.sparkles",
sortOrder: 10,
kind: "transform",
thinkingEnabled: false,
enabled: true,
localizations: {
"zh-Hans": {
name: "润色",
summary: "优化表达",
prompt: "请润色",
},
en: {
name: "Polish",
summary: "Improve wording",
prompt: "Please polish",
},
},
},
],
};
}
+4
View File
@@ -32,6 +32,10 @@ describe("format helpers", () => {
expect(statusLabel("custom")).toBe("custom");
});
it("反滥用限制不会误显示为账户停用", () => {
expect(statusLabel("suspended")).toBe("反滥用受限");
});
it("消费类型使用清晰的中文标签", () => {
expect(usageTypeLabel("polish")).toBe("润色");
expect(usageTypeLabel("hotword")).toBe("热词");
+22
View File
@@ -229,6 +229,9 @@ describe("React 管理页面", () => {
expect(screen.getByText(/较上周 \+12\.5%/)).toBeTruthy();
expect(screen.getByText("2026-08-01")).toBeTruthy();
expect(screen.getByText("文字润色")).toBeTruthy();
expect(screen.getByText("键盘中英文输入")).toBeTruthy();
expect(screen.getByText("中文活跃用户")).toBeTruthy();
expect(screen.getByText("中英混合")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
});
@@ -331,6 +334,25 @@ function analyticsOverview(): ProductAnalyticsOverview {
aiFeatures: [
{ feature: "POLISH", executionMode: "MANAGED", users: 30, successes: 100 },
],
keyboardUsage: {
activeUsers: 40,
activationToInput: rate(40, 50, 80),
chineseActiveUsers: 30,
englishActiveUsers: 20,
bilingualActiveUsers: 10,
totalCharacters: 12_000,
chineseCharacters: 7_000,
englishCharacters: 4_000,
otherCharacters: 1_000,
chineseSharePercent: 63.6,
englishSharePercent: 36.4,
inputSessions: 200,
averageCharactersPerInputSession: 60,
chineseOnlySessions: 100,
englishOnlySessions: 60,
mixedLanguageSessions: 30,
otherOnlySessions: 10,
},
referralFunnel: [
{ label: "发起分享", count: 20 },
{ label: "完成奖励", count: 5 },