深入浅出UI组件设计
所有组件均基于 shadcn/ui 构建,这意味着它们天然地拥有良好的可访问性(ARIA、键盘导航),让我们能更专注于业务逻辑和用户体验。

1. 宏观架构:
首先,让我们从宏观上把握应用的整体布局。如下图所示,整个应用的骨架由 Layout 组件负责搭建,它包含了静态的导航区(Header、Sidebar)和动态变化的主内容区(Main)。
#mermaid-svg-IwUZnFotlDQVzOsB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IwUZnFotlDQVzOsB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IwUZnFotlDQVzOsB .error-icon{fill:#552222;}#mermaid-svg-IwUZnFotlDQVzOsB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IwUZnFotlDQVzOsB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IwUZnFotlDQVzOsB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IwUZnFotlDQVzOsB .marker.cross{stroke:#333333;}#mermaid-svg-IwUZnFotlDQVzOsB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IwUZnFotlDQVzOsB p{margin:0;}#mermaid-svg-IwUZnFotlDQVzOsB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IwUZnFotlDQVzOsB .cluster-label text{fill:#333;}#mermaid-svg-IwUZnFotlDQVzOsB .cluster-label span{color:#333;}#mermaid-svg-IwUZnFotlDQVzOsB .cluster-label span p{background-color:transparent;}#mermaid-svg-IwUZnFotlDQVzOsB .label text,#mermaid-svg-IwUZnFotlDQVzOsB span{fill:#333;color:#333;}#mermaid-svg-IwUZnFotlDQVzOsB .node rect,#mermaid-svg-IwUZnFotlDQVzOsB .node circle,#mermaid-svg-IwUZnFotlDQVzOsB .node ellipse,#mermaid-svg-IwUZnFotlDQVzOsB .node polygon,#mermaid-svg-IwUZnFotlDQVzOsB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IwUZnFotlDQVzOsB .rough-node .label text,#mermaid-svg-IwUZnFotlDQVzOsB .node .label text,#mermaid-svg-IwUZnFotlDQVzOsB .image-shape .label,#mermaid-svg-IwUZnFotlDQVzOsB .icon-shape .label{text-anchor:middle;}#mermaid-svg-IwUZnFotlDQVzOsB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IwUZnFotlDQVzOsB .rough-node .label,#mermaid-svg-IwUZnFotlDQVzOsB .node .label,#mermaid-svg-IwUZnFotlDQVzOsB .image-shape .label,#mermaid-svg-IwUZnFotlDQVzOsB .icon-shape .label{text-align:center;}#mermaid-svg-IwUZnFotlDQVzOsB .node.clickable{cursor:pointer;}#mermaid-svg-IwUZnFotlDQVzOsB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IwUZnFotlDQVzOsB .arrowheadPath{fill:#333333;}#mermaid-svg-IwUZnFotlDQVzOsB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IwUZnFotlDQVzOsB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IwUZnFotlDQVzOsB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IwUZnFotlDQVzOsB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IwUZnFotlDQVzOsB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IwUZnFotlDQVzOsB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IwUZnFotlDQVzOsB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IwUZnFotlDQVzOsB .cluster text{fill:#333;}#mermaid-svg-IwUZnFotlDQVzOsB .cluster span{color:#333;}#mermaid-svg-IwUZnFotlDQVzOsB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IwUZnFotlDQVzOsB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IwUZnFotlDQVzOsB rect.text{fill:none;stroke-width:0;}#mermaid-svg-IwUZnFotlDQVzOsB .icon-shape,#mermaid-svg-IwUZnFotlDQVzOsB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IwUZnFotlDQVzOsB .icon-shape p,#mermaid-svg-IwUZnFotlDQVzOsB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IwUZnFotlDQVzOsB .icon-shape .label rect,#mermaid-svg-IwUZnFotlDQVzOsB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IwUZnFotlDQVzOsB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IwUZnFotlDQVzOsB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IwUZnFotlDQVzOsB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} App
Layout
Header
Sidebar
Main
GameScreen
TopBar
MainPanel
BottomBar
动态加载
CombatPanel
DiplomacyPanel
AstrologyPanel
...
- Layout (
src/app/layout.tsx) : 这是整个应用的根基。它有两个重要职责:- 全局状态提供者 :用
GameProvider包裹所有子组件,让游戏的核心状态(如资源、回合数)无处不在。 - 全局特性支持 :负责主题切换(暗色/亮色),并设有一个
<ErrorBoundary>。这就像一个安全网,确保某个小部件出错时,不会导致整个游戏页面白屏。
- 全局状态提供者 :用
- GameScreen (
src/components/game/*) : 这是游戏的核心舞台,它被划分为三个主要区域:TopBar: 玩家状态的"仪表盘",展示资源、回合、季节。MainPanel: 动态内容的"容器",根据玩家点击侧边栏的菜单,切换显示战争、外交等核心玩法面板。BottomBar: 玩家与AI交互的"控制台",提供快捷行动按钮和自由输入框。
2. 核心交互与数据流转
理解了静态的布局,我们来看看动态的数据是如何驱动界面变化的。下图清晰地展示了从前端UI操作到后端处理,再到界面更新的完整闭环。
后端API (/api/game/action) SSE (EventSource) Zustand Store UI组件 (BottomBar) 后端API (/api/game/action) SSE (EventSource) Zustand Store UI组件 (BottomBar) #mermaid-svg-QT9boV5qCmq6iwNu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QT9boV5qCmq6iwNu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QT9boV5qCmq6iwNu .error-icon{fill:#552222;}#mermaid-svg-QT9boV5qCmq6iwNu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QT9boV5qCmq6iwNu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QT9boV5qCmq6iwNu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QT9boV5qCmq6iwNu .marker.cross{stroke:#333333;}#mermaid-svg-QT9boV5qCmq6iwNu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QT9boV5qCmq6iwNu p{margin:0;}#mermaid-svg-QT9boV5qCmq6iwNu .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QT9boV5qCmq6iwNu text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QT9boV5qCmq6iwNu .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QT9boV5qCmq6iwNu .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QT9boV5qCmq6iwNu .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QT9boV5qCmq6iwNu .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QT9boV5qCmq6iwNu #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QT9boV5qCmq6iwNu .sequenceNumber{fill:white;}#mermaid-svg-QT9boV5qCmq6iwNu #sequencenumber{fill:#333;}#mermaid-svg-QT9boV5qCmq6iwNu #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QT9boV5qCmq6iwNu .messageText{fill:#333;stroke:none;}#mermaid-svg-QT9boV5qCmq6iwNu .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QT9boV5qCmq6iwNu .labelText,#mermaid-svg-QT9boV5qCmq6iwNu .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QT9boV5qCmq6iwNu .loopText,#mermaid-svg-QT9boV5qCmq6iwNu .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QT9boV5qCmq6iwNu .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QT9boV5qCmq6iwNu .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QT9boV5qCmq6iwNu .noteText,#mermaid-svg-QT9boV5qCmq6iwNu .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QT9boV5qCmq6iwNu .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QT9boV5qCmq6iwNu .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QT9boV5qCmq6iwNu .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QT9boV5qCmq6iwNu .actorPopupMenu{position:absolute;}#mermaid-svg-QT9boV5qCmq6iwNu .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QT9boV5qCmq6iwNu .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QT9boV5qCmq6iwNu .actor-man circle,#mermaid-svg-QT9boV5qCmq6iwNu line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QT9boV5qCmq6iwNu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 玩家发起一个行动 2. 后端处理与推送 3. 异步事件广播 (如回合结束) 调用 dispatchActionPOST 请求 (choiceId, input)返回 ActionResult (新状态, 新选项)更新本地状态触发UI重新渲染推送事件 (turnResult, achievement)接收事件,调用 fetchLatest拉取最新完整状态返回最新 GameState合并状态并更新再次触发UI重新渲染
关键环节详解:
- UI发起行动 : 玩家点击
BottomBar上的按钮,会调用全局Store里定义的dispatchAction方法。 - 请求与响应 :
dispatchAction向后端API发送请求。后端处理完毕后,会立即返回本次行动带来的所有变化(例如"民心+5"、"获得新选择项")。Store会同步更新这些变化,让界面立刻反馈,无需等待后续事件。 - 异步事件广播 : 对于非即时响应的事件(如一个回合彻底结束、解锁了新成就),后端通过 Server-Sent Events (SSE) 主动推送给前端。前端收到通知后,会调用
fetchLatest拉取最新的完整游戏状态,确保所有数据与服务器完全同步。
状态管理的核心:Store (src/lib/game/store.ts)
我们使用 Zustand + Immer 来管理全局状态,这是一个简洁高效的方案。
-
性能优化 : UI组件使用
useGameStore(state => state.xxx)精准订阅自己关心的数据。例如,TopBar只订阅worldState,而CombatPanel只订阅characters。当worldState变化时,只有TopBar会重新渲染,其他面板不受影响。 -
状态结构(简化示例) :
tsinterface GameStore { sessionId: string; turn: number; season: string; worldState: { morale: number; troops: number; gold: number }; // ... 其他状态 dispatchAction: (choiceId: string, input: string) => Promise<void>; fetchLatest: () => Promise<void>; }
3. 核心组件"说明书"
接下来,我们将逐一解读图1中的关键UI组件,它们各司其职,共同构成了完整的游戏界面。
3.1 TopBar:玩家的实时仪表盘
- 职责: 无死角地展示玩家最关心的全局信息。
- 实现细节 :
- 数据源 : 通过
useGameState()Hook实时读取store中的worldState、turn、season。 - 体验增强 : 当某项资源(如
gold)一次性增减超过10%时,会触发framer-motion库实现的闪光动画。这种细微的视觉反馈,能让玩家对资源变化更加敏感。
- 数据源 : 通过
3.2 BottomBar:玩家的智能指挥台
- 职责: 提供与游戏世界交互的统一入口。
- 工作流程 :
- 动态选项 : 这里的4个按钮(A/B/C/D)是根据后端LLM返回的
choices动态生成的。这意味着游戏的可选行动永远智能且符合当前剧情。 - 自由输入 : 点击"自由输入"按钮会弹出一个
Dialog对话框。这是玩家打破预设框架,发挥创造力的入口。输入的内容会被发送到后端的processAction接口。 - 决策辅助 : 鼠标悬停在某个选项旁的"蝴蝶"图标上时,会通过
Tooltip组件展示一条潜台词(butterflyHint),例如:"此举可能会影响西域诸国对我们的看法"。这帮助玩家做出更具远见的决策。
- 动态选项 : 这里的4个按钮(A/B/C/D)是根据后端LLM返回的
3.3 核心玩法面板 (Game Panels)
这些是MainPanel下动态切换的复杂组件,每个都独立负责一项核心玩法。
| 面板 | 文件位置 | 核心职责 | 关键Hook / 逻辑 |
|---|---|---|---|
| 战争面板 | combat-panel.tsx |
展示战况、兵力对比、地形利弊,并提供"结算战斗"按钮。 | useCombatLog: 拉取最近3回合的战斗日志,并支持分页和虚拟滚动。 |
| 外交面板 | diplomacy-panel.tsx |
列出所有可互动的势力,发起结盟、宣战等提案。 | useAlliances: 从Alliance数据表读取盟友关系,避免重复请求。 |
| 星象面板 | astrology-panel.tsx |
展示当前星象和预言任务,玩家可消耗资源触发占卜。 | useAstrology: 管理预言进度,并提供激活占卜的action。 |
| 政策面板 | policy-panel.tsx |
展示可解锁或已激活的政策卡牌。 | usePolicies: 提供activatePolicy方法,并自动处理激活后的冷却时间。 |
| 成就面板 | achievement-panel.tsx |
用折叠面板(Accordion)清晰展示已解锁和未解锁的成就。 |
useAchievements: 搭配useSWR缓存数据,减少不必要的API调用。 |
4. 关注点分离:通用与通用组件
为了保持代码的整洁和一致性,我们将所有与游戏逻辑无关的纯UI组件都放在 src/components/ui/ 下。
- 基础组件 :
Button、Dialog、Tooltip等,都进行了统一样式封装(例如品牌色bg-primary),确保整个游戏界面风格统一。 - 复杂模式组件 :
Accordion、Tabs用于整理信息密集型面板(如成就、星象预测)。 - 用户反馈组件 :
Toast通过useToastHook调用,在成就解锁、回合结束等关键时刻,以非侵入式弹窗给予玩家即时反馈。
5. 点睛之笔:可访问性与性能优化
一个好的产品不仅要功能强大,还要能被所有人流畅、舒适地使用。
可访问性 (Accessibility)
- 语义化与键盘导航 : 所有可交互元素都设置了合适的
aria-label,并且Tab键的跳转顺序严格遵循视觉布局。 - 屏幕阅读器支持 :
AlertDialog明确使用了role="alertdialog"和aria-describedby,确保错误信息能被正确朗读。 - 移动端适配 :
Tooltip在触摸屏上通过长按触发,避免在滑动时频繁误弹出。 - 视觉对比度: 主题色严格遵循WCAG AA标准,确保文本与背景的对比度大于4.5:1,让色弱或视力不佳的用户也能看清内容。
性能优化策略
| 优化场景 | 具体措施 | 带来的好处 |
|---|---|---|
| 首屏加载速度 | 使用Next.js的 dynamic import 懒加载非立即可见的面板(如CombatPanel)。 |
减少初始需要下载和解析的JavaScript代码量,让玩家更快看到游戏主界面。 |
| 渲染效率 | 使用 useMemo 缓存组件(如角色列表)的计算结果,避免在父组件更新时进行无意义的重复渲染。 |
保持交互流畅,尤其是在回合切换等复杂状态变更时。 |
| 网络开销 | 后端对SSE事件进行聚合(例如,每回合只推送一次 turnResult),而非推送多次微小变更。 |
减少前端的渲染次数和后端的负担,降低网络拥塞。 |
| 大数据量展示 | 战斗日志(CombatLog)接口支持分页,前端使用 react-window 实现虚拟滚动。 |
即使有成千上万条战斗记录,界面也能保持丝般顺滑,DOM节点数量可控。 |