diff --git a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md b/docs/backend/FRONTEND_MULTI_CHARACTER_API.md index 28b43f6b..2b961b7c 100644 --- a/docs/backend/FRONTEND_MULTI_CHARACTER_API.md +++ b/docs/backend/FRONTEND_MULTI_CHARACTER_API.md @@ -16,7 +16,7 @@ | 类型 | Elio 示例 | 用途 | | --- | --- | --- | -| `id` | `character_elio` | 数据库关联、API 请求和缓存隔离 | +| `id` | `elio` | 数据库关联、API 请求和缓存隔离 | | `slug` | `elio` | 前端 URL 和外部入口 | 后端业务接口必须使用 `id`,不能使用可能变化的展示名称。 @@ -37,9 +37,9 @@ | `id` | `slug` | `displayName` | 头像 | 页面封面 | 私密空间 Banner | | --- | --- | --- | --- | --- | --- | -| `character_elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` | -| `character_maya` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` | -| `character_nayeli` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` | +| `elio` | `elio` | Elio Silvestri | `/images/avatar/elio.png` | `/images/cover/elio.png` | `/images/private-room/banner/elio.png` | +| `maya-tan` | `maya` | Maya Tan | `/images/avatar/maya.png` | `/images/cover/maya.png` | `/images/private-room/banner/maya.png` | +| `nayeli-cervantes` | `nayeli` | Nayeli Cervantes | `/images/avatar/nayeli.png` | `/images/cover/nayeli.png` | `/images/private-room/banner/nayeli.png` | 当前开发环境开放表中的全部角色。Catalog 同时保存短名称、页面资源、空聊天问候语、排序及 Chat、Private Room、Tip 能力。后端配置中的角色 ID 必须与前端目录一致。 @@ -56,7 +56,7 @@ POST /api/chat/unlock-private POST /api/chat/unlock-history ``` -`characterId` 使用前端本地角色目录中的 `id`,例如 `character_elio`,不能传 slug 或显示名称。 +`characterId` 使用前端本地角色目录中的 `id`,例如 `elio`,不能传 slug 或显示名称。 后端必须使用以下维度定位聊天数据: @@ -79,7 +79,7 @@ Authorization: Bearer ```json { - "characterId": "character_elio", + "characterId": "elio", "message": "Hello Elio", "image": "", "imageId": "", @@ -105,7 +105,7 @@ Authorization: Bearer ### 3.3 获取聊天历史 ```http -GET /api/chat/history?characterId=character_elio&limit=50&offset=0 +GET /api/chat/history?characterId=elio&limit=50&offset=0 Authorization: Bearer ``` @@ -131,7 +131,7 @@ Authorization: Bearer ```json { - "characterId": "character_elio", + "characterId": "elio", "messageId": "message-id", "lockType": "voice_message", "clientLockId": "client-generated-id" @@ -164,7 +164,7 @@ Authorization: Bearer ```json { - "characterId": "character_elio" + "characterId": "elio" } ``` @@ -190,7 +190,7 @@ Authorization: Bearer "data": { "items": [ { - "characterId": "character_elio", + "characterId": "elio", "message": { "id": "message-id", "role": "assistant", @@ -220,7 +220,7 @@ Authorization: Bearer 标准请求改为: ```http -GET /api/private-room/albums?characterId=character_elio&limit=20 +GET /api/private-room/albums?characterId=elio&limit=20 Authorization: Bearer ``` @@ -266,7 +266,7 @@ POST /api/payment/create-order "planId": "tip_coffee_medium", "payChannel": "stripe", "autoRenew": false, - "recipientCharacterId": "character_elio" + "recipientCharacterId": "elio" } ``` @@ -326,9 +326,9 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归 上线新接口前执行: -1. 创建 Elio 角色,固定 `id=character_elio`、`slug=elio`。 +1. 创建 Elio 角色,固定 `id=elio`、`slug=elio`。 2. 为相关业务表增加可空 `character_id`。 -3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `character_elio`。 +3. 将现有消息、锁记录、相册和 Tip 记录全部回填为 `elio`。 4. 检查不存在空角色或无法关联的数据。 5. 创建联合索引和外键。 6. 将必要业务表的 `character_id` 调整为非空。 @@ -340,7 +340,7 @@ Tip 订单和最终支付记录需要保存 `recipient_character_id`,用于归 ### 第一阶段:后端兼容 - 上线角色 ID 配置和新字段。 -- Chat 请求缺少 `characterId` 时暂时默认 `character_elio`。 +- Chat 请求缺少 `characterId` 时暂时默认 `elio`。 - Private Room 缺少角色时暂时默认 Elio。 - Tip 旧客户端缺少 recipient 时,旧 Tip 订单暂时默认 Elio。 - 记录缺少角色参数的调用次数和客户端版本。 diff --git a/src/app/_components/__tests__/character-switcher.test.tsx b/src/app/_components/__tests__/character-switcher.test.tsx new file mode 100644 index 00000000..8070b95e --- /dev/null +++ b/src/app/_components/__tests__/character-switcher.test.tsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CHARACTERS, getCharacterBySlug } from "@/data/constants/character"; +import { CharacterCatalogProvider } from "@/providers/character-catalog-provider"; +import { CharacterProvider } from "@/providers/character-provider"; + +import { CharacterSwitcher } from "../character-switcher"; + +describe("CharacterSwitcher", () => { + it("links every chat character and marks the active account conversation", () => { + const maya = getCharacterBySlug("maya"); + expect(maya).not.toBeNull(); + + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toContain('aria-label="Choose character"'); + expect(html).toContain('href="/characters/elio/splash"'); + expect(html).toContain('href="/characters/maya/splash"'); + expect(html).toContain('href="/characters/nayeli/splash"'); + expect(html).toContain('data-character-id="elio"'); + expect(html).toContain('data-character-id="maya-tan"'); + expect(html).toContain('data-character-id="nayeli-cervantes"'); + expect(html).toMatch( + /]*data-character-id="maya-tan")(?=[^>]*aria-current="page")[^>]*>/, + ); + }); +}); diff --git a/src/app/_components/character-switcher.tsx b/src/app/_components/character-switcher.tsx new file mode 100644 index 00000000..3e210cb4 --- /dev/null +++ b/src/app/_components/character-switcher.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; + +import { getCharacterRoutes } from "@/router/routes"; +import { useActiveCharacter } from "@/providers/character-provider"; +import { useCharacterCatalog } from "@/providers/character-catalog-provider"; + +import { CharacterAvatar } from "./character-avatar"; + +export interface CharacterSwitcherProps { + destination?: "splash" | "chat"; +} + +export function CharacterSwitcher({ + destination = "splash", +}: CharacterSwitcherProps) { + const activeCharacter = useActiveCharacter(); + const catalog = useCharacterCatalog(); + const chatCharacters = catalog.characters.filter( + (character) => character.capabilities.chat, + ); + + return ( + + ); +} diff --git a/src/app/_components/core/__tests__/tailwind-components.test.tsx b/src/app/_components/core/__tests__/tailwind-components.test.tsx index 9f5c87b3..fdfffa6f 100644 --- a/src/app/_components/core/__tests__/tailwind-components.test.tsx +++ b/src/app/_components/core/__tests__/tailwind-components.test.tsx @@ -93,6 +93,15 @@ describe("core Tailwind components", () => { onPrivateRoomClick={() => undefined} />, ); + const disabledPrivateRoomHtml = renderToStaticMarkup( + undefined} + onPrivateRoomClick={() => undefined} + />, + ); expect(chatHtml).toContain('aria-label="Primary navigation"'); expect(chatHtml).toMatch(/class="[^"]*dark[^"]*"/); @@ -107,5 +116,8 @@ describe("core Tailwind components", () => { expect(privateRoomHtml).toContain( 'data-analytics-key="navigation.private_room"', ); + expect(disabledPrivateRoomHtml).toMatch( + /]*aria-disabled="true"[^>]*disabled=""[^>]*>/, + ); }); }); diff --git a/src/app/_components/core/app-bottom-nav.module.css b/src/app/_components/core/app-bottom-nav.module.css index 6b3cc2e0..38a195ba 100644 --- a/src/app/_components/core/app-bottom-nav.module.css +++ b/src/app/_components/core/app-bottom-nav.module.css @@ -80,6 +80,20 @@ outline-offset: 3px; } +.disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.disabled:hover { + background: transparent; + color: var(--nav-button-color); +} + +.disabled:active { + transform: none; +} + .active { color: var(--nav-active-color); } diff --git a/src/app/_components/core/app-bottom-nav.tsx b/src/app/_components/core/app-bottom-nav.tsx index 1b4d347d..af826212 100644 --- a/src/app/_components/core/app-bottom-nav.tsx +++ b/src/app/_components/core/app-bottom-nav.tsx @@ -11,6 +11,7 @@ export interface AppBottomNavProps { activeItem?: AppBottomNavItem | null; variant?: AppBottomNavVariant; privateRoomLabel: string; + privateRoomEnabled?: boolean; onChatClick: () => void; onPrivateRoomClick: () => void; } @@ -19,6 +20,7 @@ export function AppBottomNav({ activeItem = null, variant = "warm", privateRoomLabel, + privateRoomEnabled = true, onChatClick, onPrivateRoomClick, }: AppBottomNavProps) { @@ -39,8 +41,13 @@ export function AppBottomNav({ type="button" data-analytics-key="navigation.private_room" data-analytics-label="Private room navigation" - className={getButtonClass(activeItem === "privateRoom")} + className={getButtonClass( + activeItem === "privateRoom", + !privateRoomEnabled, + )} aria-current={activeItem === "privateRoom" ? "page" : undefined} + aria-disabled={!privateRoomEnabled} + disabled={!privateRoomEnabled} onClick={onPrivateRoomClick} >