diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index f74442c..ad72afd 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -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(`/analytics${encodeQuery({ range })}`), + contentSkills: () => request("/content/skills"), + + createContentSkill: (payload: CreateOfficialSkillRequest) => + request("/content/skills", { + method: "POST", + body: JSON.stringify(payload), + }), + + updateContentSkill: (id: string, payload: UpdateOfficialSkillRequest) => + request(`/content/skills/${encodeURIComponent(id)}`, { + method: "PUT", + body: JSON.stringify(payload), + }), + + setContentSkillEnabled: (id: string, enabled: boolean) => + request( + `/content/skills/${encodeURIComponent(id)}/${enabled ? "enable" : "disable"}`, + { method: "POST" }, + ), + + contentHintPack: (locale: "zh" | "en") => + request(`/content/hints/${locale}`), + + updateContentHintPack: ( + locale: "zh" | "en", + payload: UpdateHintPackRequest, + ) => + request(`/content/hints/${locale}`, { + method: "PUT", + body: JSON.stringify(payload), + }), + users: (value: string | UsersQuery = "", legacyCursor?: string) => { const params = typeof value === "string" diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index f406377..f53b193 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -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; +} + +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; diff --git a/admin-web/src/app.tsx b/admin-web/src/app.tsx index 1915b5c..a38bf8e 100644 --- a/admin-web/src/app.tsx +++ b/admin-web/src/app.tsx @@ -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 }) { <> } /> } /> + } /> ) : null} {role === "SUPER_ADMIN" ? ( diff --git a/admin-web/src/features/analytics/analytics-page.tsx b/admin-web/src/features/analytics/analytics-page.tsx index b9c88d6..026fc6f 100644 --- a/admin-web/src/features/analytics/analytics-page.tsx +++ b/admin-web/src/features/analytics/analytics-page.tsx @@ -4,6 +4,7 @@ import { BrainCircuit, ChartNoAxesCombined, Gauge, + Keyboard, Repeat2, Sparkles, Target, @@ -264,6 +265,74 @@ export function AnalyticsPage() { +
+ + + + + + + + + + + +
+
diff --git a/admin-web/src/features/content/content-page.tsx b/admin-web/src/features/content/content-page.tsx new file mode 100644 index 0000000..c78e017 --- /dev/null +++ b/admin-web/src/features/content/content-page.tsx @@ -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(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [editingSkill, setEditingSkill] = useState(); + const [busySkillId, setBusySkillId] = useState(); + const [locale, setLocale] = useState("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; + 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 void loadSkills()} />; + if (loading || !catalog) return ; + + return ( +
+ setEditingSkill("new")}> + + 新增 Skill + + ) : ( + 只读访问 + ) + } + /> + +
+
+
+

+ 官方 Skill +

+

+ 当前 revision {catalog.revision} · 共 {catalog.skills.length} 项 +

+
+ +
+ + {catalog.skills.length === 0 ? ( + + + + ) : ( +
+ {catalog.skills.map((skill) => ( + +
+ + + +
+
+

{skill.localizations["zh-Hans"].name}

+ + {skill.enabled ? "已启用" : "已停用"} + + {skill.thinkingEnabled ? 思考 : null} +
+

+ {skill.localizations["zh-Hans"].summary} +

+

+ {skill.id} · {skill.systemImage} · 排序 {skill.sortOrder} +

+ {canEdit ? ( +
+ + +
+ ) : null} +
+
+
+ ))} +
+ )} +
+ +
+
+

+ AI Hint packs +

+

+ JSON 必须符合 AIHintPack 卡片字段;服务端自动递增 version。 +

+
+ +
+ {(["zh", "en"] as HintLocale[]).map((item) => ( + + ))} + + version {hintVersion} + +
+
+ {hintLoading ? ( + + ) : ( + <> +