feat: add mobile interaction prototype
@@ -2,7 +2,7 @@
|
||||
|
||||
> 文档状态:方案基线
|
||||
> 最后核验:2026-08-04
|
||||
> 当前阶段:Android 工程尚未初始化,需求与工程约束已建立
|
||||
> 当前阶段:P-1 高保真交互原型已产出、待视觉确认;Android 工程尚未初始化
|
||||
|
||||
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
||||
|
||||
@@ -19,6 +19,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
||||
| [产品规格](product-spec.md) | 判断功能范围和验收结果时 | 目标、非目标、需求编号、MVP 边界 |
|
||||
| [领域规则](domain-rules.md) | 修改投币、六爻、卦象映射时 | 唯一允许的起卦算法与不变量 |
|
||||
| [UX 与东方视觉](ux-design.md) | 修改页面、文案、动画和主题时 | 用户流程、文化表达、无障碍标准 |
|
||||
| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
|
||||
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
|
||||
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
|
||||
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
|
||||
@@ -31,7 +32,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
|
||||
## 推荐阅读路径
|
||||
|
||||
- 实现起卦:本页 → [领域规则](domain-rules.md) → [系统架构](architecture.md) → [质量门禁](quality-gates.md)
|
||||
- 实现界面:本页 → [产品规格](product-spec.md) → [UX 与东方视觉](ux-design.md) → [系统架构](architecture.md)
|
||||
- 实现界面:本页 → [产品规格](product-spec.md) → [UX 与东方视觉](ux-design.md) → [移动端交互原型](prototype.md) → [系统架构](architecture.md)
|
||||
- 接入 AI:本页 → [AI 解释与安全](ai-safety.md) → [数据与内容](data-content.md) → [质量门禁](quality-gates.md)
|
||||
- 初始化/构建:本页 → [本地开发环境](environment.md) → [系统架构](architecture.md) → [实施计划](implementation-plan.md)
|
||||
- 修复缺陷:本页 → [失败记忆](failure-memory.md) → 对应领域文档 → [质量门禁](quality-gates.md)
|
||||
|
||||
@@ -90,6 +90,17 @@
|
||||
- 原因:不同解释传统存在差异,产品不应把一种裁决算法伪装成唯一事实。
|
||||
- 后果:AI 可以组织内容,但输入和界面保留全部动爻。
|
||||
|
||||
## ADR-011:先确认可点击原型,再固化 Compose 页面
|
||||
|
||||
- 状态:`Accepted`
|
||||
- 日期:2026-08-04
|
||||
- 关联:[移动端交互原型](prototype.md)、[实施计划 P-1](implementation-plan.md#2-p-1可点击原型与视觉确认)
|
||||
- 决定:Android 工程初始化前先产出可点击高保真原型和关键状态截图,由用户确认视觉与流程后再实现 Compose 页面。
|
||||
- 原因:手动录入、结果层级、AI 同意门和东方文化表达都具有较高体验返工成本,先在无构建成本的原型中验证更易调整。
|
||||
- 备选:Android 骨架初始化后直接实现 Compose;低保真线框图。
|
||||
- 后果:原型是体验契约与评审证据,但不是生产领域代码;P1 必须独立实现和穷举验证算法,P3 复用已确认的设计令牌与状态关系。
|
||||
- 复审条件:用户否定当前流程或目标平台发生变化。
|
||||
|
||||
## 未决问题
|
||||
|
||||
| ID | 问题 | 推荐默认 | 阻塞阶段 |
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
| `core.autocrlf` | `true` |
|
||||
| `core.longpaths` | 未显式设置 |
|
||||
| Python | `3.10.11`,`C:\Python310\python.exe` |
|
||||
| Node.js / npm | `22.22.1` / `11.12.1` |
|
||||
| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
|
||||
|
||||
环境文件写入前,仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。这些属于 Android 工程初始化阶段的交付物,不应被误认为已存在。
|
||||
|
||||
@@ -145,6 +147,7 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
||||
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。
|
||||
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
|
||||
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
|
||||
8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
|
||||
|
||||
## 10. 已知缺口
|
||||
|
||||
@@ -166,6 +169,8 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
|
||||
Get-CimInstance Win32_OperatingSystem
|
||||
$PSVersionTable
|
||||
git --version
|
||||
node --version
|
||||
npm --version
|
||||
java -version
|
||||
javac -version
|
||||
Get-ChildItem $env:ANDROID_HOME\platforms -Directory
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# 分阶段实施计划
|
||||
|
||||
> 状态:未开始
|
||||
> 计划原则:先锁定确定性领域核心,再接内容和 UI,最后接网络 AI
|
||||
> 状态:P-1 高保真交互原型已完成,待用户视觉确认;P0 尚未开始
|
||||
> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
|
||||
|
||||
## 1. 依赖图
|
||||
|
||||
```text
|
||||
P-1 可点击原型与视觉确认
|
||||
↓
|
||||
P0 工程骨架
|
||||
├── P1 起卦领域核心 ──→ P3 主流程 UI ──→ P4 本地完整 MVP
|
||||
├── P2 内容数据管线 ──→ P3 主流程 UI
|
||||
@@ -14,9 +16,30 @@ P0 工程骨架
|
||||
P4 本地完整 MVP ──→ P5 AI 后端与解释
|
||||
```
|
||||
|
||||
P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临时算法或硬编码卦辞。
|
||||
P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原型算法或硬编码卦辞。P-1 的视觉确认不改变 P1/P2 的工程门禁。
|
||||
|
||||
## 2. P0:仓库与 Android 骨架
|
||||
## 2. P-1:可点击原型与视觉确认
|
||||
|
||||
目标:在 Android 工程初始化前确认核心流程、信息层级、手动录入方式、AI 同意门和东方文化视觉方向。
|
||||
|
||||
任务:
|
||||
|
||||
- 建立 360 × 792 基准视口的高保真 HTML 原型和设计令牌。
|
||||
- 覆盖欢迎、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
|
||||
- 使用已知夹具核对本卦、之卦、动爻和无动爻布局。
|
||||
- 导出关键状态截图,并把评审入口与 Android 映射写入[原型说明](prototype.md)。
|
||||
- 收集用户对视觉、录入控件、结果层级与解释语气的明确反馈。
|
||||
|
||||
退出条件:
|
||||
|
||||
- 可点击主流程及关键异常状态可在 360 × 792 浏览器中复现。
|
||||
- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1 且无之卦。
|
||||
- 原型不发起外部请求,不伪装真实 AI,不把工作名当作正式决定。
|
||||
- 用户确认或提出一轮可执行的修改意见;确认前不把视觉固化为生产 Compose 页面。
|
||||
|
||||
当前交付物已完成,视觉确认仍待用户评审。
|
||||
|
||||
## 3. P0:仓库与 Android 骨架
|
||||
|
||||
目标:建立可构建、可测试、可导航的原生 Android 项目。
|
||||
|
||||
@@ -38,7 +61,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- 空应用可在模拟器启动,导航到占位欢迎页。
|
||||
- 没有生产服务密钥或真实内容。
|
||||
|
||||
## 3. P1:领域核心
|
||||
## 4. P1:领域核心
|
||||
|
||||
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
|
||||
|
||||
@@ -58,7 +81,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- 测试无随机、无网络、无系统时间依赖。
|
||||
- `CastEngine` API 经评审后冻结为 `coin-v1`。
|
||||
|
||||
## 4. P2:内容数据管线
|
||||
## 5. P2:内容数据管线
|
||||
|
||||
目标:建立可追踪、可校验、可发布的本地内容包。
|
||||
|
||||
@@ -77,7 +100,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- 缺失、重复、非法顺序和错误映射测试均能失败。
|
||||
- 内容负责人确认可再分发。
|
||||
|
||||
## 5. P3:核心用户流程与东方设计系统
|
||||
## 6. P3:核心用户流程与东方设计系统
|
||||
|
||||
目标:完成离线起念、六次录入和结果阅读。
|
||||
|
||||
@@ -98,7 +121,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- AI/网络代码尚未存在也不影响流程。
|
||||
- [UX 与东方视觉](ux-design.md)检查表通过。
|
||||
|
||||
## 6. P4:本地解释与历史
|
||||
## 7. P4:本地解释与历史
|
||||
|
||||
目标:形成不依赖 AI 的完整 MVP。
|
||||
|
||||
@@ -117,7 +140,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- 删除行为与备份策略一致且经过验证。
|
||||
- 此阶段已经是可发布的本地版候选。
|
||||
|
||||
## 7. P5:AI 解读
|
||||
## 8. P5:AI 解读
|
||||
|
||||
目标:在不扩大起卦权限的前提下增加可控 AI 解释。
|
||||
|
||||
@@ -142,7 +165,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
- 抓包显示只有用户明确动作触发请求,载荷与同意说明一致。
|
||||
- 服务端故障不会改变或隐藏 `CastResult`。
|
||||
|
||||
## 8. P6:质量、发布与运营
|
||||
## 9. P6:质量、发布与运营
|
||||
|
||||
贯穿所有阶段:
|
||||
|
||||
@@ -155,7 +178,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
|
||||
发布条件完全遵循[质量门禁](quality-gates.md#6-发布门禁)。
|
||||
|
||||
## 9. 编码任务模板
|
||||
## 10. 编码任务模板
|
||||
|
||||
后续任务应使用以下结构,减少代理猜测:
|
||||
|
||||
@@ -185,7 +208,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
|
||||
|
||||
任务应尽量小到单次变更可完整验证,不以“大致完成页面”作为验收描述。
|
||||
|
||||
## 10. MVP 切分建议
|
||||
## 11. MVP 切分建议
|
||||
|
||||
最稳妥的发布顺序:
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 移动端交互原型
|
||||
|
||||
> 状态:高保真候选 v0.1,待用户视觉与流程确认
|
||||
> 基准视口:360 × 792 CSS px(对应当前目标手机的紧凑竖屏体验)
|
||||
> 实现位置:[`prototype/`](../prototype/)
|
||||
|
||||
## 1. 原型目的
|
||||
|
||||
在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
|
||||
|
||||
本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。
|
||||
|
||||
## 2. 交付范围
|
||||
|
||||
原型已经覆盖:
|
||||
|
||||
- 欢迎与方法说明;
|
||||
- 开放式问题输入,以及明确的“不写具体内容”入口;
|
||||
- 三枚硬币逐枚录入、字/背计值、六轮 bottom-up 进度和撤销上一爻;
|
||||
- 本地确定性计算本卦、之卦和全部动爻;
|
||||
- 有动爻与无动爻两种结果布局;
|
||||
- 经典原文、本地白话和动爻的来源标签;
|
||||
- “解”的本地/AI 两种分支;
|
||||
- AI 发送范围说明、逐次同意、加载、成功、失败和本地降级;
|
||||
- 减少动态效果、键盘焦点、语义标签和最小触控目标。
|
||||
|
||||
本轮不包含:
|
||||
|
||||
- Android/Compose 生产代码;
|
||||
- 深色主题、平板和横屏定稿;
|
||||
- 历史记录与设置页面;
|
||||
- 真实 AI 请求、账户、服务端或持久化;
|
||||
- 完整 64 卦授权内容包;
|
||||
- 正式名称、图标、字体授权和商店视觉。
|
||||
|
||||
## 3. 运行方式
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python -m http.server 4173 --bind 127.0.0.1
|
||||
```
|
||||
|
||||
然后访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:4173/prototype/
|
||||
```
|
||||
|
||||
原型无构建步骤、无第三方前端依赖,也不会发起外部网络请求。浏览器刷新会清空本次会话,这符合“评审原型”而非生产存储的定位。
|
||||
|
||||
服务保持运行时,重新生成截图并执行浏览器门禁:
|
||||
|
||||
```powershell
|
||||
node prototype\capture.mjs
|
||||
```
|
||||
|
||||
脚本使用本机 Chrome/Edge 的 DevTools 协议,不安装 npm 依赖。可通过任务专用环境变量 `BRAINWAVE_PROTOTYPE_URL` 指向不同的本地服务地址。
|
||||
|
||||
## 4. 评审入口
|
||||
|
||||
正常入口可以完整点击。为稳定复现截图,还提供只用于评审的查询参数:
|
||||
|
||||
| 状态 | URL | 评审重点 |
|
||||
|---|---|---|
|
||||
| 欢迎 | `?view=welcome` | 第一印象、文化气质、产品边界 |
|
||||
| 起念 | `?view=question` | 输入层级、隐私提示、跳过入口 |
|
||||
| 第三爻录入 | `?view=casting` | 手动录入、计值可见性、进度 |
|
||||
| 有动爻结果 | `?view=result` | 复 24 → 坤 2、初爻动、内容顺序 |
|
||||
| 无动爻结果 | `?view=static` | 乾 1、不出现之卦 |
|
||||
| AI 同意 | `?view=consent` | 发送范围与明确同意 |
|
||||
| AI 解读 | `?view=explanation` | 来源标签、非裁决语言、行动收束 |
|
||||
| 本地解读 | `?view=local` | 离线分支与来源区分 |
|
||||
| AI 失败 | `?view=error` | 结果保留、重试和本地降级 |
|
||||
|
||||
这些参数只设置初始预览状态,不属于 Android 正式版路由。
|
||||
|
||||
## 5. 交互与领域契约
|
||||
|
||||
### 5.1 起卦
|
||||
|
||||
- 用户必须亲自投币并逐枚录入;页面没有随机、摇一摇或 AI 代投入口。
|
||||
- 字记 2,背记 3;每轮三枚硬币全部有值后才能确认。
|
||||
- 六轮按初爻到上爻记录,界面以“从下往上”明确提示。
|
||||
- 6 为老阴、7 为少阳、8 为少阴、9 为老阳,6/9 标为动爻。
|
||||
- 原型包含完整 64 卦 King Wen 序号映射,用于验证交互结果;生产实现仍必须按[领域规则](domain-rules.md)在纯 Kotlin 中重建并穷举测试,不能复制前端代码当作权威领域实现。
|
||||
- 已确认的爻可逐步撤销;进入结果后本次结果不可被 AI 改写。
|
||||
|
||||
### 5.2 结果阅读
|
||||
|
||||
信息优先级固定为:
|
||||
|
||||
1. 本卦、之卦和动爻;
|
||||
2. 用户本次所问;
|
||||
3. 经典原文;
|
||||
4. 本地白话;
|
||||
5. 动爻文本与提示;
|
||||
6. 用户主动点击“解”后的扩展解释。
|
||||
|
||||
无动爻时只显示本卦和“卦象稳定”提示,不虚构之卦。多动爻时必须透明保留全部变化位置。
|
||||
|
||||
### 5.3 AI 调用门
|
||||
|
||||
原型中的 AI 加载是本地模拟,不会调用模型。正式流程必须维持以下顺序:
|
||||
|
||||
```text
|
||||
本地结果已展示
|
||||
→ 用户点击「解」
|
||||
→ 用户选择 AI
|
||||
→ 页面说明确切发送字段
|
||||
→ 用户勾选并确认
|
||||
→ 才允许发起一次请求
|
||||
```
|
||||
|
||||
失败状态不得隐藏或重算卦象,必须同时提供本地解释和重试入口。
|
||||
|
||||
## 6. 视觉系统
|
||||
|
||||
视觉遵循 [ADR-006](decisions.md#adr-006东方文化采用内容优先的纸墨朱砂设计):
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `paper` | `#F7F2E8` | 页面底色 |
|
||||
| `surface` | `#FFFDF7` | 阅读卡片与弹层 |
|
||||
| `ink` | `#1F1B16` | 标题、卦象和主要正文 |
|
||||
| `ink-soft` | `#655E55` | 次级正文 |
|
||||
| `cinnabar` | `#8C2F2B` | 主操作、动爻和关键提示 |
|
||||
| `ochre` | `#765D3E` | 辅助标签和次级动作 |
|
||||
| `outline` | `#B9AEA0` | 分隔和控件边界 |
|
||||
|
||||
标题和文化文本优先使用系统可用的中文宋体/衬线字体,控件与说明使用中文黑体/无衬线字体。正式 Android 版本必须决定可再分发字体或采用平台字体回退,不能把本机字体直接打包。
|
||||
|
||||
文化感来自排版、阅读节奏、卦象结构和克制的朱砂强调,不使用龙凤祥云、伪古印章、金色发光、正文毛笔字或抽卡式动效。
|
||||
|
||||
## 7. 原型截图
|
||||
|
||||
关键状态截图位于 [`prototype/screenshots/`](../prototype/screenshots/),命名按用户旅程排序:
|
||||
|
||||
1. `01-welcome.png`
|
||||
2. `02-question.png`
|
||||
3. `03-casting.png`
|
||||
4. `04-result-moving.png`
|
||||
5. `05-result-static.png`
|
||||
6. `06-ai-consent.png`
|
||||
7. `07-ai-explanation.png`
|
||||
8. `08-ai-error.png`
|
||||
|
||||
截图是 360 CSS px 宽度的评审证据,不是应用商店素材。
|
||||
|
||||
## 8. 验收清单
|
||||
|
||||
进入 Android P0/P3 前,需要用户明确反馈或确认:
|
||||
|
||||
- [ ] “纸、墨、朱砂、留白”的整体气质是否合适;
|
||||
- [ ] 工作名只作占位,不把“一问”直接视为正式名称;
|
||||
- [ ] 三枚硬币用圆形切换控件录入是否容易理解;
|
||||
- [ ] 结果页先卦象、后原文/白话、再点击“解”的层级是否合适;
|
||||
- [ ] 本地解读与 AI 解读的选择、同意和来源标签是否足够清楚;
|
||||
- [ ] “可以试的一小步”的非预测、非裁决语气是否符合定位;
|
||||
- [ ] 是否需要在下一轮原型加入历史记录、深色主题或更大字号状态。
|
||||
|
||||
未确认项应继续停留在原型层修改;不应提前固化为 Compose 生产页面。
|
||||
|
||||
## 9. 向 Android 实现的映射
|
||||
|
||||
| 原型概念 | Compose 目标 |
|
||||
|---|---|
|
||||
| CSS 颜色/间距/圆角令牌 | `MaterialTheme` 扩展与 design token |
|
||||
| `welcome/question/casting/result` | Navigation destination + 状态驱动页面 |
|
||||
| 三枚硬币控件 | 具备明确 semantics 的 48dp+ Compose 控件 |
|
||||
| 六爻图 | Canvas 绘制 + 完整读屏描述 |
|
||||
| 底部选择/同意层 | Material 3 modal bottom sheet/dialog |
|
||||
| 本地/AI/错误来源标签 | sealed UI state,不以颜色作为唯一提示 |
|
||||
| `prefers-reduced-motion` | 系统动画缩放/无障碍偏好适配 |
|
||||
|
||||
Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen 映射或根据文本猜测卦象。
|
||||
|
||||
## 10. 本轮验证记录
|
||||
|
||||
- `node --check prototype/app.js`:通过。
|
||||
- `node --check prototype/capture.mjs`:通过。
|
||||
- `node prototype/capture.mjs`:通过;八个状态完成 360px 截图与浏览器审计。
|
||||
- `git diff --check`:通过。
|
||||
- Chromium 360 × 792:欢迎、起念、录入、有/无动爻结果、AI 同意、成功与失败状态已渲染。
|
||||
- 已知夹具 `9,8,8,8,8,8`:显示复 24 → 坤 2,初爻动。
|
||||
- 静态夹具 `7,7,7,7,7,7`:显示乾 1,无动爻且无之卦。
|
||||
- 浏览器控制台错误:0;运行时异常:0;外部请求:0;模型调用:0。
|
||||
- 八个状态的未命名按钮、低于 48px 的按钮触控目标、过小复选框标签:均为 0。
|
||||
|
||||
原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。
|
||||
@@ -41,6 +41,19 @@
|
||||
|
||||
## 3. 测试层次
|
||||
|
||||
### 3.0 原型验证(P-1 阻塞视觉确认)
|
||||
|
||||
- `node --check prototype/app.js` 无语法错误。
|
||||
- `node --check prototype/capture.mjs` 无语法错误。
|
||||
- `git diff --check` 无空白错误。
|
||||
- 本地服务运行时,`node prototype\capture.mjs` 退出码为 0。
|
||||
- Chromium 360 × 792 走通问题输入、六次录入、结果和解释选择。
|
||||
- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1、无动爻且无之卦。
|
||||
- 欢迎、起念、录入、有/无动爻结果、AI 同意、解释和失败状态均有可复现截图。
|
||||
- 浏览器控制台无应用错误;页面不发起外部请求;AI 状态明确标为本地模拟。
|
||||
|
||||
原型通过只表示流程可供评审,不替代纯 Kotlin 领域测试、Compose UI 测试或真机无障碍检查。
|
||||
|
||||
### 3.1 纯 JVM 领域测试(最快,阻塞合并)
|
||||
|
||||
- 三枚铜币 8 种排列映射。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Brainwave 移动端原型
|
||||
|
||||
这是一个无构建步骤、无外部依赖的高保真 HTML/CSS/JavaScript 原型。它用于在 Android 开发前确认流程与视觉,不是生产实现。
|
||||
|
||||
从仓库根目录启动:
|
||||
|
||||
```powershell
|
||||
python -m http.server 4173 --bind 127.0.0.1
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:4173/prototype/`。完整状态入口、领域边界、评审清单和 Android 映射见 [`docs/prototype.md`](../docs/prototype.md)。
|
||||
|
||||
服务保持运行时,可用当前系统的 Chrome/Edge 重新生成八张截图并执行浏览器审计:
|
||||
|
||||
```powershell
|
||||
node prototype\capture.mjs
|
||||
```
|
||||
|
||||
脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会核对两个已知卦象、AI 同意门、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
|
||||
|
||||
约束:
|
||||
|
||||
- 不发起外部请求,不调用 AI;
|
||||
- 不生成随机卦,用户逐枚录入硬币;
|
||||
- 查询参数只用于稳定预览和截图;
|
||||
- 工作名“一问”与当前文字内容均为评审候选,不代表正式命名或内容授权完成。
|
||||
@@ -0,0 +1,733 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
|
||||
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
|
||||
|
||||
const HEXAGRAM_NAMES = [
|
||||
"", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
|
||||
"同人", "大有", "谦", "豫", "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
|
||||
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", "遁", "大壮", "晋",
|
||||
"明夷", "家人", "睽", "蹇", "解", "损", "益", "夬", "姤", "萃", "升", "困",
|
||||
"井", "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", "巽", "兑", "涣",
|
||||
"节", "中孚", "小过", "既济", "未济"
|
||||
];
|
||||
|
||||
// 行为下卦、列为上卦;三位编码均按自下而上排列,1 为阳、0 为阴。
|
||||
const KING_WEN = {
|
||||
"111": { "111": 1, "100": 34, "010": 5, "001": 26, "000": 11, "011": 9, "101": 14, "110": 43 },
|
||||
"100": { "111": 25, "100": 51, "010": 3, "001": 27, "000": 24, "011": 42, "101": 21, "110": 17 },
|
||||
"010": { "111": 6, "100": 40, "010": 29, "001": 4, "000": 7, "011": 59, "101": 64, "110": 47 },
|
||||
"001": { "111": 33, "100": 62, "010": 39, "001": 52, "000": 15, "011": 53, "101": 56, "110": 31 },
|
||||
"000": { "111": 12, "100": 16, "010": 8, "001": 23, "000": 2, "011": 20, "101": 35, "110": 45 },
|
||||
"011": { "111": 44, "100": 32, "010": 48, "001": 18, "000": 46, "011": 57, "101": 50, "110": 28 },
|
||||
"101": { "111": 13, "100": 55, "010": 63, "001": 22, "000": 36, "011": 37, "101": 30, "110": 49 },
|
||||
"110": { "111": 10, "100": 54, "010": 60, "001": 41, "000": 19, "011": 61, "101": 38, "110": 58 }
|
||||
};
|
||||
|
||||
const icons = {
|
||||
lock: '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8.5 10V7.5a3.5 3.5 0 017 0V10"/></svg>',
|
||||
arrow: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>',
|
||||
chevron: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>',
|
||||
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>'
|
||||
};
|
||||
|
||||
const screen = document.querySelector("#screen");
|
||||
const modalRoot = document.querySelector("#modal-root");
|
||||
const toastRegion = document.querySelector("#toast-region");
|
||||
const backButton = document.querySelector("[data-action='back']");
|
||||
|
||||
const state = {
|
||||
screen: "welcome",
|
||||
question: "",
|
||||
lines: [],
|
||||
coins: [null, null, null],
|
||||
modal: null,
|
||||
consent: false,
|
||||
explanationSource: "ai",
|
||||
aiError: false
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function lineIsYang(value) {
|
||||
return value === 7 || value === 9;
|
||||
}
|
||||
|
||||
function lineIsMoving(value) {
|
||||
return value === 6 || value === 9;
|
||||
}
|
||||
|
||||
function transformLines(lines) {
|
||||
return lines.map((value) => {
|
||||
if (value === 6) return 7;
|
||||
if (value === 9) return 8;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function lookupHexagram(lines) {
|
||||
const bits = lines.map((value) => lineIsYang(value) ? "1" : "0");
|
||||
const lower = bits.slice(0, 3).join("");
|
||||
const upper = bits.slice(3, 6).join("");
|
||||
const number = KING_WEN[lower][upper];
|
||||
return { number, name: HEXAGRAM_NAMES[number] };
|
||||
}
|
||||
|
||||
function lineName(value, index) {
|
||||
const position = ["初", "二", "三", "四", "五", "上"][index];
|
||||
const polarity = lineIsYang(value) ? "九" : "六";
|
||||
if (index === 0) return `${position}${polarity}`;
|
||||
if (index === 5) return `${position}${polarity}`;
|
||||
return `${polarity}${position}`;
|
||||
}
|
||||
|
||||
function resultData() {
|
||||
const sourceLines = state.lines.length === 6 ? state.lines : SAMPLE_LINES;
|
||||
const changedLines = transformLines(sourceLines);
|
||||
return {
|
||||
sourceLines,
|
||||
changedLines,
|
||||
primary: lookupHexagram(sourceLines),
|
||||
changed: lookupHexagram(changedLines),
|
||||
movingIndexes: sourceLines
|
||||
.map((value, index) => lineIsMoving(value) ? index : -1)
|
||||
.filter((index) => index >= 0)
|
||||
};
|
||||
}
|
||||
|
||||
function stepper(current, total) {
|
||||
const progress = Math.round((current / total) * 100);
|
||||
return `
|
||||
<div class="stepper" aria-label="第 ${current} 步,共 ${total} 步">
|
||||
<span>${String(current).padStart(2, "0")}</span>
|
||||
<span class="stepper__track" style="--progress:${progress}%"></span>
|
||||
<span>${String(total).padStart(2, "0")}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function welcomeView() {
|
||||
return `
|
||||
<section class="page page--welcome" aria-labelledby="welcome-title">
|
||||
<div>
|
||||
<p class="eyebrow">慢一点,看清当下</p>
|
||||
<h1 id="welcome-title">把一个犹豫,安静地放在这里。</h1>
|
||||
<p class="lede">用你手中的三枚硬币,依次记录六次结果。这里提供文化文本与反思线索,不替你预测,也不替你决定。</p>
|
||||
|
||||
<div class="hero-mark" aria-hidden="true">
|
||||
<span class="hero-mark__dot">观</span>
|
||||
</div>
|
||||
|
||||
<ul class="principles" aria-label="产品原则">
|
||||
<li>手动记录</li>
|
||||
<li>本地成卦</li>
|
||||
<li>选择解读</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="begin">
|
||||
写下此刻所问 ${icons.arrow}
|
||||
</button>
|
||||
<p class="fine-print" style="margin:0;text-align:center">工作名“一问”仅用于本轮视觉评审</p>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function questionView() {
|
||||
return `
|
||||
<section class="page" aria-labelledby="question-title">
|
||||
${stepper(1, 2)}
|
||||
<p class="eyebrow">起念</p>
|
||||
<h1 id="question-title">你想看清什么?</h1>
|
||||
<p class="lede">写成一个开放的问题,比只问“会不会”更容易照见可行动的部分。</p>
|
||||
|
||||
<label class="field-label" for="question-input">此刻的问题</label>
|
||||
<textarea
|
||||
id="question-input"
|
||||
class="question-input"
|
||||
maxlength="120"
|
||||
placeholder="例如:面对这次工作调整,我怎样做能更稳妥?"
|
||||
data-question-input
|
||||
>${escapeHtml(state.question)}</textarea>
|
||||
<div class="field-meta">
|
||||
<span>只保存在本次原型会话中</span>
|
||||
<span data-char-count>${state.question.length}/120</span>
|
||||
</div>
|
||||
|
||||
<div class="privacy-note">
|
||||
${icons.lock}
|
||||
<p>起卦计算在本地完成。只有你主动选择 AI 解读并再次确认后,所需内容才会被发送。</p>
|
||||
</div>
|
||||
|
||||
<button class="text-link" type="button" data-action="skip-question">不写具体内容,直接开始</button>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="start-casting" ${state.question.trim() ? "" : "disabled"}>
|
||||
开始记录六爻 ${icons.arrow}
|
||||
</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function lineStack(lines) {
|
||||
return [5, 4, 3, 2, 1, 0].map((index) => {
|
||||
const value = lines[index];
|
||||
if (!value) {
|
||||
return '<div class="line-slot line-slot--empty" aria-label="尚未记录"></div>';
|
||||
}
|
||||
const kind = lineIsYang(value) ? "yang" : "yin";
|
||||
const moving = lineIsMoving(value) ? " line-slot--moving" : "";
|
||||
return `<div class="line-slot line-slot--${kind}${moving}" aria-label="${lineName(value, index)},数值 ${value}${lineIsMoving(value) ? ",动爻" : ""}">
|
||||
${lineIsMoving(value) ? `<span class="line-slot__number">${value}</span>` : ""}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function coinButton(value, index) {
|
||||
const face = value === 2 ? "字" : value === 3 ? "背" : "点选";
|
||||
const score = value ? String(value) : "字 ↔ 背";
|
||||
const valueAttr = value ? `data-value="${value}"` : "";
|
||||
const next = value === null ? "字,2 分" : value === 2 ? "背,3 分" : "字,2 分";
|
||||
return `
|
||||
<div class="coin-wrap">
|
||||
<button
|
||||
class="coin"
|
||||
type="button"
|
||||
data-action="toggle-coin"
|
||||
data-index="${index}"
|
||||
${valueAttr}
|
||||
aria-label="第 ${index + 1} 枚硬币,当前${value ? `${face},${score} 分` : "未记录"};点击切换为${next}"
|
||||
>
|
||||
<span class="coin__value">
|
||||
<span class="coin__face">${face}</span>
|
||||
<span class="coin__score">${score}</span>
|
||||
</span>
|
||||
</button>
|
||||
<span>硬币 ${index + 1}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function castingView() {
|
||||
const round = state.lines.length + 1;
|
||||
const selected = state.coins.every((value) => value !== null);
|
||||
const sum = selected ? state.coins.reduce((total, value) => total + value, 0) : null;
|
||||
const currentType = sum
|
||||
? ({ 6: "老阴 · 动", 7: "少阳", 8: "少阴", 9: "老阳 · 动" })[sum]
|
||||
: "";
|
||||
|
||||
return `
|
||||
<section class="page page--compact" aria-labelledby="casting-title">
|
||||
${stepper(2, 2)}
|
||||
<div class="question-chip" title="${escapeHtml(state.question)}">${escapeHtml(state.question)}</div>
|
||||
|
||||
<div class="casting-layout">
|
||||
<div class="line-progress-card">
|
||||
<div class="line-progress-copy">
|
||||
<strong>${state.lines.length} / 6</strong>
|
||||
<p>从下往上记录</p>
|
||||
${state.lines.length ? '<button class="text-link" type="button" data-action="undo-line">撤销上一爻</button>' : ""}
|
||||
</div>
|
||||
<div class="line-stack" aria-label="已记录的六爻">${lineStack(state.lines)}</div>
|
||||
</div>
|
||||
|
||||
<div class="coin-prompt">
|
||||
<h2 id="casting-title">第 ${round} 爻</h2>
|
||||
<p>依照手中硬币,逐枚点选“字”或“背”</p>
|
||||
</div>
|
||||
|
||||
<div class="coins" aria-label="三枚硬币录入">
|
||||
${state.coins.map(coinButton).join("")}
|
||||
</div>
|
||||
|
||||
<div class="round-sum" aria-live="polite">
|
||||
${sum ? `本次合计 <strong>${sum}</strong> · ${currentType}` : "点按硬币可在“字 2”与“背 3”间切换"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="confirm-line" ${selected ? "" : "disabled"}>
|
||||
${round === 6 ? "记录并查看结果" : `确认第 ${round} 爻`} ${icons.arrow}
|
||||
</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function hexLine(value, index, showMoving) {
|
||||
const kind = lineIsYang(value) ? "yang" : "yin";
|
||||
const moving = showMoving && lineIsMoving(value) ? " hex-line--moving" : "";
|
||||
return `<div class="hex-line hex-line--${kind}${moving}" aria-label="${lineName(value, index)}${moving ? ",动爻" : ""}"><span></span><span></span></div>`;
|
||||
}
|
||||
|
||||
function hexagramFigure(lines, hexagram, showMoving) {
|
||||
const rendered = [5, 4, 3, 2, 1, 0]
|
||||
.map((index) => hexLine(lines[index], index, showMoving))
|
||||
.join("");
|
||||
return `
|
||||
<div class="hexagram-view">
|
||||
<div class="hexagram-lines" role="img" aria-label="第 ${hexagram.number} 卦,${hexagram.name}卦">${rendered}</div>
|
||||
<div class="hexagram-name">${hexagram.name}</div>
|
||||
<div class="hexagram-number">第 ${hexagram.number} 卦</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function resultCopy(data) {
|
||||
if (data.primary.number === 24) {
|
||||
return {
|
||||
classic: "复:亨。出入无疾,朋来无咎。反复其道,七日来复。利有攸往。",
|
||||
plain: "“复”提醒人把注意力放回可返回、可重新开始之处。它不是替你判断成败,而是邀请你看见:此刻是否有一条更朴素、更合乎本心的路。",
|
||||
moving: "初九:不远复,无祇悔,元吉。",
|
||||
movingPlain: "偏离尚不远时就察觉并回转,代价通常较小。先修正一个最近的动作,比一次解决所有问题更可行。"
|
||||
};
|
||||
}
|
||||
return {
|
||||
classic: `${data.primary.name}卦 · 第 ${data.primary.number} 卦。本原型仅演示排盘与阅读层级,正式经典文本将从经校勘的本地内容包读取。`,
|
||||
plain: "先观察卦象呈现的张力与变化,再回到你能核实的事实、感受和资源。不要把符号当成替你下结论的证据。",
|
||||
moving: data.movingIndexes.length ? `${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join("、")}发生变化。` : "",
|
||||
movingPlain: "变化的位置可作为反思入口:哪里正在松动,哪里仍需要保留余地?"
|
||||
};
|
||||
}
|
||||
|
||||
function errorCard() {
|
||||
return `
|
||||
<div class="error-card" role="alert">
|
||||
<h3>AI 解读暂时没有返回</h3>
|
||||
<p class="quiet">起卦结果仍完整保存在本页。你可以改看本地解读,或稍后重试。</p>
|
||||
<div class="error-actions">
|
||||
<button class="button button--secondary" type="button" data-action="choose-local">本地解读</button>
|
||||
<button class="button button--danger-quiet" type="button" data-action="retry-ai">重试</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function resultView() {
|
||||
const data = resultData();
|
||||
const copy = resultCopy(data);
|
||||
const hasMoving = data.movingIndexes.length > 0;
|
||||
const question = state.question || "未记录具体问题";
|
||||
|
||||
return `
|
||||
<section class="page page--compact" aria-labelledby="result-title">
|
||||
<div class="result-hero">
|
||||
<div class="result-kicker">
|
||||
<span>起卦结果 · 本地计算</span>
|
||||
<span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
|
||||
</div>
|
||||
|
||||
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
|
||||
${hexagramFigure(data.sourceLines, data.primary, true)}
|
||||
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
|
||||
</div>
|
||||
${hasMoving ? "" : '<div class="static-indicator">卦象稳定 · 不显示之卦</div>'}
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<div class="section-heading">
|
||||
<h2 id="result-title">你所问</h2>
|
||||
<span class="source-badge">本次记录</span>
|
||||
</div>
|
||||
<p class="question-chip" style="white-space:normal">${escapeHtml(question)}</p>
|
||||
</div>
|
||||
|
||||
${state.aiError ? errorCard() : ""}
|
||||
|
||||
<div class="content-section">
|
||||
<div class="section-heading">
|
||||
<h2>经典原文</h2>
|
||||
<span class="source-badge">经典文本</span>
|
||||
</div>
|
||||
<blockquote class="classic-quote">${copy.classic}</blockquote>
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
<div class="section-heading">
|
||||
<h2>本地白话</h2>
|
||||
<span class="source-badge">本地内容</span>
|
||||
</div>
|
||||
<div class="plain-copy"><p>${copy.plain}</p></div>
|
||||
</div>
|
||||
|
||||
${hasMoving ? `
|
||||
<div class="content-section">
|
||||
<div class="section-heading"><h2>动爻</h2><span class="source-badge">变化处</span></div>
|
||||
<div class="moving-card">
|
||||
<div class="moving-card__label">${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join(" · ")}</div>
|
||||
<h3>${copy.moving}</h3>
|
||||
<p class="quiet">${copy.movingPlain}</p>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
|
||||
<hr class="divider" />
|
||||
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--primary" type="button" data-action="open-interpretation">
|
||||
解 · 选择一种解读方式 ${icons.arrow}
|
||||
</button>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function loadingView() {
|
||||
return `
|
||||
<section class="page" aria-labelledby="loading-title">
|
||||
<div class="loading-state">
|
||||
<div class="loading-orbit" aria-hidden="true"></div>
|
||||
<h1 id="loading-title" style="font-size:27px">正在整理反思线索</h1>
|
||||
<p class="lede">结果已经生成。AI 只在此处协助组织文字,不参与起卦。</p>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function explanationView() {
|
||||
const data = resultData();
|
||||
const ai = state.explanationSource === "ai";
|
||||
return `
|
||||
<article class="page page--compact" aria-labelledby="explanation-title">
|
||||
<header class="interpretation-header">
|
||||
<p class="eyebrow">${ai ? "辅助反思" : "离线阅读"}</p>
|
||||
<h1 id="explanation-title">从“回到起点”开始</h1>
|
||||
<div class="interpretation-meta">
|
||||
<span class="source-badge">${ai ? "AI 生成" : "本地解读"}</span>
|
||||
<span>${data.primary.name}卦 · 第 ${data.primary.number} 卦</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="interpretation-lead">眼前更重要的,也许不是立刻选定一条路,而是辨认哪一步能让你重新获得稳定与判断力。</p>
|
||||
|
||||
<section class="content-section" aria-labelledby="observation-title">
|
||||
<div class="section-heading"><h2 id="observation-title">可以先观察</h2></div>
|
||||
<ul class="reflection-list">
|
||||
<li>你对“调整”的担心里,哪些是已经发生的事实,哪些是尚未验证的推测?</li>
|
||||
<li>复卦的动处在最初一爻,适合把注意力放在能尽早修正的小偏差。</li>
|
||||
<li>之卦为坤,提示行动时给现实条件、协作关系与节奏留出空间。</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="content-section" aria-labelledby="questions-title">
|
||||
<div class="section-heading"><h2 id="questions-title">问问自己</h2></div>
|
||||
<ol class="question-list">
|
||||
<li>如果暂时不追求一次做对,我最想先确认的一个事实是什么?</li>
|
||||
<li>哪一种选择更容易保留回转余地,而不是把自己锁死?</li>
|
||||
<li>我可以向谁说明顾虑,并获得一条具体信息?</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="content-section" aria-labelledby="step-title">
|
||||
<div class="small-step">
|
||||
<div class="small-step__label" id="step-title">可以试的一小步</div>
|
||||
<p>在今天结束前,写下“已知事实、尚待确认、我的底线”各一条,再约一次不超过 20 分钟的信息沟通。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="safety-note">这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。</p>
|
||||
|
||||
<div class="bottom-action">
|
||||
<button class="button button--secondary" type="button" data-action="back-to-result">回到起卦结果</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function interpretationModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="interpretation-modal-title" data-modal-sheet>
|
||||
<div class="modal-handle" aria-hidden="true"></div>
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="interpretation-modal-title">选择解读方式</h2>
|
||||
<p>起卦结果不会改变,只切换阅读材料的来源。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</header>
|
||||
<div class="option-list">
|
||||
<button class="option-card" type="button" data-action="choose-local">
|
||||
<span class="option-card__icon" aria-hidden="true">本</span>
|
||||
<span><strong>先看本地解读</strong><small>离线可用,不发送任何内容</small></span>
|
||||
${icons.chevron}
|
||||
</button>
|
||||
<button class="option-card" type="button" data-action="choose-ai">
|
||||
<span class="option-card__icon" aria-hidden="true">解</span>
|
||||
<span><strong>使用 AI 辅助解读</strong><small>确认发送范围后再开始</small></span>
|
||||
${icons.chevron}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function consentModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="consent-modal-title" data-modal-sheet>
|
||||
<div class="modal-handle" aria-hidden="true"></div>
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="consent-modal-title">发送前,请你确认</h2>
|
||||
<p>AI 不参与起卦,只根据既有结果整理反思线索。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</header>
|
||||
|
||||
<div class="notice">
|
||||
${icons.shield}
|
||||
<p>将发送以下内容;不会发送设备标识、历史记录或其他本地数据。</p>
|
||||
</div>
|
||||
<ul class="data-list">
|
||||
<li>你本次写下的问题</li>
|
||||
<li>本卦、之卦与动爻结果</li>
|
||||
<li>用于约束语气和安全边界的提示</li>
|
||||
</ul>
|
||||
|
||||
<label class="consent-check">
|
||||
<input type="checkbox" data-action="consent-toggle" ${state.consent ? "checked" : ""} />
|
||||
<span>我已了解发送范围,并同意仅为本次 AI 解读使用这些内容。</span>
|
||||
</label>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="button button--primary" type="button" data-action="confirm-ai" ${state.consent ? "" : "disabled"}>同意并开始解读</button>
|
||||
<button class="button button--quiet" type="button" data-action="choose-local">改看本地解读</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function methodModal() {
|
||||
return `
|
||||
<div class="modal-backdrop" data-action="dismiss-modal">
|
||||
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="method-modal-title" data-modal-sheet>
|
||||
<div class="modal-handle" aria-hidden="true"></div>
|
||||
<header class="modal-header">
|
||||
<div>
|
||||
<h2 id="method-modal-title">三枚硬币法</h2>
|
||||
<p>原型采用明确、可复核的固定规则。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</header>
|
||||
|
||||
<ol class="method-steps">
|
||||
<li>准备三枚硬币,在现实中投掷一次。</li>
|
||||
<li>在页面逐枚记录字面或背面,确认后形成一爻。</li>
|
||||
<li>从下往上重复六次;程序只换算,不替你随机生成。</li>
|
||||
</ol>
|
||||
|
||||
<div class="score-table" aria-label="硬币计分规则">
|
||||
<div><strong>字 · 2</strong><span>字面记两分</span></div>
|
||||
<div><strong>背 · 3</strong><span>背面记三分</span></div>
|
||||
</div>
|
||||
<p class="fine-print">合计 6 为老阴、7 为少阳、8 为少阴、9 为老阳;6 与 9 是动爻。</p>
|
||||
<button class="button button--secondary" style="width:100%" type="button" data-action="close-modal">知道了</button>
|
||||
</section>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
|
||||
else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
|
||||
else if (state.modal === "method") modalRoot.innerHTML = methodModal();
|
||||
else modalRoot.innerHTML = "";
|
||||
|
||||
document.body.style.overflow = state.modal ? "hidden" : "";
|
||||
if (state.modal) {
|
||||
window.setTimeout(() => {
|
||||
modalRoot.querySelector("button, input")?.focus();
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
|
||||
function render(options = {}) {
|
||||
if (state.screen === "welcome") screen.innerHTML = welcomeView();
|
||||
else if (state.screen === "question") screen.innerHTML = questionView();
|
||||
else if (state.screen === "casting") screen.innerHTML = castingView();
|
||||
else if (state.screen === "result") screen.innerHTML = resultView();
|
||||
else if (state.screen === "loading") screen.innerHTML = loadingView();
|
||||
else if (state.screen === "explanation") screen.innerHTML = explanationView();
|
||||
|
||||
backButton.hidden = state.screen === "welcome";
|
||||
renderModal();
|
||||
|
||||
if (!options.preserveFocus) {
|
||||
screen.focus({ preventScroll: true });
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
toastRegion.innerHTML = `<div class="toast">${escapeHtml(message)}</div>`;
|
||||
window.setTimeout(() => {
|
||||
toastRegion.innerHTML = "";
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
state.modal = null;
|
||||
state.consent = false;
|
||||
renderModal();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (state.modal) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
if (state.screen === "question") state.screen = "welcome";
|
||||
else if (state.screen === "casting") state.screen = "question";
|
||||
else if (state.screen === "result") state.screen = "question";
|
||||
else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
|
||||
render();
|
||||
}
|
||||
|
||||
function beginAiExplanation() {
|
||||
state.modal = null;
|
||||
state.screen = "loading";
|
||||
state.explanationSource = "ai";
|
||||
render();
|
||||
window.setTimeout(() => {
|
||||
if (state.screen !== "loading") return;
|
||||
state.screen = "explanation";
|
||||
render();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
document.addEventListener("input", (event) => {
|
||||
if (!event.target.matches("[data-question-input]")) return;
|
||||
state.question = event.target.value;
|
||||
document.querySelector("[data-char-count]").textContent = `${state.question.length}/120`;
|
||||
const startButton = document.querySelector("[data-action='start-casting']");
|
||||
if (startButton) startButton.disabled = !state.question.trim();
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
if (!event.target.matches("[data-action='consent-toggle']")) return;
|
||||
state.consent = event.target.checked;
|
||||
const confirm = document.querySelector("[data-action='confirm-ai']");
|
||||
if (confirm) confirm.disabled = !state.consent;
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const actionTarget = event.target.closest("[data-action]");
|
||||
if (!actionTarget) return;
|
||||
const action = actionTarget.dataset.action;
|
||||
|
||||
if (action === "dismiss-modal" && event.target.closest("[data-modal-sheet]")) return;
|
||||
|
||||
if (action === "back") goBack();
|
||||
else if (action === "begin") {
|
||||
state.screen = "question";
|
||||
render();
|
||||
} else if (action === "skip-question") {
|
||||
state.question = "未记录具体问题(仅作自我观察)";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.screen = "casting";
|
||||
render();
|
||||
} else if (action === "start-casting") {
|
||||
if (!state.question.trim()) return;
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.screen = "casting";
|
||||
render();
|
||||
} else if (action === "toggle-coin") {
|
||||
const index = Number(actionTarget.dataset.index);
|
||||
const current = state.coins[index];
|
||||
state.coins[index] = current === null || current === 3 ? 2 : 3;
|
||||
render({ preserveFocus: true });
|
||||
document.querySelector(`[data-action='toggle-coin'][data-index='${index}']`)?.focus();
|
||||
} else if (action === "confirm-line") {
|
||||
if (!state.coins.every((value) => value !== null)) return;
|
||||
state.lines.push(state.coins.reduce((total, value) => total + value, 0));
|
||||
state.coins = [null, null, null];
|
||||
if (state.lines.length === 6) {
|
||||
state.screen = "result";
|
||||
}
|
||||
render();
|
||||
} else if (action === "undo-line") {
|
||||
state.lines.pop();
|
||||
state.coins = [null, null, null];
|
||||
render();
|
||||
showToast("已撤销上一爻");
|
||||
} else if (action === "open-interpretation") {
|
||||
state.modal = "interpretation";
|
||||
renderModal();
|
||||
} else if (action === "choose-ai") {
|
||||
state.modal = "consent";
|
||||
state.consent = false;
|
||||
renderModal();
|
||||
} else if (action === "choose-local") {
|
||||
state.modal = null;
|
||||
state.explanationSource = "local";
|
||||
state.screen = "explanation";
|
||||
render();
|
||||
} else if (action === "confirm-ai") {
|
||||
if (!state.consent) return;
|
||||
beginAiExplanation();
|
||||
} else if (action === "retry-ai") {
|
||||
state.aiError = false;
|
||||
state.modal = "consent";
|
||||
state.consent = false;
|
||||
renderModal();
|
||||
} else if (action === "restart") {
|
||||
state.question = "";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.aiError = false;
|
||||
state.screen = "welcome";
|
||||
render();
|
||||
} else if (action === "back-to-result") {
|
||||
state.screen = "result";
|
||||
render();
|
||||
} else if (action === "open-method") {
|
||||
state.modal = "method";
|
||||
renderModal();
|
||||
} else if (action === "close-modal" || action === "dismiss-modal") {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && state.modal) closeModal();
|
||||
});
|
||||
|
||||
function seedPreview() {
|
||||
const view = new URLSearchParams(window.location.search).get("view");
|
||||
if (!view || view === "welcome") return;
|
||||
|
||||
if (view === "question") {
|
||||
state.screen = "question";
|
||||
state.question = SAMPLE_QUESTION;
|
||||
return;
|
||||
}
|
||||
|
||||
state.question = SAMPLE_QUESTION;
|
||||
if (view === "casting") {
|
||||
state.screen = "casting";
|
||||
state.lines = [9, 8];
|
||||
state.coins = [2, 3, null];
|
||||
return;
|
||||
}
|
||||
|
||||
state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
|
||||
state.screen = "result";
|
||||
|
||||
if (view === "consent") state.modal = "consent";
|
||||
else if (view === "explanation") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "ai";
|
||||
} else if (view === "local") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "local";
|
||||
} else if (view === "error") {
|
||||
state.aiError = true;
|
||||
}
|
||||
}
|
||||
|
||||
seedPreview();
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,342 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const screenshotDir = path.join(__dirname, "screenshots");
|
||||
const baseUrl = process.env.BRAINWAVE_PROTOTYPE_URL ?? "http://127.0.0.1:4173/prototype/";
|
||||
|
||||
const cases = [
|
||||
{ view: "welcome", file: "01-welcome.png", fullPage: true },
|
||||
{ view: "question", file: "02-question.png", fullPage: true },
|
||||
{ view: "casting", file: "03-casting.png", fullPage: true },
|
||||
{ view: "result", file: "04-result-moving.png", fullPage: true },
|
||||
{ view: "static", file: "05-result-static.png", fullPage: true },
|
||||
{ view: "consent", file: "06-ai-consent.png", fullPage: false },
|
||||
{ view: "explanation", file: "07-ai-explanation.png", fullPage: true },
|
||||
{ view: "error", file: "08-ai-error.png", fullPage: true }
|
||||
];
|
||||
|
||||
function findChrome() {
|
||||
const candidates = process.platform === "win32"
|
||||
? [
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
]
|
||||
: process.platform === "darwin"
|
||||
? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
|
||||
: ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
|
||||
|
||||
const executable = candidates.find(existsSync);
|
||||
if (!executable) {
|
||||
throw new Error("未找到 Chrome/Edge。请安装浏览器,或扩展 capture.mjs 中的候选路径。");
|
||||
}
|
||||
return executable;
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function waitForDebugTarget(port) {
|
||||
const endpoint = `http://127.0.0.1:${port}/json/list`;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
if (response.ok) {
|
||||
const targets = await response.json();
|
||||
const page = targets.find((target) => target.type === "page");
|
||||
if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
|
||||
}
|
||||
} catch {
|
||||
// Chrome may still be starting; retry within the bounded loop.
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error("Chrome DevTools endpoint did not become ready.");
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
constructor(webSocketUrl) {
|
||||
this.socket = new WebSocket(webSocketUrl);
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await new Promise((resolve, reject) => {
|
||||
this.socket.addEventListener("open", resolve, { once: true });
|
||||
this.socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
this.socket.addEventListener("message", (event) => this.#handleMessage(event));
|
||||
}
|
||||
|
||||
#handleMessage(event) {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id) {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) pending.reject(new Error(message.error.message));
|
||||
else pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this.listeners.get(message.method) ?? [];
|
||||
for (const listener of listeners) listener(message.params);
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(method, listeners);
|
||||
}
|
||||
|
||||
waitFor(method, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeoutMs);
|
||||
const listener = (params) => {
|
||||
clearTimeout(timeout);
|
||||
const listeners = this.listeners.get(method) ?? [];
|
||||
this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
|
||||
resolve(params);
|
||||
};
|
||||
this.on(method, listener);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluate(client, expression) {
|
||||
const result = await client.send("Runtime.evaluate", {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(result.exceptionDetails.text ?? "Runtime evaluation failed.");
|
||||
}
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
async function navigate(client, view) {
|
||||
const loaded = client.waitFor("Page.loadEventFired");
|
||||
await client.send("Page.navigate", { url: `${baseUrl}?view=${view}` });
|
||||
await loaded;
|
||||
await delay(320);
|
||||
}
|
||||
|
||||
async function capture(client, item) {
|
||||
await navigate(client, item.view);
|
||||
const dimensions = await evaluate(client, `(() => ({
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)
|
||||
}))()`);
|
||||
const height = item.fullPage ? dimensions.height : 792;
|
||||
const screenshot = await client.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, y: 0, width: 360, height, scale: 1 }
|
||||
});
|
||||
await writeFile(path.join(screenshotDir, item.file), Buffer.from(screenshot.data, "base64"));
|
||||
return { view: item.view, file: item.file, width: 360, height };
|
||||
}
|
||||
|
||||
async function auditView(client, view) {
|
||||
await navigate(client, view);
|
||||
return evaluate(client, `(() => {
|
||||
const visible = (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const buttons = [...document.querySelectorAll("button")].filter(visible);
|
||||
const touchIssues = buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
name: button.getAttribute("aria-label") || button.innerText.trim(),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
};
|
||||
}).filter((item) => item.width < 48 || item.height < 48);
|
||||
const unnamedButtons = buttons
|
||||
.filter((button) => !(button.getAttribute("aria-label") || button.innerText.trim()))
|
||||
.length;
|
||||
const checkboxes = [...document.querySelectorAll('input[type="checkbox"]')].filter(visible);
|
||||
const smallCheckboxLabels = checkboxes.filter((checkbox) => {
|
||||
const label = checkbox.closest("label");
|
||||
if (!label) return true;
|
||||
const rect = label.getBoundingClientRect();
|
||||
return rect.width < 48 || rect.height < 48;
|
||||
}).length;
|
||||
return { view: ${JSON.stringify(view)}, unnamedButtons, touchIssues, smallCheckboxLabels };
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const health = await fetch(baseUrl);
|
||||
if (!health.ok) {
|
||||
throw new Error(`原型服务不可用:${baseUrl} (${health.status})`);
|
||||
}
|
||||
|
||||
const chrome = findChrome();
|
||||
const port = await getFreePort();
|
||||
const profileDir = await mkdtemp(path.join(os.tmpdir(), "brainwave-prototype-capture-"));
|
||||
const chromeProcess = spawn(chrome, [
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"about:blank"
|
||||
], { stdio: "ignore", windowsHide: true });
|
||||
|
||||
let client;
|
||||
try {
|
||||
const webSocketUrl = await waitForDebugTarget(port);
|
||||
client = new CdpClient(webSocketUrl);
|
||||
await client.connect();
|
||||
await Promise.all([
|
||||
client.send("Page.enable"),
|
||||
client.send("Runtime.enable"),
|
||||
client.send("Network.enable"),
|
||||
client.send("Log.enable"),
|
||||
client.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 360,
|
||||
height: 792,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
screenWidth: 360,
|
||||
screenHeight: 792
|
||||
})
|
||||
]);
|
||||
|
||||
const consoleErrors = [];
|
||||
const runtimeErrors = [];
|
||||
const externalRequests = [];
|
||||
client.on("Log.entryAdded", ({ entry }) => {
|
||||
if (entry.level === "error") consoleErrors.push(entry.text);
|
||||
});
|
||||
client.on("Runtime.exceptionThrown", ({ exceptionDetails }) => {
|
||||
runtimeErrors.push(exceptionDetails.exception?.description ?? exceptionDetails.text);
|
||||
});
|
||||
client.on("Network.requestWillBeSent", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
if (!["127.0.0.1", "localhost"].includes(url.hostname) && url.protocol !== "data:") {
|
||||
externalRequests.push(request.url);
|
||||
}
|
||||
});
|
||||
|
||||
await mkdir(screenshotDir, { recursive: true });
|
||||
const screenshots = [];
|
||||
for (const item of cases) screenshots.push(await capture(client, item));
|
||||
|
||||
await navigate(client, "result");
|
||||
const movingResult = await evaluate(client, `(() => ({
|
||||
names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
|
||||
numbers: [...document.querySelectorAll(".hexagram-number")].map((node) => node.textContent.trim()),
|
||||
moving: document.querySelector(".result-kicker")?.textContent.replace(/\\s+/g, " ").trim()
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "static");
|
||||
const staticResult = await evaluate(client, `(() => ({
|
||||
names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
|
||||
transformCount: document.querySelectorAll(".transform-arrow").length,
|
||||
label: document.querySelector(".static-indicator")?.textContent.trim()
|
||||
}))()`);
|
||||
|
||||
await navigate(client, "consent");
|
||||
const consentBefore = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled");
|
||||
await evaluate(client, `(() => {
|
||||
document.querySelector('[data-action="consent-toggle"]').click();
|
||||
return document.querySelector('[data-action="confirm-ai"]').disabled;
|
||||
})()`);
|
||||
const consentAfter = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled");
|
||||
await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.click()");
|
||||
await delay(1050);
|
||||
const explanationSource = await evaluate(client, "document.querySelector('.source-badge')?.textContent.trim()");
|
||||
|
||||
const accessibility = [];
|
||||
for (const item of cases) accessibility.push(await auditView(client, item.view));
|
||||
|
||||
const report = {
|
||||
chrome,
|
||||
baseUrl,
|
||||
screenshots,
|
||||
movingResult,
|
||||
staticResult,
|
||||
consentGate: {
|
||||
disabledBeforeConsent: consentBefore,
|
||||
disabledAfterConsent: consentAfter,
|
||||
explanationSource
|
||||
},
|
||||
consoleErrors,
|
||||
runtimeErrors,
|
||||
externalRequests: [...new Set(externalRequests)],
|
||||
accessibility
|
||||
};
|
||||
|
||||
const failed =
|
||||
movingResult.names.join(",") !== "复,坤" ||
|
||||
movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" ||
|
||||
!movingResult.moving.includes("1 个动爻") ||
|
||||
staticResult.names.join(",") !== "乾" ||
|
||||
staticResult.transformCount !== 0 ||
|
||||
consentBefore !== true ||
|
||||
consentAfter !== false ||
|
||||
explanationSource !== "AI 生成" ||
|
||||
consoleErrors.length > 0 ||
|
||||
runtimeErrors.length > 0 ||
|
||||
externalRequests.length > 0 ||
|
||||
accessibility.some((item) =>
|
||||
item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failed) process.exitCode = 1;
|
||||
} finally {
|
||||
client?.close();
|
||||
chromeProcess.kill();
|
||||
await delay(150);
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error.stack ?? error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f7f2e8" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%238c2f2b'/%3E%3Cpath d='M8 11h16M8 21h6m4 0h6' stroke='%23fffdf7' stroke-width='3'/%3E%3C/svg%3E" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Brainwave 东方文化反思工具的移动端高保真交互原型"
|
||||
/>
|
||||
<title>一问 · Brainwave 高保真原型</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<script src="./app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#screen">跳到主要内容</a>
|
||||
|
||||
<div class="prototype-stage">
|
||||
<div class="app-shell" data-app-shell>
|
||||
<header class="topbar" data-topbar>
|
||||
<button
|
||||
class="icon-button topbar__back"
|
||||
type="button"
|
||||
data-action="back"
|
||||
aria-label="返回"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="wordmark" aria-label="Brainwave 原型,工作名一问">
|
||||
<span class="wordmark__name">一问</span>
|
||||
<span class="wordmark__meta">BRAINWAVE · 原型</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
data-action="open-method"
|
||||
aria-label="查看起卦方法说明"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 10.8v5.2M12 7.4v.1" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main id="screen" class="screen" tabindex="-1"></main>
|
||||
<div id="toast-region" class="toast-region" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 109 KiB |