feat(web): B4 枚举标签映射 — labelFor 未知值原样显示
- 新增 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:
1 parent
22e8398110
commit
f42ea1e41c
5 files changed
+151
-4
No files matched your search
@@ -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}
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user