feat(web): B4 枚举标签映射 — labelFor 未知值原样显示
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 lib/enums.ts:platform/mood 标签 + labelFor(kind,value),未知值原样返回(display-raw 红线),注释标明权威来源是 GET /api/v1/meta/enums
- 4 处 mood 渲染改用标签(Nav/TopBar/chat×2);未动 types.ts 的 MODES/Mode
- 新增纯逻辑测试 enums.test.ts(47 项,esbuild+node,零新依赖)
- 未动 admin channelFields 的第二份 PLATFORM_LABEL(记入待办)
This commit is contained in:
AskaEth committed 2026-10-03 16:55:22 +08:00
1 parent 22e8398110
commit f42ea1e41c
5 files changed
+151 -4

No files matched your search

+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { createSubSession, getAuthToken } from "../lib/api";
import { labelFor } from "../lib/enums";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
@@ -86,7 +87,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
{mood ? (
<div className="px-2 py-1 text-[11px] text-[var(--mu-text-lo)]">
心情 · {mood}
心情 · {labelFor("mood", mood)}
</div>
) : null}
+2 -1
View File
@@ -3,6 +3,7 @@ import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { personaAvatar } from "../lib/persona";
import { labelFor } from "../lib/enums";
import type { Mode, WsStatus } from "../lib/types";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
@@ -67,7 +68,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
<div className="flex-1" />
{mood ? <Tag tone="accent" className="hidden sm:inline-flex">心情 · {mood}</Tag> : null}
{mood ? <Tag tone="accent" className="hidden sm:inline-flex">心情 · {labelFor("mood", mood)}</Tag> : null}
<AwakenBadge />
+3 -2
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
import { useAppStore } from "../store/useAppStore";
import { personaAvatar } from "../lib/persona";
import { labelFor } from "../lib/enums";
import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { WakePath } from "./status";
@@ -261,7 +262,7 @@ function AssistantParaBubble({
<div className="min-w-0 max-w-[85%]">
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
{full && message.mood ? (
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
) : null}
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
{full ? <WakePath active={active} /> : null}
@@ -402,7 +403,7 @@ function AssistantBubble({
<div className="min-w-0 max-w-[85%]">
<ModeTag mode={mode} />
{message.mood ? (
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {labelFor("mood", message.mood)}</div>
) : null}
<SourceLabel deviceId={message.deviceId} role={message.role} />
<WakePath active={active} />
+89
View File
@@ -0,0 +1,89 @@
// Logic test for the Web-side enum mirror (Android collaboration B4, design/07
// §6): the platform/mood id → label mapping and, above all, the unknown-value
// fallback red line — an unlisted value must come back raw, never dropped and
// never a throw. The project has no frontend test runner, so this is a
// dependency-free plain-node check that drives the real `labelFor` and throws on
// the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/enums.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-enums.test.mjs \
// && node /tmp/cyrene-enums.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import { labelFor, MOOD_LABELS, PLATFORM_LABELS } from "./enums";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq(got: string, want: string, what: string): void {
ok(got === want, `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
}
// Every known platform id resolves to its default Chinese label.
const platformCases: Array<[string, string]> = [
["web", "网页"],
["kook", "KOOK"],
["qq-obv11", "QQ"],
["weixin-ilink", "微信"],
["telegram", "Telegram"],
["webhook", "Webhook"],
];
for (const [id, label] of platformCases) {
eq(labelFor("platform", id), label, `platform ${id} → ${label}`);
}
// Every known mood id resolves to its default Chinese label.
const moodCases: Array<[string, string]> = [
["calm", "平静"],
["happy", "开心"],
["excited", "兴奋"],
["sad", "低落"],
["angry", "生气"],
];
for (const [id, label] of moodCases) {
eq(labelFor("mood", id), label, `mood ${id} → ${label}`);
}
// The mirrored tables are complete and non-empty (a label is always available).
eq(String(Object.keys(PLATFORM_LABELS).length), "6", "platform table size");
eq(String(Object.keys(MOOD_LABELS).length), "5", "mood table size");
for (const kind of ["platform", "mood"] as const) {
const source = kind === "platform" ? PLATFORM_LABELS : MOOD_LABELS;
for (const [id, label] of Object.entries(source)) {
eq(labelFor(kind, id), label, `${kind} table entry ${id}`);
ok(label !== "", `${kind} ${id} has a non-empty label`);
}
}
// RED LINE (display-raw): an unknown value comes back exactly as received.
eq(labelFor("mood", "sleepy"), "sleepy", "unknown mood returns raw");
eq(labelFor("platform", "discord"), "discord", "unknown platform returns raw");
eq(labelFor("mood", "此刻心情很好"), "此刻心情很好", "unknown non-ASCII mood returns raw");
eq(labelFor("platform", "web:v2"), "web:v2", "unknown id-shaped value returns raw");
eq(labelFor("mood", "<script>alert(1)</script>"), "<script>alert(1)</script>", "unknown markup returns raw");
// Case variants are not contract ids, so they fall through to display-raw
// without throwing.
eq(labelFor("mood", "CALM"), "CALM", "uppercase mood returns raw");
eq(labelFor("platform", "Telegram"), "Telegram", "mixed-case platform returns raw");
// Blank / absent / whitespace values must not crash. Whitespace is preserved
// verbatim (no trimming — the function only maps, never rewrites).
eq(labelFor("mood", ""), "", "empty mood");
eq(labelFor("platform", ""), "", "empty platform");
eq(labelFor("mood", undefined), "", "undefined mood");
eq(labelFor("mood", null), "", "null mood");
eq(labelFor("mood", " calm "), " calm ", "padded mood returns raw");
console.log(`enums.test.ts OK — ${checks} checks`);
+55
View File
@@ -0,0 +1,55 @@
/**
* Web-side mirror of the shared client enumerations (Android collaboration B4,
* design/07 §6).
*
* The authority is the backend: `GET /api/v1/meta/enums` publishes
* `{platforms, moods, modes, unknown}` as `{id,label}` pairs. This module mirrors
* the platform and mood tables (the mode union already lives in `types.ts` as
* `MODES`/`Mode` and is deliberately not duplicated here); a client with i18n may
* override any label.
*
* RED LINE — unknown-value fallback (the published `unknown: "display-raw"`): a
* value that is not in the table is returned AS RECEIVED and displayed raw.
* Never drop it, never throw, never substitute a placeholder. The backend adding a
* value is a non-breaking contract change, so a client that lags must degrade
* gracefully instead of losing the badge/label.
*/
/** The enum tables the Web client renders labels for. */
export type EnumKind = "platform" | "mood";
/** Platform id → default Chinese label. Mirrors the `platforms` table. */
export const PLATFORM_LABELS: Readonly<Record<string, string>> = {
web: "网页",
kook: "KOOK",
"qq-obv11": "QQ",
"weixin-ilink": "微信",
telegram: "Telegram",
webhook: "Webhook",
};
/** Mood id → default Chinese label. Mirrors the `moods` table. */
export const MOOD_LABELS: Readonly<Record<string, string>> = {
calm: "平静",
happy: "开心",
excited: "兴奋",
sad: "低落",
angry: "生气",
};
/** The id → label table of one enum kind. */
function table(kind: EnumKind): Readonly<Record<string, string>> {
return kind === "platform" ? PLATFORM_LABELS : MOOD_LABELS;
}
/**
* Resolve `value` to its default display label. A known id yields the table's
* label; anything else — an unknown id, an empty/blank string, a value whose
* case differs from the lowercase contract id — yields `value` unchanged
* (display-raw), and an absent value yields `""`. This never throws and never
* drops a value: see the unknown-value red line above.
*/
export function labelFor(kind: EnumKind, value: string | null | undefined): string {
if (!value) return "";
return table(kind)[value] ?? value;
}