Add managed content and keyboard usage insights
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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: "已拒绝",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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("热词");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user