1
This commit is contained in:
@@ -126,7 +126,7 @@ tools: ["read", "write", "shell"]
|
||||
| `apps/admin-web/src/` | `apps/admin-web/README.md` |
|
||||
| `apps/miniprogram/` | `apps/miniprogram/README.md` |
|
||||
| `packages/shared/` | `packages/shared/README.md` |
|
||||
| `db/*/migrations/*.sql` | `docs/database/BD_Manual_*.md` + `docs/database/ddl/` |
|
||||
| `db/*/migrations/*.sql` | `docs/database/BD_Manual_*.md` + `apps/etl/connectors/feiqiu/docs/database/` + `docs/database/ddl/` |
|
||||
|
||||
### 步骤 4:DDL/迁移检查
|
||||
- 若 `compliance.new_migration_sql` 非空:
|
||||
|
||||
14
.kiro/hooks/field-disappearance-scan.kiro.hook
Normal file
14
.kiro/hooks/field-disappearance-scan.kiro.hook
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"name": "字段消失扫描",
|
||||
"description": "手动触发 DWD 表字段消失扫描,检测字段值从某天起突然全部为空的异常(≥3天且≥20条连续空记录)。输出终端报告 + CSV。",
|
||||
"version": "1",
|
||||
"when": {
|
||||
"type": "userTriggered"
|
||||
},
|
||||
"then": {
|
||||
"type": "runCommand",
|
||||
"command": "python scripts/ops/field_disappearance_scan.py",
|
||||
"timeout": 300
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"image-compare": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-image-compare-server"],
|
||||
"disabled": false,
|
||||
"autoApprove": [ "*",
|
||||
"all"]
|
||||
},
|
||||
"weixin-devtools-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"],
|
||||
@@ -14,9 +7,8 @@
|
||||
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
|
||||
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
|
||||
},
|
||||
"disabled": false,
|
||||
"autoApprove": [ "*",
|
||||
"all"]
|
||||
"disabled": true,
|
||||
"autoApprove": ["*"]
|
||||
},
|
||||
"git": {
|
||||
"command": "uvx",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 任务
|
||||
|
||||
- [ ] 1. 数据库表结构与基础模块搭建
|
||||
- [x] 1. 数据库表结构与基础模块搭建
|
||||
- [x] 1.1 创建 DDL 迁移脚本,在 `biz` schema 下建表 `ai_conversations`、`ai_messages`、`ai_cache`
|
||||
- 按设计文档中的 DDL 创建三张表,包含所有字段、CHECK 约束、索引
|
||||
- DDL 文件放置于 `db/zqyy_app/migrations/` 目录,日期前缀命名
|
||||
@@ -33,7 +33,7 @@
|
||||
- 测试文件:`tests/test_p5_ai_integration_properties.py`
|
||||
- **验证: 需求 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
|
||||
|
||||
- [ ] 2. 百炼 API 统一封装层(BailianClient)
|
||||
- [x] 2. 百炼 API 统一封装层(BailianClient)
|
||||
- [x] 2.1 实现 BailianClient 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/bailian_client.py`
|
||||
- 使用 `openai` Python SDK,`base_url` 指向百炼端点
|
||||
@@ -90,7 +90,7 @@
|
||||
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
|
||||
- **验证: 需求 3.7**
|
||||
|
||||
- [ ] 4. AI 缓存读写服务(AICacheService)
|
||||
- [x] 4. AI 缓存读写服务(AICacheService)
|
||||
- [x] 4.1 实现 AICacheService
|
||||
- 文件:`apps/backend/app/ai/cache_service.py`
|
||||
- `get_latest`:按 (cache_type, site_id, target_id) 查询最新记录
|
||||
@@ -99,30 +99,30 @@
|
||||
- `_cleanup_excess`:保留最近 500 条,删除最旧的
|
||||
- _需求: 12.1, 12.2, 12.3, 12.4, 12.5_
|
||||
|
||||
- [-] 4.2 编写属性测试:缓存写入 round-trip
|
||||
- [x] 4.2 编写属性测试:缓存写入 round-trip
|
||||
- **Property 7: 缓存写入 round-trip**
|
||||
- 使用 test_zqyy_app 数据库,随机 cache_type、target_id、result_json,验证写入后查询一致
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
|
||||
|
||||
- [-] 4.3 编写属性测试:缓存查询 site_id 隔离
|
||||
- [x] 4.3 编写属性测试:缓存查询 site_id 隔离
|
||||
- **Property 13: 缓存查询 site_id 隔离**
|
||||
- 使用 test_zqyy_app 数据库,写入 site_id=A 的记录,以 site_id=B 查询应返回空
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 12.1, 12.5**
|
||||
|
||||
- [-] 4.4 编写属性测试:缓存保留上限
|
||||
- [x] 4.4 编写属性测试:缓存保留上限
|
||||
- **Property 14: 缓存保留上限**
|
||||
- 使用 test_zqyy_app 数据库,批量写入 >500 条记录,验证清理后 ≤ 500
|
||||
- 测试文件:`apps/backend/tests/test_ai_cache.py`
|
||||
- **验证: 需求 12.3**
|
||||
|
||||
- [ ] 5. 检查点 - 基础服务验证
|
||||
- [x] 5. 检查点 - 基础服务验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证 BailianClient、ConversationService、AICacheService 三个核心服务可独立工作
|
||||
|
||||
- [ ] 6. 应用 1 通用对话 SSE 端点
|
||||
- [~] 6.1 实现 App1 Chat 核心逻辑
|
||||
- [x] 6. 应用 1 通用对话 SSE 端点
|
||||
- [x] 6.1 实现 App1 Chat 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app1_chat.py`
|
||||
- 每次进入 chat 页面新建 ai_conversations 记录(不复用)
|
||||
- 首条消息注入页面上下文(source_page、page_context、screen_content)
|
||||
@@ -132,7 +132,7 @@
|
||||
- 上下文注入框架留接口(页面文本化工具 P5-B 实现)
|
||||
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8_
|
||||
|
||||
- [~] 6.2 实现 SSE 路由端点
|
||||
- [x] 6.2 实现 SSE 路由端点
|
||||
- 文件:`apps/backend/app/routers/xcx_ai_chat.py`
|
||||
- `POST /api/ai/chat/stream`:SSE 协议推送,Content-Type: text/event-stream
|
||||
- SSE 事件格式:chunk / done / error
|
||||
@@ -142,21 +142,21 @@
|
||||
- 注册路由到 FastAPI app
|
||||
- _需求: 3.1, 3.7_
|
||||
|
||||
- [~] 6.3 编写单元测试:SSE 端点
|
||||
- [x] 6.3 编写单元测试:SSE 端点
|
||||
- 验证 SSE Content-Type 和事件格式(chunk/done/error)
|
||||
- 验证未认证返回 401、空消息返回 422
|
||||
- 测试文件:`apps/backend/tests/test_ai_chat.py`
|
||||
- _需求: 3.1_
|
||||
|
||||
- [ ] 7. 应用 2 财务洞察(完整 Prompt)
|
||||
- [~] 7.1 实现 App2 Finance Prompt 模板
|
||||
- [x] 7. 应用 2 财务洞察(完整 Prompt)
|
||||
- [x] 7.1 实现 App2 Finance Prompt 模板
|
||||
- 文件:`apps/backend/app/ai/prompts/app2_finance_prompt.py`
|
||||
- 完整 Prompt 包含:当期和上期收入结构(table_fee=table_charge_money、assistant_pd=assistant_pd_money、assistant_cx=assistant_cx_money、goods=goods_money、recharge=充值 pay_amount settle_type=5)
|
||||
- 包含储值资产、费用汇总、平台结算数据
|
||||
- 使用 items_sum 口径,禁止 consume_money
|
||||
- _需求: 4.5, 4.6_
|
||||
|
||||
- [~] 7.2 实现 App2 Finance 核心逻辑
|
||||
- [x] 7.2 实现 App2 Finance 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app2_finance.py`
|
||||
- 8 个时间维度独立调用(this_month, last_month, this_week, last_week, last_3_months, this_quarter, last_quarter, last_6_months)
|
||||
- 营业日分界点 08:00(`BUSINESS_DAY_START_HOUR` 环境变量)
|
||||
@@ -165,14 +165,14 @@
|
||||
- 返回结构化 JSON(insights 数组:seq + title + body)
|
||||
- _需求: 4.1, 4.2, 4.3, 4.4, 4.7_
|
||||
|
||||
- [~] 7.3 编写单元测试:App2 时间维度计算
|
||||
- [x] 7.3 编写单元测试:App2 时间维度计算
|
||||
- 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00)
|
||||
- 验证 Prompt 使用 items_sum 口径字段映射
|
||||
- 测试文件:`apps/backend/tests/test_ai_app2.py`
|
||||
- _需求: 4.3, 4.6_
|
||||
|
||||
- [ ] 8. 应用 3/4/5/6/7 骨架实现
|
||||
- [~] 8.1 实现 App3 Clue 骨架
|
||||
- [x] 8. 应用 3/4/5/6/7 骨架实现
|
||||
- [x] 8.1 实现 App3 Clue 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app3_clue.py`
|
||||
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache
|
||||
- `build_prompt`:留接口,返回占位 Prompt,标注待细化字段(consumption_records 等待 P9-T1)
|
||||
@@ -182,7 +182,7 @@
|
||||
- 结果写入 ai_cache(cache_type=app3_clue,target_id=member_id)
|
||||
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
|
||||
|
||||
- [~] 8.2 实现 App4 Analysis 骨架
|
||||
- [x] 8.2 实现 App4 Analysis 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app4_analysis.py`
|
||||
- `build_prompt`:留接口(service_history、assistant_info 待 P6-T4)
|
||||
- Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at)
|
||||
@@ -190,7 +190,7 @@
|
||||
- 结果写入 ai_cache(cache_type=app4_analysis,target_id=`{assistant_id}_{member_id}`)
|
||||
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7_
|
||||
|
||||
- [~] 8.3 实现 App5 Tactics 骨架
|
||||
- [x] 8.3 实现 App5 Tactics 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app5_tactics.py`
|
||||
- 接收 App4 完整返回结果作为 Prompt 中的 task_suggestion 字段
|
||||
- `build_prompt`:留接口(service_history、assistant_info 随 App4 同步在 P6-T4)
|
||||
@@ -198,7 +198,7 @@
|
||||
- 结果写入 ai_cache(cache_type=app5_tactics,target_id=`{assistant_id}_{member_id}`)
|
||||
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
|
||||
|
||||
- [~] 8.4 实现 App6 Note 骨架
|
||||
- [x] 8.4 实现 App6 Note 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app6_note.py`
|
||||
- `build_prompt`:留接口(consumption_data 待 P9-T1)
|
||||
- 返回 score(1-10)+ clues 数组,category 限定 6 个枚举值
|
||||
@@ -208,7 +208,7 @@
|
||||
- 结果写入 ai_cache(cache_type=app6_note_analysis,target_id=member_id),score 存入 ai_cache.score
|
||||
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8_
|
||||
|
||||
- [~] 8.5 实现 App7 Customer 骨架
|
||||
- [x] 8.5 实现 App7 Customer 骨架
|
||||
- 文件:`apps/backend/app/ai/apps/app7_customer.py`
|
||||
- `build_prompt`:留接口(objective_data 待 P9-T1)
|
||||
- 使用 items_sum 口径
|
||||
@@ -217,22 +217,22 @@
|
||||
- 结果写入 ai_cache(cache_type=app7_customer_analysis,target_id=member_id)
|
||||
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
|
||||
|
||||
- [~] 8.6 编写属性测试:Prompt reference 历史注入
|
||||
- [x] 8.6 编写属性测试:Prompt reference 历史注入
|
||||
- **Property 9: Prompt reference 历史注入**
|
||||
- Mock 缓存数据,验证各应用 build_prompt 的 reference 字段包含正确的缓存结果和 generated_at 时间戳
|
||||
- 缓存不存在时 reference 为空对象
|
||||
- 测试文件:`apps/backend/tests/test_ai_apps_prompt.py`
|
||||
- **验证: 需求 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
|
||||
|
||||
- [ ] 9. 应用 8 维客线索整理(完整 Prompt)+ ClueWriter
|
||||
- [~] 9.1 实现 App8 Consolidation Prompt 模板
|
||||
- [x] 9. 应用 8 维客线索整理(完整 Prompt)+ ClueWriter
|
||||
- [x] 9.1 实现 App8 Consolidation Prompt 模板
|
||||
- 文件:`apps/backend/app/ai/prompts/app8_consolidation_prompt.py`
|
||||
- 完整 Prompt:接收 App3 和 App6 全部线索(附 generated_at),整合去重
|
||||
- 分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致)
|
||||
- 合并相似线索(多提供者逗号分隔),其余原文返回,最小改动原则
|
||||
- _需求: 10.3, 10.4, 10.5, 10.6_
|
||||
|
||||
- [~] 9.2 实现 ClueWriter 全量替换逻辑
|
||||
- [x] 9.2 实现 ClueWriter 全量替换逻辑
|
||||
- 集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
|
||||
- DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)
|
||||
- 字段映射:emoji+summary 拼接、providers→recorded_by_name、source 判断逻辑
|
||||
@@ -240,25 +240,25 @@
|
||||
- 人工线索(source='manual')不受影响
|
||||
- _需求: 10.7, 10.8, 10.9_
|
||||
|
||||
- [~] 9.3 实现 App8 Consolidation 核心逻辑
|
||||
- [x] 9.3 实现 App8 Consolidation 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/apps/app8_consolidation.py`
|
||||
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache + member_retention_clue
|
||||
- 结果同时写入 ai_cache(cache_type=app8_clue_consolidated,target_id=member_id)
|
||||
- _需求: 10.1, 10.2, 10.10_
|
||||
|
||||
- [~] 9.4 编写属性测试:ClueWriter 全量替换不变量
|
||||
- [x] 9.4 编写属性测试:ClueWriter 全量替换不变量
|
||||
- **Property 12: ClueWriter 全量替换不变量**
|
||||
- 使用 test_zqyy_app 数据库,随机线索列表 + 预置人工线索
|
||||
- 验证:AI 线索数量 = new_clues 数量、人工线索不变、recorded_by_assistant_id=NULL、summary=emoji+空格+原始 summary
|
||||
- 测试文件:`apps/backend/tests/test_ai_clue_writer.py`
|
||||
- **验证: 需求 10.7, 10.8, 10.9**
|
||||
|
||||
- [ ] 10. 检查点 - 应用层验证
|
||||
- [x] 10. 检查点 - 应用层验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- 验证 8 个应用的 run 函数可独立调用(Mock 百炼 API)
|
||||
|
||||
- [ ] 11. 事件调度与调用链编排(AIDispatcher)
|
||||
- [~] 11.1 实现 AIDispatcher 核心逻辑
|
||||
- [x] 11. 事件调度与调用链编排(AIDispatcher) ✅
|
||||
- [x] 11.1 实现 AIDispatcher 核心逻辑
|
||||
- 文件:`apps/backend/app/ai/dispatcher.py`
|
||||
- `handle_consumption_event`:App3 → App8 → App7(+ App4 → App5 如有助教)
|
||||
- `handle_note_event`:App6 → App8
|
||||
@@ -268,40 +268,40 @@
|
||||
- 整条链后台异步执行,不阻塞业务请求
|
||||
- _需求: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
|
||||
|
||||
- [~] 11.2 集成事件触发点
|
||||
- [x] 11.2 集成事件触发点
|
||||
- 在 `trigger_scheduler.fire_event()` 中注册 AI 事件处理器
|
||||
- 消费事件(consumption_settled)→ `ai_dispatcher.handle_consumption_event`
|
||||
- 备注事件(note_created)→ `ai_dispatcher.handle_note_event`
|
||||
- 任务分配事件(task_assigned)→ `ai_dispatcher.handle_task_assign_event`
|
||||
- _需求: 5.1, 6.1, 6.2, 7.1, 8.1, 9.1, 11.1, 11.2, 11.3, 11.4_
|
||||
|
||||
- [~] 11.3 编写属性测试:事件调用链顺序正确性
|
||||
- [x] 11.3 编写属性测试:事件调用链顺序正确性
|
||||
- **Property 10: 事件调用链顺序正确性**
|
||||
- Mock 所有应用,记录调用序列,验证四种事件链的严格顺序
|
||||
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
|
||||
- **验证: 需求 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
|
||||
|
||||
- [~] 11.4 编写属性测试:调用链容错不变量
|
||||
- [x] 11.4 编写属性测试:调用链容错不变量
|
||||
- **Property 11: 调用链容错不变量**
|
||||
- Mock 随机应用失败,验证后续应用继续执行且失败应用有错误日志
|
||||
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
|
||||
- **验证: 需求 11.7**
|
||||
|
||||
- [ ] 12. 缓存查询路由与环境配置
|
||||
- [~] 12.1 实现缓存查询路由
|
||||
- [x] 12. 缓存查询路由与环境配置
|
||||
- [x] 12.1 实现缓存查询路由
|
||||
- 文件:`apps/backend/app/routers/xcx_ai_cache.py`
|
||||
- `GET /api/ai/cache/{cache_type}?target_id=xxx`:查询最新缓存
|
||||
- JWT 认证,site_id 从 token 提取强制过滤
|
||||
- 注册路由到 FastAPI app
|
||||
- _需求: 12.1, 12.2, 12.5_
|
||||
|
||||
- [~] 12.2 新增环境变量配置
|
||||
- [x] 12.2 新增环境变量配置
|
||||
- 在 `.env.template` 中添加 `BAILIAN_API_KEY`、`BAILIAN_BASE_URL`、`BAILIAN_MODEL`、`BUSINESS_DAY_START_HOUR`
|
||||
- 在后端配置加载逻辑中读取这些变量,缺失时报错
|
||||
- _需求: 2.1, 14.1, 14.2_
|
||||
|
||||
- [ ] 13. 百炼技术方案确认文档
|
||||
- [ ] 13.1 输出百炼技术方案确认文档
|
||||
- [x] 13. 百炼技术方案确认文档 ✅
|
||||
- [x] 13.1 输出百炼技术方案确认文档
|
||||
- 文件:`docs/reports/bailian-technical-solution.md`
|
||||
- 确认流式返回方案(OpenAI 兼容 SSE)
|
||||
- 确认 JSON 输出模式(response_format + System Prompt 约束)
|
||||
@@ -309,8 +309,8 @@
|
||||
- 作为 BailianClient 实现的依据
|
||||
- _需求: 14.1, 14.2, 14.3_
|
||||
|
||||
- [ ] 14. 最终检查点 - 全量验证
|
||||
- 确保所有测试通过,ask the user if questions arise.
|
||||
- [x] 14. 最终检查点 - 全量验证 ✅
|
||||
- 全部 9 个测试文件、62 个测试用例通过(2026-03-09)
|
||||
- 验证所有路由注册正确、事件触发点集成完毕、环境变量配置完整
|
||||
|
||||
## 备注
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}
|
||||
498
.kiro/specs/h5-miniprogram-migration-subsequent/design.md
Normal file
498
.kiro/specs/h5-miniprogram-migration-subsequent/design.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# 技术设计文档:H5 → 微信小程序像素精调
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本设计文档为 17 个页面(共 79 个对照处理单元)的 Step 6-7 像素精调提供技术方案。所有页面已完成 Step 0-5 结构迁移(TS 零诊断、路由注册、四态处理)。
|
||||
|
||||
核心设计思路:以「对照处理单元」为最小粒度,通过两阶段收敛流程(结构级修正 → 像素级精调)系统性消除视觉差异。
|
||||
|
||||
参考文档(不重复,仅索引):
|
||||
- 迁移规则与转换公式:`docs/prd/MIGRATION-PLAYBOOK.md`
|
||||
- 样式标准值:`docs/h5_ui/design-tokens.json`(颜色灰阶、字号、圆角、阴影的标准 rpx 值)
|
||||
- 工具链源码:`scripts/ops/anchor_compare.py`
|
||||
|
||||
## 2. 截图策略
|
||||
|
||||
### 2.1 双端参数对齐
|
||||
|
||||
| 参数 | H5 | MP | 对比基准 |
|
||||
|------|-----|-----|---------|
|
||||
| viewport | 430×752 | 430×752(windowHeight) | 统一 |
|
||||
| DPR | 1.5 | 1.5(MCP 有效 DPR) | 统一 |
|
||||
| 输出尺寸 | 645×1128 | 645×1128 | 统一,无需缩放 |
|
||||
|
||||
> 验证数据(2026-03-10 board-finance 实测):H5 Playwright DPR=1.5 输出 645×1128;MP MCP screenshot 输出 645×1128。两端尺寸完全一致,无需任何缩放处理。
|
||||
|
||||
### 2.2 固定步长滚动方案
|
||||
|
||||
采用固定 600px 步长滚动截图,替代锚点方案。两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点位置,消除 H5/MP 元素位置差异导致的对齐问题。
|
||||
|
||||
核心参数:
|
||||
- 步长:600px(逻辑像素)
|
||||
- scrollTop 序列:从 0 开始,每次 +600,最后一屏 clamp 到 `maxScroll = scrollHeight - viewportHeight`
|
||||
- 段数计算:`N = floor(maxScroll / 600) + 1`(首屏 step-0 + 每 600px 一步 + 最后一步 clamp 到 maxScroll);`maxScroll ≤ 10` 的页面视为单屏(N=1)
|
||||
|
||||
实测精度(board-finance,2026-03-10):
|
||||
- H5 端:Playwright `window.scrollTo` 精确命中目标值(偏差 0px)
|
||||
- MP 端:`wx.pageScrollTo` 精确命中目标值
|
||||
|
||||
### 2.3 底部浮动元素处理
|
||||
|
||||
H5 端存在 `#bottomNav`(64px fixed 底部导航)和 `.ai-float-btn-container`(56px 浮动按钮),MP 端使用原生 tabBar(不在 webview 截图中)。
|
||||
|
||||
处理方式:H5 截图前用 JS 隐藏所有底部浮动元素:
|
||||
```javascript
|
||||
document.getElementById('bottomNav').style.display = 'none';
|
||||
document.querySelectorAll('.ai-float-btn-container').forEach(el => el.style.display = 'none');
|
||||
```
|
||||
MP 端原生 tabBar 本来不在截图中,无需处理。MP 端如果存在 AI 浮动按钮,因 H5 端已隐藏,该区域的差异在对比时可忽略,不计入差异率。
|
||||
|
||||
### 2.4 浮动元素专项检测(结构校验阶段)
|
||||
|
||||
长页面滚动截图中,sticky/fixed 元素(如 sticky 导航栏、筛选栏)在每一屏的相同位置出现。通过滚屏上下页对比,可以精准识别这些浮动元素的差异并专项修复。
|
||||
|
||||
检测方法:
|
||||
1. 对同一页面的相邻屏截图进行对比,在不同屏中持续出现在相同位置的差异区域即为 sticky/fixed 元素
|
||||
2. 对 H5 vs MP 的 sticky 区域做像素对比,差异即为浮动元素的样式偏差
|
||||
3. sticky 元素差异在每一屏都会重复出现,修复一次即可消除所有屏的该区域差异
|
||||
|
||||
适用场景:
|
||||
- board-finance:`.safe-area-top`(45px) + `#filterBar`(71px) = 116px sticky
|
||||
- board-coach/board-customer:`.board-tabs`(42px) + `.filter-bar`(68px) = 110px sticky
|
||||
- 所有带 sticky 头部的长页面
|
||||
|
||||
优化效果:修复 sticky 区域差异后,所有屏的差异率会同步下降(因为每屏都包含相同的 sticky 区域)。
|
||||
|
||||
### 2.5 截图操作指南
|
||||
|
||||
#### 2.5.1 H5 截图
|
||||
|
||||
底层技术:Playwright + Chromium,viewport 430×752,DPR=1.5,headless=True。
|
||||
|
||||
流程:
|
||||
1. 打开 Live Server 提供的 H5 页面(`http://localhost:5500/docs/h5_ui/<page>.html`)
|
||||
2. 等待 Tailwind CDN JIT 渲染(1500ms)
|
||||
3. 隐藏滚动条 + 底部浮动元素(`#bottomNav` + `.ai-float-btn-container`)
|
||||
4. 按 scrollTop 序列逐屏截图:先 `scrollTo(0,0)` 再 `scrollTo(0, target)`
|
||||
|
||||
截图脚本:`scripts/ops/anchor_compare.py extract-h5 <page>`(固定 600px 步长模式)
|
||||
|
||||
输出:`docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png`(645×1128)
|
||||
|
||||
#### 2.5.2 MP 截图
|
||||
|
||||
前置条件:微信开发者工具已打开,MCP 已连接。
|
||||
|
||||
流程:
|
||||
1. 导航到目标页面(tabBar 页面用 `switch_tab`,其他用 `navigate_to`)
|
||||
2. 等待页面加载(2000ms)
|
||||
3. 按相同 scrollTop 序列逐屏截图:
|
||||
```javascript
|
||||
// 每屏:先回顶再滚到目标,确保精确
|
||||
wx.pageScrollTo({ scrollTop: 0, duration: 0 })
|
||||
// 等待 200ms
|
||||
wx.pageScrollTo({ scrollTop: target, duration: 0 })
|
||||
// 等待 500ms,读取实际 scrollTop,截图
|
||||
```
|
||||
|
||||
输出:`docs/h5_ui/compare/<page>/mp--step-<scrollTop>.png`(645×1128)
|
||||
|
||||
#### 2.5.3 多维度页面 MP 截图
|
||||
|
||||
board-coach 和 board-customer 为多维度页面,每个维度的卡片模板不同,需逐维度截图。
|
||||
|
||||
board-coach(4 种排序维度):
|
||||
- 维度通过筛选栏下拉切换(`onSortChange`),对应 4 种卡片模板:`perf`(定档业绩)、`salary`(工资)、`sv`(客源储值)、`task`(任务完成)
|
||||
- MP 操作:获取页面快照 → 点击排序筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
|
||||
- H5 操作:通过 JS 调用 `selectType('perf_desc')` 等切换 dim-container 显隐 → 截图
|
||||
- 子目录:`board-coach/perf/`、`board-coach/salary/`、`board-coach/sv/`、`board-coach/task/`
|
||||
|
||||
board-customer(8 种客户维度):
|
||||
- 维度通过筛选栏下拉切换(`onDimensionChange`),对应 8 种卡片模板:`recall`(最应召回)、`potential`(消费潜力)、`balance`(最高余额)、`recharge`(最近充值)、`recent`(最近到店)、`spend60`(最高消费)、`freq60`(最频繁)、`loyal`(最专一)
|
||||
- MP 操作:获取页面快照 → 点击维度筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
|
||||
- H5 操作:通过 JS 调用 `selectType('recall')` 等切换 dim-container 显隐 → 截图
|
||||
- 子目录:`board-customer/recall/`、`board-customer/potential/` ... `board-customer/loyal/`
|
||||
|
||||
> 交互文档参考:`docs/h5_ui/interactions/board-coach.md`、`docs/h5_ui/interactions/board-customer.md`
|
||||
|
||||
#### 2.5.4 逐屏对比
|
||||
|
||||
对同一 scrollTop 的 H5/MP 截图做像素对比:
|
||||
```
|
||||
mcp_image_compare_compare_images
|
||||
image1_path: "docs/h5_ui/compare/<page>/h5--step-<N>.png"
|
||||
image2_path: "docs/h5_ui/compare/<page>/mp--step-<N>.png"
|
||||
diff_output_path: "docs/h5_ui/compare/<page>/diff--step-<N>.png"
|
||||
threshold: 0.1
|
||||
```
|
||||
|
||||
### 2.5.5 双端高度不一致处理
|
||||
|
||||
MP 端页面高度可能与 H5 不一致(样式差异导致内容更短或更长)。如果直接按 H5 的 scrollTop 序列去滚 MP,可能滚过头(scrollTo 被 clamp)或漏截(页面更长)。
|
||||
|
||||
处理流程:
|
||||
1. 先截双端 step-0(首屏),对比确认基线
|
||||
2. 再截 step-600(第二屏),读取 MP 端实际 scrollTop:
|
||||
- 如果 MP 实际 scrollTop 远小于 600(被 clamp),说明 MP 页面比 H5 短,后续步骤需按 MP 实际 maxScroll 调整序列
|
||||
- 如果 MP 实际 scrollTop ≈ 600,说明双端高度接近,继续按 H5 序列推进
|
||||
3. 之后逐屏推进,每屏截图前先读取 MP 端实际 scrollTop,确认到达预期位置
|
||||
4. 如果 MP 页面比 H5 长(MP 还能继续滚但 H5 序列已结束),追加额外步骤直到 MP 也到达 maxScroll
|
||||
|
||||
> 此规则防止因双端高度差异导致后续屏截图错位或遗漏。
|
||||
|
||||
### 2.6 长页面级联影响与修正规则
|
||||
|
||||
修正某一屏的 WXSS 时,可能影响后续屏的布局。因此:
|
||||
|
||||
1. 修正后必须重新截取所有屏的截图(因为固定步长方案下,布局变化会影响每屏内容)
|
||||
2. 优先修复 sticky 区域差异(一次修复,所有屏受益)
|
||||
3. 从 step-0 开始顺序审查差异,确保前序屏达标后再处理后续屏
|
||||
4. 如果修正引入回归,回退到受影响的最早屏重新验证
|
||||
|
||||
### 2.7 结构级视觉元素识别清单(阶段一修正范围)
|
||||
|
||||
以下元素类型在双端截图中容易产生大面积差异(>10%),属于阶段一优先识别和修正的对象。严重缺失时可能导致 >15% 触发重写。
|
||||
|
||||
#### 2.7.1 大面积背景
|
||||
|
||||
| 背景类型 | MP 支持 | 处理方式 |
|
||||
|----------|---------|---------|
|
||||
| 纯色 / `linear-gradient` / `radial-gradient` / `repeating-linear-gradient` | ✅ | 直接迁移,查 `docs/h5_ui/design-tokens.json` 取精确色值和方向 |
|
||||
| `backdrop-filter: blur()` | ❌ | 改用 `background: rgba(255,255,255,0.95)` 半透明纯色 |
|
||||
| `url("data:image/svg+xml,...")` | ❌ | 用 CSS 渐变模拟,或导出为 PNG/base64 引用 |
|
||||
|
||||
精调要点:
|
||||
- 渐变方向和色值必须与 H5 完全一致,大面积色差肉眼极其明显
|
||||
- 半透明背景透明度差 0.1 在大面积上可见
|
||||
|
||||
#### 2.7.2 复杂图标与 SVG
|
||||
|
||||
Step 0-5 已完成所有 SVG 迁移决策(详见 `MIGRATION-PLAYBOOK.md` 3.5 节)。精调阶段关注:
|
||||
|
||||
| 图标类型 | 精调关注点 | 已审计案例 |
|
||||
|----------|-----------|-----------|
|
||||
| TDesign `<t-icon>` | `size`(rpx)和 `color` 与 H5 原始 SVG 一致 | 目录按钮 `view-list`、筛选箭头 `caret-down-small` |
|
||||
| 导出 SVG + `<image>` | `width`/`height`(rpx)与 H5 显示尺寸一致 | AI 悬浮按钮、底部导航栏图标 |
|
||||
| 文字/Emoji 替代 | 可接受差异,不计入差异率 | 环比箭头 ↑↓、AI 机器人 🤖 |
|
||||
| CSS 渐变 + 文字组合(如头像) | 渐变方向、色值、圆角、文字大小一致 | 客户头像(8 种渐变色 135deg) |
|
||||
| 帮助"?"图标(文字+圆形背景) | 圆形背景 size/color、文字 font-size 一致 | board-finance 指标标签旁 |
|
||||
|
||||
图标缺失或尺寸严重偏差属于阶段一结构级问题;尺寸/颜色微调属于阶段二。
|
||||
|
||||
#### 2.7.3 带文字的标签(Badge / Tag)
|
||||
|
||||
标签是高频偏差元素,涉及背景色、圆角、字号、内边距的组合,任一偏差都会在截图中明显可见:
|
||||
|
||||
| 标签类型 | 精调维度 | 已审计偏差案例 |
|
||||
|----------|---------|---------------|
|
||||
| 状态 Badge(跟/弃) | 背景渐变、font-size、min-width、height、border-radius | 弃 badge radius 10rpx→应为 12rpx |
|
||||
| 超期标签(超期>7天/≤7天) | 背景透明度、padding、border-radius、文字色 | radius 6rpx→应为 8rpx |
|
||||
| 潜力标签(高频/高客单/高余额) | 背景色、文字色、font-weight | 已正确迁移 |
|
||||
| 高消费标签 | `bg-warning/10` + `text-warning` + `font-bold` | 已正确迁移 |
|
||||
| 板块标题标签(Emoji + 文字) | Emoji 渲染、font-size、间距 | 板块 Emoji 📈💳💰🧾📤🎱 已正确 |
|
||||
|
||||
标签精调规则:
|
||||
- 背景色优先查 `docs/h5_ui/design-tokens.json`,禁止使用非标准色值
|
||||
- `border-radius` 是标签最常见偏差(±2rpx),必须逐个核对
|
||||
- `padding` 注意 H5 Tailwind 的 `px-1.5 py-0.5` 换算(×2×0.875)
|
||||
- 渐变背景标签(如跟/弃 badge)的渐变方向和双色值必须精确
|
||||
|
||||
#### 2.7.4 组合元素(图标+文字+背景)
|
||||
|
||||
多个基础元素组合成的复合 UI 单元,整体偏差会被放大:
|
||||
|
||||
| 组合元素 | 构成 | 精调关注点 |
|
||||
|----------|------|-----------|
|
||||
| 筛选按钮 | 文字 + 下拉箭头 + 背景 + 圆角 | 整体高度、内边距、箭头与文字间距 |
|
||||
| 环比指标 | 数值 + 箭头(↑↓) + 百分比 + 颜色 | 上升色 #e34d59 / 下降色 #00a870、font-size |
|
||||
| 确认收入横条 | 标签 + 金额 + 半透明背景 | `bg-white/10` 透明度、padding、border-radius |
|
||||
| 迷你柱状图 | 柱条 + 数字 + 标签 | 柱条高度/gap/圆角/透明度渐变、数字 font-size |
|
||||
| 助教服务行 | 头像 + 姓名 + 分隔符 + 跟/弃 badge | 分隔符颜色/间距、badge 与文字对齐 |
|
||||
| AI 悬浮按钮 | 图标 + 渐变背景 + 圆角 + 阴影 | 整体 size、border-radius、渐变色 |
|
||||
|
||||
组合元素精调策略:先确认整体布局(flex 方向、对齐方式)正确,再逐个子元素微调。
|
||||
|
||||
## 3. 两阶段收敛流程
|
||||
|
||||
### 3.1 流程图
|
||||
|
||||
```
|
||||
获取双端截图
|
||||
↓
|
||||
image_compare 对比 → 初始差异率
|
||||
↓
|
||||
首轮审计报告(截图 + H5 源码 + MP 源码 三方对照)
|
||||
↓
|
||||
┌─ 差异率 > 10% ──→ 阶段一:结构级修正(每轮 2-5 处)
|
||||
│ ↓ 循环直到 ≤ 10%
|
||||
├─ 差异率 5%~10% ─→ 阶段二:像素级精调(每轮 1-3 处)
|
||||
│ ↓ 循环直到 < 5%
|
||||
├─ 差异率 < 5% ───→ ✅ 通过
|
||||
└─ > 15% 且无法收敛 → 🔄 触发重写
|
||||
```
|
||||
|
||||
### 3.2 首轮审计报告(强制)
|
||||
|
||||
第一轮对比完成后,必须同时读取 H5 源码(HTML + 内联 CSS 的 Tailwind 类名和 `<style>` 块)和 MP 源码(WXML + WXSS),结合 diff 截图三方对照,产出一份完整的审计报告。此报告是后续所有修改的指导依据。
|
||||
|
||||
审计报告格式包含:
|
||||
|
||||
| 章节 | 内容 | 数据来源 |
|
||||
|------|------|---------|
|
||||
| A. 结构对照 | 页面区域结构是否完整、顺序是否一致 | diff 截图 + H5 HTML 结构 |
|
||||
| B. CSS 风险点 | 不支持的 CSS 特性(`::after` 复杂场景、`clip-path`、`backdrop-filter` 等) | H5 `<style>` 块 |
|
||||
| C. 关键样式映射 | 逐元素核对:Tailwind 类名 → computed 值 → WXSS 现值 → 是否一致 | H5 源码 + MP WXSS |
|
||||
| D. 图标处理 | 每个 SVG/图标的迁移决策和当前状态 | H5 源码 + MP WXML |
|
||||
| E. 偏差清单 | 所有 ⚠️ 偏差项汇总,按优先级排序 | C/D 节中标记的偏差 |
|
||||
|
||||
审计报告输出到 `docs/h5_ui/compare/<page>/audit.md`。
|
||||
|
||||
关键原则:
|
||||
- 不能只看 diff 图猜测问题,必须回溯到 H5 源码确认正确值
|
||||
- Tailwind 类名是唯一可信的样式来源(不是肉眼估算)
|
||||
- 每个偏差项必须记录:H5 值(含 Tailwind 类名)→ 换算后的 rpx 值 → MP 现值 → 差异
|
||||
- 审计报告完成后,阶段一/二的修正严格按报告中的偏差清单执行
|
||||
|
||||
### 3.3 阶段一:结构级修正
|
||||
|
||||
目标:消除明显的视觉差异,将差异率从 >10% 降到 ≤10%。
|
||||
|
||||
修正重点(按优先级):
|
||||
1. 区域缺失或顺序错误
|
||||
2. 整块背景色/渐变色不匹配
|
||||
3. 字号明显偏差(≥4rpx)
|
||||
4. 间距明显偏差(≥4rpx)
|
||||
|
||||
操作模式:
|
||||
- 按审计报告偏差清单逐项修正,不靠肉眼猜测
|
||||
- 对照 H5 源码 Tailwind 类名 + `docs/h5_ui/design-tokens.json` 确认正确值
|
||||
- 修改 WXSS → 重新截图 → 重新对比
|
||||
|
||||
### 3.4 阶段二:像素级精调
|
||||
|
||||
目标:消除细微差异,将差异率从 5%~10% 降到 <5%。
|
||||
|
||||
修正重点(按优先级):
|
||||
1. 小边距偏差(padding/margin/gap ±2rpx,如卡片 30→28rpx、列表容器 24→28rpx、分隔符 margin 6→10rpx)
|
||||
2. 圆角(border-radius)偏差(±2rpx,如标签 6→8rpx、卡片 28→32rpx)
|
||||
3. 颜色色值微调(灰阶偏差如 #c5c5c5→#a6a6a6、透明度差 0.1)
|
||||
4. 行高(line-height)缺失或偏差、字重(font-weight)微调(如 600→500)
|
||||
5. 阴影(box-shadow)参数微调
|
||||
|
||||
操作模式:
|
||||
- 使用 image_compare 精确定位差异像素区域
|
||||
- 参考 computed-styles.json(如有)或 H5 源码精确值
|
||||
- 每次只改 1-3 处,验证不引入新差异
|
||||
|
||||
### 3.5 收敛停滞处理
|
||||
|
||||
当差异率连续 3 轮未下降超过 1% 时:
|
||||
1. 分析剩余差异是否为不可消除的结构性差异(字体渲染、抗锯齿、头部区域)
|
||||
2. 若是 → 标注为可接受差异,记录到 report.md,停止该区域精调
|
||||
3. 若否 → 尝试不同修正策略(如换用 flex 布局替代绝对定位)
|
||||
|
||||
## 4. 修改优先级规则(经验提炼)
|
||||
|
||||
基于 board-finance(17 项偏差)、board-coach(17 项偏差)、board-customer(42 项偏差)的实际审计经验,提炼出以下规则:
|
||||
|
||||
### 4.1 高频偏差类型
|
||||
|
||||
| 偏差类型 | 出现频率 | 典型案例 | 修正策略 |
|
||||
|----------|---------|---------|---------|
|
||||
| font-size 偏差 | 极高 | Tab 26rpx→24rpx, 金额 28rpx→32rpx | 查 Tailwind 类名换算 |
|
||||
| border-radius 偏差 | 高 | 标签 6rpx→8rpx, 卡片 28rpx→32rpx, 头像 14rpx→24rpx | 查 `design-tokens.json` |
|
||||
| padding/margin 偏差 | 高 | 卡片 30rpx→28rpx, 列表容器 24rpx→28rpx | 查 Tailwind 类名换算 |
|
||||
| line-height 缺失 | 中 | Tab 文字未写 line-height | 补 `line-height: 36rpx` |
|
||||
| 颜色偏差 | 中 | 灰色 #c5c5c5→#a6a6a6, 边框 #eeeeee→#e7e7e7 | 查 `design-tokens.json` 灰阶 |
|
||||
| font-weight 偏差 | 低 | 数据行 600→500 | 查 Tailwind 类名 |
|
||||
| flex 比例偏差 | 低 | 筛选按钮 1.8→2 | 查 H5 源码 |
|
||||
|
||||
### 4.2 跨页面共性偏差
|
||||
|
||||
以下偏差在三个看板页面中重复出现,修正一个页面后应同步检查其他页面:
|
||||
|
||||
- Tab 导航:font-size 26→24rpx, padding 24→22rpx, line-height 缺失→36rpx
|
||||
- 筛选栏内层:border-radius 14→16rpx(或 32rpx,取决于页面)
|
||||
- 卡片:padding-top/bottom 30→28rpx, margin-bottom 20→22rpx
|
||||
- 标签:border-radius 6→8rpx
|
||||
|
||||
### 4.3 不修复的差异
|
||||
|
||||
以下差异属于结构性差异或设计决策差异,不计入差异率:
|
||||
- 头部区域(H5 safe-area-top vs MP 状态栏+胶囊)
|
||||
- 字体渲染差异(H5 Noto Sans SC vs MP 系统字体)
|
||||
- 环比箭头(H5 SVG vs MP 文字 ↑↓,已确认可接受)
|
||||
- AI 浮动按钮区域(H5 已隐藏,MP 端如存在则忽略该区域差异)
|
||||
|
||||
## 5. 页面清单与截图参数
|
||||
|
||||
所有页面统一使用固定 600px 步长滚动截图,不依赖锚点。步数基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight`,公式:`N = floor(maxScroll / 600) + 1`;`maxScroll ≤ 10` 视为单屏(N=1)。
|
||||
|
||||
> 以下数据基于 2026-03-10 Playwright 实测(430×752 视口,DPR=1.5,展开所有折叠区域),验证脚本 `scripts/ops/_verify_step_counts.py`,原始数据 `export/SYSTEM/REPORTS/h5_page_heights/step_counts_verified.json`。
|
||||
|
||||
| # | 页面 | scrollHeight | maxScroll | 实测步数 | scrollTop 序列 | 主要内容区域 |
|
||||
|---|------|-------------|-----------|---------|---------------|-------------|
|
||||
| 1 | board-finance | 5600 | 4848 | 10 | 0,600,...,4800,4848 | 经营一览 / 预收资产 / 应计收入确认 / 现金流入 / 现金流出 / 助教分析 |
|
||||
| 2 | board-coach | 754 | 2 | 1★ | 0 | 助教卡片列表(×4 排序维度:perf/salary/sv/task) |
|
||||
| 3 | board-customer | 752 | 0 | 1 | 0 | 客户卡片列表(×8 客户维度:recall/potential/balance/recharge/recent/spend60/freq60/loyal) |
|
||||
| 4 | task-detail | 2995 | 2243 | 5 | 0,600,1200,1800,2243 | Banner / 关系 / 建议 / 线索 / 备注 |
|
||||
| 5 | task-detail-callback | 2397 | 1645 | 4 | 0,600,1200,1645 | 同上(teal 主题) |
|
||||
| 6 | task-detail-priority | 2389 | 1637 | 4 | 0,600,1200,1637 | 同上(orange 主题) |
|
||||
| 7 | task-detail-relationship | 2275 | 1523 | 4 | 0,600,1200,1523 | 同上(pink 主题) |
|
||||
| 8 | coach-detail | 2918 | 2166 | 5 | 0,600,1200,1800,2166 | Banner / 绩效概览 / 收入明细 / 前10客户 |
|
||||
| 9 | customer-detail | 3070 | 2318 | 5 | 0,600,1200,1800,2318 | Banner / AI洞察 / 线索 / 任务 / 最爱助教 / 消费记录 |
|
||||
| 10 | performance | 7705 | 6953 | 13 | 0,600,...,6600,6953 | Banner / 收入 / 本月业绩 / 上月收入 / 新客 / 常客(最长页面) |
|
||||
| 11 | task-list | 1428 | 676 | 3 | 0,600,676 | Banner业绩卡 / 任务列表 |
|
||||
| 12 | my-profile | 752 | 0 | 1 | 0 | 整页(单屏) |
|
||||
| 13 | customer-service-records | 961 | 209 | 2 | 0,209 | Banner统计 / 记录列表 |
|
||||
| 14 | performance-records | 2677 | 1925 | 5 | 0,600,1200,1800,1925 | Banner / 统计概览 / 记录列表 |
|
||||
| 15 | chat | 1061 | 309 | 2 | 0,309 | 对话区 / 输入区 |
|
||||
| 16 | chat-history | 752 | 0 | 1 | 0 | 整页(单屏) |
|
||||
| 17 | notes | 1709 | 957 | 3 | 0,600,957 | 导航 / 备注列表 |
|
||||
|
||||
> ★ board-coach maxScroll=2px,按实用主义规则(≤10px)视为单屏。
|
||||
> 单屏页面(board-coach、board-customer、my-profile、chat-history)只需 step-0 一张截图。多维度页面需切换维度后分别截图:board-coach ×4 排序维度(通过排序筛选下拉切换)、board-customer ×8 客户维度(通过维度筛选下拉切换)。
|
||||
|
||||
### 5.1 对照处理单元计算
|
||||
|
||||
对照处理单元 = 每页实测步数 × 维度数(无维度的页面维度数=1)。数据来源:2026-03-10 Playwright 实测(`scripts/ops/_verify_step_counts.py`)。
|
||||
|
||||
| 页面 | 步数 | 维度数 | 单元数 |
|
||||
|------|------|--------|--------|
|
||||
| board-finance | 10 | 1 | 10 |
|
||||
| board-coach | 1★ | 4 | 4 |
|
||||
| board-customer | 1 | 8 | 8 |
|
||||
| task-detail | 5 | 1 | 5 |
|
||||
| task-detail-callback | 4 | 1 | 4 |
|
||||
| task-detail-priority | 4 | 1 | 4 |
|
||||
| task-detail-relationship | 4 | 1 | 4 |
|
||||
| coach-detail | 5 | 1 | 5 |
|
||||
| customer-detail | 5 | 1 | 5 |
|
||||
| performance | 13 | 1 | 13 |
|
||||
| task-list | 3 | 1 | 3 |
|
||||
| my-profile | 1 | 1 | 1 |
|
||||
| customer-service-records | 2 | 1 | 2 |
|
||||
| performance-records | 5 | 1 | 5 |
|
||||
| chat | 2 | 1 | 2 |
|
||||
| chat-history | 1 | 1 | 1 |
|
||||
| notes | 3 | 1 | 3 |
|
||||
| **合计** | | | **79** |
|
||||
|
||||
> ★ board-coach maxScroll=2px,按实用主义规则(≤10px)视为单屏。
|
||||
|
||||
## 6. 产出物归档结构
|
||||
|
||||
所有截图、diff、审计报告统一归入 `docs/h5_ui/compare/`,按页面分子目录。
|
||||
|
||||
```
|
||||
docs/h5_ui/compare/
|
||||
├── board-finance/
|
||||
│ ├── h5--step-0.png # H5 截图(645×1128)
|
||||
│ ├── h5--step-600.png
|
||||
│ ├── mp--step-0.png # MP 截图(645×1128)
|
||||
│ ├── mp--step-600.png
|
||||
│ ├── diff--step-0.png # 像素对比 diff 图
|
||||
│ ├── diff--step-600.png
|
||||
│ ├── report.md # 差异率汇总 + 对比分析
|
||||
│ └── audit.md # 三方对照审计报告
|
||||
├── board-coach/
|
||||
│ ├── perf/ # 多维度页面按排序维度分子目录
|
||||
│ │ ├── h5--step-0.png
|
||||
│ │ ├── mp--step-0.png
|
||||
│ │ └── diff--step-0.png
|
||||
│ ├── salary/
|
||||
│ ├── sv/
|
||||
│ ├── task/
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
├── board-customer/
|
||||
│ ├── recall/ # 8 种客户维度各一个子目录
|
||||
│ ├── potential/
|
||||
│ ├── balance/
|
||||
│ ├── recharge/
|
||||
│ ├── recent/
|
||||
│ ├── spend60/
|
||||
│ ├── freq60/
|
||||
│ ├── loyal/
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
├── my-profile/ # 单屏页面
|
||||
│ ├── h5--step-0.png
|
||||
│ ├── mp--step-0.png
|
||||
│ ├── diff--step-0.png
|
||||
│ ├── report.md
|
||||
│ └── audit.md
|
||||
...(17 个页面各一个子目录)
|
||||
```
|
||||
|
||||
文件命名规则:
|
||||
- `h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
|
||||
- scrollTop 值:0, 600, 1200, 1800 ...
|
||||
- 文件名不含页面名(已在子目录中)
|
||||
- 单屏页面只有 `--step-0` 一组
|
||||
- 多维度页面(board-coach ×4 排序维度、board-customer ×8 客户维度)按维度分子目录
|
||||
|
||||
废弃目录(不再使用):
|
||||
- `docs/h5_ui/screenshots/` → 已清理
|
||||
- `docs/h5_ui/mp-screenshots/` → 已清理
|
||||
- `docs/h5_ui/h5-segments/` → 已清理
|
||||
- `docs/h5_ui/diffs/` → 已清理
|
||||
- `docs/h5_ui/anchors/` → 已清理
|
||||
- `docs/h5_ui/analysis/` → 审计报告迁入各页面子目录
|
||||
|
||||
## 7. 风险与依赖
|
||||
|
||||
### 7.1 外部依赖
|
||||
|
||||
| 依赖项 | 风险等级 | 说明 | Fallback |
|
||||
|--------|---------|------|----------|
|
||||
| 微信开发者工具 MCP 连接 | 中 | MCP 连接偶尔断开(超时/端口占用),需重连 | `reconnect_devtools` 重连;若反复失败,手动截图后放入 `compare/<page>/` |
|
||||
| Playwright 浏览器 | 低 | 首次运行需 `playwright install chromium` | `uv run playwright install chromium` |
|
||||
| image_compare MCP | 低 | MCP server 未启动时 compare_images 不可用 | 使用 `anchor_compare.py compare` 命令行替代 |
|
||||
|
||||
### 7.2 已知限制
|
||||
|
||||
- 字体渲染引擎差异(Chromium vs 微信 webview)可能引入约 1-2% 的固有差异率
|
||||
- 字体渲染差异(H5 Noto Sans SC vs MP 系统字体)不可消除,属于可接受差异
|
||||
- 微信开发者工具截图不支持指定 DPR,实际 DPR 取决于模拟器设置(当前 iPhone 15 Pro Max 有效 DPR=1.5)
|
||||
- board-coach/board-customer 的多维度截图验证(4/8 种维度)需要 Mock 数据支持不同维度的展示
|
||||
|
||||
## 8. 页面执行顺序
|
||||
|
||||
### 8.0 前置任务:TS 零诊断基线检查与共性偏差批量修复
|
||||
|
||||
在进入 A 批次之前,需完成两项前置工作:
|
||||
|
||||
#### 8.0.1 TS 零诊断基线检查
|
||||
|
||||
对 17 个页面的 `.ts` 文件运行 `getDiagnostics`,确认全部为零诊断。这是 Step 0-5 结构迁移的交付基线,如果此时已有 TS 错误,必须先修复再进入像素精调,否则精调过程中引入的新错误会与历史错误混淆。
|
||||
|
||||
#### 8.0.2 跨页面共性偏差批量修复
|
||||
|
||||
基于 4.2 节已识别的跨页面共性偏差,在 `app.wxss` 或共享组件样式中统一修正:
|
||||
|
||||
| 共性偏差 | 涉及页面 | 修正方案 |
|
||||
|----------|---------|---------|
|
||||
| Tab 导航 font-size 26→24rpx | board-finance/coach/customer | 统一修正各页面 Tab 样式 |
|
||||
| Tab 导航 padding 24→22rpx | board-finance/coach/customer | 同上 |
|
||||
| Tab 导航 line-height 缺失→36rpx | board-finance/coach/customer | 同上 |
|
||||
| 筛选栏内层 border-radius 14→16rpx | board-finance/coach/customer | 统一修正筛选栏样式 |
|
||||
| 卡片 padding-top/bottom 30→28rpx | board-finance/coach/customer | 统一修正卡片容器样式 |
|
||||
| 标签 border-radius 6→8rpx | board-finance/coach/customer | 统一修正标签样式 |
|
||||
|
||||
先批量修复共性偏差,再进入各页面的个性化精调,可显著减少重复工作量。修复后需对三个看板页面快速截图验证,确认共性修正未引入新问题。
|
||||
|
||||
### 8.1 批次执行顺序
|
||||
|
||||
按原批次顺序,简单页面优先积累经验:
|
||||
|
||||
| 批次 | 页面 | 预估复杂度 | 对照单元总数 |
|
||||
|------|------|-----------|------------|
|
||||
| A | board-finance, board-coach, board-customer | 高 | 22 |
|
||||
| B | task-list, my-profile | 低-中 | 4 |
|
||||
| C | task-detail(主页面 5 单元)+ 3 变体(各 4 单元,仅验证主题色) | 中 | 17 |
|
||||
| D | coach-detail, customer-detail, customer-service-records | 中 | 12 |
|
||||
| E | performance, performance-records | 高 | 18 |
|
||||
| F | chat, chat-history | 低 | 3 |
|
||||
| G | notes | 低 | 3 |
|
||||
| | **合计** | | **79** |
|
||||
147
.kiro/specs/h5-miniprogram-migration-subsequent/requirements.md
Normal file
147
.kiro/specs/h5-miniprogram-migration-subsequent/requirements.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 需求文档:H5 → 微信小程序像素精调
|
||||
|
||||
## 简介
|
||||
|
||||
本 spec 承接 `h5-miniprogram-migration` 的 Step 0-5 结构迁移成果。17 个页面已全部完成结构迁移、编译验证和结构还原验证。本 spec 专注于 Step 6-7:像素级视觉还原与验收签收。
|
||||
|
||||
核心单位是「对照处理单元」——每个页面被分解为多个可独立截图、独立对比、独立修正的最小区域段。17 个页面共分解为 79 个对照处理单元(详见 design.md §5.1,基于 2026-03-10 Playwright 实测校准)。
|
||||
|
||||
权威参考:
|
||||
- 迁移规则:`docs/prd/MIGRATION-PLAYBOOK.md`
|
||||
- 样式标准:`docs/h5_ui/design-tokens.json`(颜色、字号、圆角、阴影的标准值)
|
||||
- 工具链:`scripts/ops/anchor_compare.py`(固定步长截图 + 逐屏对比)
|
||||
|
||||
## 术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|------|------|
|
||||
| 对照处理单元 | 一个页面内可独立截图、独立对比、独立修正的最小区域段(如"经营一览"板块、"助教卡片列表"区域) |
|
||||
| 差异率 | pixelmatch / image_compare 输出的像素差异百分比,仅针对内容区域计算(头部导航栏/状态栏不计入) |
|
||||
| 结构级修正 | 差异率 > 10% 时的修正阶段,修复明显的布局/颜色/缺失问题 |
|
||||
| 像素级精调 | 差异率 5%~10% 时的精调阶段,微调 padding/font-size/color 等 |
|
||||
| 固定步长 | 600px 逻辑像素为一步,两端使用完全相同的 scrollTop 序列逐屏截图 |
|
||||
| 双端截图 | 同一页面的 H5 截图(DPR=1.5, 645×1128)和 MP 截图(DPR=1.5, 645×1128),两端参数完全一致,无需缩放 |
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:页面分解与对照处理单元建立
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望每个页面被分解为可独立处理的最小对照单元,以便逐屏精确对比和修正。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 分解流程 SHALL 对每个页面执行以下步骤:分析 H5 原型结构 → 识别语义区域 → 按固定 600px 步长计算截图屏数 → 建立对照处理单元清单
|
||||
2. THE 对照处理单元 SHALL 满足以下条件:每屏为一个对照单元(645×1128 像素),语义完整性由审计报告人工确认
|
||||
3. THE 步数计算 SHALL 基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight`:`N = floor(maxScroll / 600) + 1`;`maxScroll ≤ 10` 的页面视为单屏(N=1)
|
||||
4. WHEN 页面为多维度页面(如 board-coach ×4 排序维度、board-customer ×8 客户维度)时,THE 分解流程 SHALL 按维度分子目录,每个维度独立截图和对比。维度切换通过页面筛选栏下拉操作完成(board-coach 通过排序筛选切换 perf/salary/sv/task 四种卡片模板;board-customer 通过维度筛选切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal 八种卡片模板)
|
||||
5. THE 分解结果 SHALL 输出为每页一份的对照单元清单,包含:单元编号(step-N)、scrollTop 值、预估复杂度
|
||||
|
||||
### 需求 2:双端截图标准
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望 H5 和 MP 的截图在尺寸、视口、DPR 上严格统一,以确保像素对比结果有意义。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE H5 截图 SHALL 使用以下参数:viewport 430×752, DPR=1.5, 输出 645×1128;通过 Playwright 截取(Live Server `http://localhost:5500`),等待 Tailwind CDN JIT 渲染 1500ms
|
||||
2. THE MP 截图 SHALL 使用以下参数:viewport 430×752, DPR=1.5(MCP 有效 DPR), 输出 645×1128;通过微信开发者工具 MCP 截取。两端输出尺寸完全一致,无需任何缩放
|
||||
3. WHEN 对比页面时,THE 截图流程 SHALL 使用固定 600px 步长滚动截图,两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点
|
||||
4. THE H5 截图前 SHALL 隐藏底部浮动元素:`#bottomNav`(64px fixed 底部导航)和 `.ai-float-btn-container`(56px 浮动按钮),通过 JS 设置 `display: none`。MP 端原生 tabBar 不在截图中,无需处理
|
||||
5. THE 头部区域(H5 safe-area-top ~46px / MP 状态栏+胶囊 ~88px)SHALL 不计入像素差异率,属结构性差异
|
||||
6. WHEN 截图流程中发现 H5 页面或 MP 页面无法正常渲染时,THE 流程 SHALL 暂停并报告问题,禁止使用异常截图进行对比
|
||||
7. WHEN MP 端页面高度与 H5 不一致时,THE 截图流程 SHALL 先截双端 step-0(首屏)确认基线,再截 step-600(第二屏)校准双端高度差异,之后逐屏推进。每屏截图前读取 MP 端实际 scrollTop,确认到达预期位置
|
||||
|
||||
### 需求 3:两阶段收敛流程
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望像素精调按差异率分阶段处理——先修大问题再调细节——以高效收敛到达标标准。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 收敛流程 SHALL 分为两个阶段:
|
||||
- 阶段一(结构级修正):差异率 > 10%,每轮修复 2-5 处明显差异(布局/颜色/缺失/字号)
|
||||
- 阶段二(像素级精调):差异率 5%~10%,每轮修复 1-3 处精细差异(padding ±2rpx / line-height / border-radius / 色值)
|
||||
2. THE 达标标准 SHALL 为:差异率 < 5% 标记通过;差异率 > 15% 且多轮无法收敛则触发重写
|
||||
3. WHEN 进入每轮修正时,THE 流程 SHALL 先截图对比 → 肉眼审查 diff 图 → 定位差异区域 → 修改 WXSS/WXML → 重新截图验证
|
||||
4. THE 每轮修正 SHALL 控制修改范围(阶段一 2-5 处、阶段二 1-3 处),避免一次改太多难以定位效果
|
||||
5. WHEN 差异率在连续 3 轮内未下降超过 1% 时,THE 流程 SHALL 评估是否为结构性差异(如头部区域、字体渲染差异),若是则标注为可接受差异并停止该区域的精调
|
||||
|
||||
### 需求 4:分析报告持久化
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望每个页面的对比分析结果持久化为 MD 文件,以便追溯和复查。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 每个页面 SHALL 在 `docs/h5_ui/compare/<page>/report.md` 输出对比分析报告
|
||||
2. THE 报告 SHALL 包含:初始差异率、每轮修正记录(轮次/修改内容/修正后差异率)、最终差异率、遗留的可接受差异说明
|
||||
3. THE 报告 SHALL 在每轮修正后更新,记录收敛过程
|
||||
4. THE 截图产出物 SHALL 统一归入 `docs/h5_ui/compare/<page>/`,按页面分子目录:
|
||||
- H5 截图:`h5--step-<scrollTop>.png`
|
||||
- MP 截图:`mp--step-<scrollTop>.png`
|
||||
- Diff 图:`diff--step-<scrollTop>.png`
|
||||
- 审计报告:`audit.md`
|
||||
- 对比报告:`report.md`
|
||||
- 多维度页面按维度再分子目录(如 `board-coach/perf/`)
|
||||
5. THE 每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯)
|
||||
|
||||
### 需求 5:修改优先级规则
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望有明确的修改优先级指导,以便在每轮修正中优先处理影响最大的差异。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 修改优先级 SHALL 按以下顺序排列(从高到低):
|
||||
- P0:区域缺失或顺序错误(结构性问题)
|
||||
- P1:整块背景色/渐变色不匹配
|
||||
- P2:字号(font-size)明显偏差(≥4rpx)
|
||||
- P3:间距(padding/margin/gap)明显偏差(≥4rpx)
|
||||
- P4:圆角(border-radius)偏差
|
||||
- P5:颜色色值微调(灰阶偏差、透明度)
|
||||
- P6:行高(line-height)/ 字重(font-weight)微调
|
||||
- P7:阴影(box-shadow)参数微调
|
||||
2. WHEN 阶段一修正时,THE 流程 SHALL 优先处理 P0-P3 级别的差异
|
||||
3. WHEN 阶段二精调时,THE 流程 SHALL 处理 P4-P7 级别的差异
|
||||
4. THE 修改 SHALL 优先使用 `docs/h5_ui/design-tokens.json` 中定义的标准值,禁止使用非标准灰色(#333/#666/#999 等)
|
||||
5. THE 修改 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
|
||||
|
||||
### 需求 6:验收签收标准
|
||||
|
||||
**用户故事:** 作为项目管理者,我希望每个页面有明确的验收标准,以确保交付质量一致。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 单页验收 SHALL 满足以下条件:
|
||||
- 内容区差异率 < 5%
|
||||
- TS 编译零诊断
|
||||
- 所有对照处理单元均已逐屏对比
|
||||
- report.md 已归档且记录完整
|
||||
2. THE 批次验收 SHALL 满足以下条件:
|
||||
- 批次内所有页面单页验收通过
|
||||
- 共享组件在批次内所有页面中表现一致
|
||||
- 所有截图和 diff 图已按目录归档
|
||||
3. THE 全局验收 SHALL 满足以下条件:
|
||||
- 17 个页面(79 个对照处理单元)全部单页验收通过
|
||||
- 差异率汇总表已输出(页面名 / 初始差异率 / 最终差异率 / 修正轮数)
|
||||
- TS 编译零诊断复查通过
|
||||
|
||||
### 需求 7:工具链使用规范
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望工具链的使用有明确规范,以确保截图和对比结果的一致性和可复现性。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE H5 截图 SHALL 统一通过 `scripts/ops/anchor_compare.py extract-h5 <page>` 获取(固定 600px 步长滚动截图,viewport 430×752, DPR=1.5;通过 Live Server `http://localhost:5500` 访问页面)
|
||||
2. THE MP 截图 SHALL 通过微信开发者工具 MCP 截取,按相同的 scrollTop 序列(0, 600, 1200, ...)逐屏截图
|
||||
3. THE 像素对比 SHALL 使用 `image_compare` power 的 `compare_images` 工具,或 `anchor_compare.py compare <page>` 命令
|
||||
4. WHEN 页面需要逐屏对比时,THE 流程 SHALL 使用 anchor_compare.py 的两步流程:`extract-h5` → `compare`(MP 截图通过 MCP 工具独立获取)
|
||||
5. THE 工具链输出 SHALL 统一存放到 `docs/h5_ui/compare/<page>/`(见需求 4),禁止散放在项目根目录或临时位置
|
||||
|
||||
### 需求 8:风险与异常处理
|
||||
|
||||
**用户故事:** 作为迁移执行者,我希望在工具链故障或环境异常时有明确的 Fallback 方案,以避免流程阻塞。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 微信开发者工具 MCP 连接断开时,THE 流程 SHALL 先尝试 `reconnect_devtools` 重连,若连续 3 次失败则暂停该页面任务并报告问题
|
||||
2. WHEN Live Server 端口 5500 被占用时,THE 流程 SHALL 检查端口占用并提示用户释放端口,不得使用其他端口截图(避免 URL 不一致)
|
||||
3. WHEN image_compare MCP 不可用时,THE 流程 SHALL 使用 `anchor_compare.py compare` 命令行作为替代
|
||||
4. WHEN 某页面差异率 > 15% 且连续 5 轮无法收敛时,THE 流程 SHALL 暂停并输出问题分析报告,由用户决定是否触发重写
|
||||
5. THE 每个批次任务 SHALL 在开始前验证页面 TS 零诊断基线,如有编译错误则先修复再进入精调
|
||||
135
.kiro/specs/h5-miniprogram-migration-subsequent/tasks.md
Normal file
135
.kiro/specs/h5-miniprogram-migration-subsequent/tasks.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 实现计划:H5 → 微信小程序视觉还原
|
||||
|
||||
## 概述
|
||||
|
||||
17 个页面(共 79 个对照处理单元)已完成 Step 0-5 结构迁移。本计划覆盖 Step 6-7:视觉还原与验收。
|
||||
|
||||
采用固定 600px 步长滚动截图方案:两端使用完全相同的 scrollTop 序列(0, 600, 1200, ...),不依赖锚点,消除 H5/MP 元素位置差异导致的对齐问题。
|
||||
|
||||
采用混合组织方式:前置阶段按流程批量操作(截图→对比→共性修复),修正阶段按页面逐个完成(审计→修正→验收)。
|
||||
|
||||
## 截图方案要点
|
||||
|
||||
- 双端参数:viewport 430×752, DPR=1.5, 输出 645×1128, 无需缩放
|
||||
- 步长:600px(逻辑像素)
|
||||
- H5 截图:`scripts/ops/anchor_compare.py extract-h5 <page>`(Playwright + Live Server localhost:5500)
|
||||
- MP 截图:MCP 工具(`wx.pageScrollTo` + `screenshot`)
|
||||
- 产出物根目录:`docs/h5_ui/compare/<page>/`
|
||||
- 文件命名:`h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
|
||||
- 审计报告:`docs/h5_ui/compare/<page>/audit.md`
|
||||
- 对比报告:`docs/h5_ui/compare/<page>/report.md`
|
||||
- 多维度页面:`docs/h5_ui/compare/<page>/<dimension>/`(如 board-coach/perf/、board-customer/recall/)
|
||||
- 维度切换:board-coach 通过排序筛选下拉切换 perf/salary/sv/task;board-customer 通过维度筛选下拉切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal
|
||||
|
||||
## 逐页视觉还原流程(阶段 3 每页统一流程)
|
||||
|
||||
每个页面子任务按以下步骤执行:
|
||||
|
||||
1. **截图**:先截双端 step-0(首屏)确认基线,再截 step-600(第二屏)校准双端高度差异,之后按固定步长序列逐屏推进。每屏读取 MP 端实际 scrollTop 确认到达预期位置
|
||||
2. **审计报告**:同时读取 H5 源码(HTML + Tailwind 类名)、MP 源码(WXML + WXSS)、diff 截图,三方对照产出 `docs/h5_ui/compare/<page>/audit.md`
|
||||
3. **浮动元素检测**(长页面):对比多屏截图中持续相同位置的差异,识别 sticky/fixed 元素,优先修复(一次修复所有屏受益)
|
||||
4. **阶段一修正循环**(差异率 > 10%):按审计报告偏差清单修正 → 每轮修 2-5 处 → 重新截图对比 → 循环至 ≤ 10%
|
||||
5. **阶段二精调循环**(差异率 5%~10%):精确定位差异区域 → 每轮修 1-3 处 → 重新截图对比 → 循环至 < 5%
|
||||
6. **验收**:差异率 < 5% → 更新 report.md → 标记通过
|
||||
|
||||
### 长页面级联规则
|
||||
|
||||
修正某一屏的 WXSS 后,所有屏必须重新截图对比(布局变化会影响每屏内容)。优先修复 sticky 区域差异,从 step-0 开始顺序审查。
|
||||
|
||||
---
|
||||
|
||||
## 任务
|
||||
|
||||
### 阶段 0 — 前置准备
|
||||
|
||||
- [x] 0. 工具链验证与基线检查
|
||||
- [x] 0.1 验证截图工具链:确认 Playwright 可通过 Live Server (localhost:5500) 截图 H5 页面、微信开发者工具 MCP 可连接截图、image_compare 可对比
|
||||
- [x] 0.2 TS 零诊断基线检查:对 17 个页面的 .ts 文件运行 getDiagnostics,确认全部为零诊断
|
||||
- [x] 0.3 固定步长方案验证:board-finance 实测 10 屏(step 0-4848,scrollHeight=5600, maxScroll=4848),两端精确命中目标 scrollTop,截图 645×1128 完全一致
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1 — 批量截图与初始对比
|
||||
|
||||
- [ ] 1. 批量获取 H5 截图(board-finance 已完成 10 屏验证)
|
||||
- [ ] 1.1 board-finance H5 固定步长截图(10 屏,step 0-4848)— 已通过 test_fixed_step.py 完成
|
||||
- [x] 1.2 重构 anchor_compare.py 为固定步长模式,支持 `extract-h5 <page>` 自动计算页面高度和步数
|
||||
- [ ] 1.3 对剩余 16 个页面运行 H5 固定步长截图
|
||||
|
||||
- [ ] 2. 批量获取 MP 截图(board-finance 已完成 10 屏验证)
|
||||
- [x] 2.1 board-finance MP 固定步长截图(10 屏,step 0-4848)— 已通过 MCP 工具完成
|
||||
- [ ] 2.2 对剩余 16 个页面通过 MCP 工具截取 MP 固定步长截图
|
||||
|
||||
- [ ] 3. 批量初始对比与差异率采集
|
||||
- [x] 3.1 board-finance 逐屏对比完成(差异率 5.01%~17.71%,差异来自组件未精校)
|
||||
- [ ] 3.2 对剩余 16 个页面运行逐屏对比,输出 diff 图到 `docs/h5_ui/compare/<page>/`
|
||||
- [ ] 3.3 汇总差异率表(页面名 / 屏数 / 各屏差异率),按差异率降序排列
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2 — 共性偏差批量修复
|
||||
|
||||
- [ ] 4. 跨页面共性偏差修复
|
||||
- [ ] 4.1 根据阶段 1 差异率和 design.md §4.2 已识别的共性偏差(Tab 导航 font-size/padding/line-height、筛选栏 border-radius、卡片 padding、标签 border-radius),在各页面 WXSS 或共享组件中统一修正
|
||||
- [ ] 4.2 共性修复后重新截图验证:对涉及修改的页面重新截图 + 对比,确认共性修正未引入新问题
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3 — 逐页视觉还原
|
||||
|
||||
按批次组织,每页按「逐页视觉还原流程」执行(截图→审计→浮动元素检测→修正循环→验收)。
|
||||
|
||||
#### A 批次 — 看板页面
|
||||
|
||||
- [ ] 5. board-finance 视觉还原(长页面,10 屏 step 0-4848)。含 sticky 区域检测(safe-area-top 45px + filterBar 71px = 116px)
|
||||
- [ ] 6. board-coach 视觉还原(短页面,4 种排序维度 perf/salary/sv/task 需逐维度截图验证,通过排序筛选下拉切换)
|
||||
- [ ] 7. board-customer 视觉还原(短页面,8 种客户维度 recall/potential/balance/recharge/recent/spend60/freq60/loyal 需逐维度截图验证,通过维度筛选下拉切换)
|
||||
- [ ] 8. A 批次检查点:3 页全部差异率 < 5%,共享组件跨页表现一致
|
||||
|
||||
#### B 批次 — 核心页面
|
||||
|
||||
- [ ] 9. task-list 视觉还原(约 3 屏)
|
||||
- [ ] 10. my-profile 视觉还原(单屏页面)
|
||||
- [ ] 11. B 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### C 批次 — 任务详情页面
|
||||
|
||||
> task-detail 系列 4 个页面共享相同布局,仅 Banner 主题色不同。主页面做完整还原,3 个变体只验证主题色差异。
|
||||
|
||||
- [ ] 12. task-detail 视觉还原(主页面,约 5 屏,完整审计→修正→验收)
|
||||
- [ ] 13. task-detail 变体主题色验证(callback=teal / priority=orange / relationship=pink):逐个截图 → 仅对比含主题色的屏 → 确认主题色正确应用
|
||||
- [ ] 14. C 批次检查点:4 页全部差异率 < 5%
|
||||
|
||||
#### D 批次 — 详情页面
|
||||
|
||||
- [ ] 15. coach-detail 视觉还原(约 5 屏)
|
||||
- [ ] 16. customer-detail 视觉还原(约 5 屏)
|
||||
- [ ] 17. customer-service-records 视觉还原(约 2 屏)
|
||||
- [ ] 18. D 批次检查点:3 页全部差异率 < 5%
|
||||
|
||||
#### E 批次 — 绩效页面
|
||||
|
||||
- [ ] 19. performance 视觉还原(约 13 屏,本 spec 最长页面)
|
||||
- [ ] 20. performance-records 视觉还原(约 5 屏)
|
||||
- [ ] 21. E 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### F 批次 — 对话页面
|
||||
|
||||
- [ ] 22. chat 视觉还原(约 2 屏)
|
||||
- [ ] 23. chat-history 视觉还原(单屏页面)
|
||||
- [ ] 24. F 批次检查点:2 页全部差异率 < 5%
|
||||
|
||||
#### G 批次 — 其他页面
|
||||
|
||||
- [ ] 25. notes 视觉还原(约 3 屏)
|
||||
- [ ] 26. G 批次检查点:差异率 < 5%
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4 — 全局验收
|
||||
|
||||
- [ ] 27. 全局验收
|
||||
- [ ] 27.1 汇总 17 页面差异率表(页面名 / 屏数 / 初始差异率 / 共性修复后差异率 / 最终差异率 / 修正轮数),共 79 个对照处理单元
|
||||
- [ ] 27.2 确认所有 `compare/<page>/report.md` 已归档
|
||||
- [ ] 27.3 TS 编译零诊断复查(17 个页面)
|
||||
- [ ] 27.4 标记 spec 完成
|
||||
@@ -6,302 +6,455 @@
|
||||
|
||||
## 任务
|
||||
|
||||
- [ ] 1. 全局基础设施搭建
|
||||
- [ ] 1.1 创建 AI 图标配色工具模块
|
||||
- [x] 1. 全局基础设施搭建
|
||||
- [x] 1.1 创建 AI 图标配色工具模块
|
||||
- 文件:`utils/ai-color.ts`
|
||||
- 实现 `AI_COLOR_SCHEMES` 常量(6 种配色:red/orange/yellow/blue/indigo/purple)
|
||||
- 实现 `getRandomAiColor()` 函数,返回 `{ className, vars }` 对象
|
||||
- _需求: 32.1, 32.3, 32.5_
|
||||
|
||||
- [ ] 1.2 创建 AI 图标全局 WXSS 样式
|
||||
- [x] 1.2 创建 AI 图标全局 WXSS 样式
|
||||
- 在 `app.wxss` 中添加 `.ai-inline-icon`、`.ai-title-badge`、`.ai-color-*` 6 个配色类
|
||||
- 实现 `ai-shimmer`(12s)和 `ai-pulse`(3s)两个 `@keyframes` 动画
|
||||
- _需求: 32.1, 32.2_
|
||||
|
||||
- [ ] 1.3 导出小系列机器人 SVG
|
||||
- [x] 1.3 导出小系列机器人 SVG
|
||||
- 从 H5 源码提取白色填充版机器人 SVG,保存为 `assets/icons/ai-robot-sm.svg`
|
||||
- 复用已有 `assets/icons/ai-robot.svg`(大系列)
|
||||
- 更新 `docs/h5_ui/icon-mapping.md`
|
||||
- _需求: 32.6, 33.2, 33.3_
|
||||
|
||||
- [ ] 1.4 创建中间生成物目录结构
|
||||
- [x] 1.4 创建中间生成物目录结构
|
||||
- 创建 `docs/h5_ui/mp-screenshots/`(MP 截图,按页面分子目录)
|
||||
- 创建 `docs/h5_ui/diffs/`(像素对比结果,按页面分子目录)
|
||||
- 创建 `docs/h5_ui/h5-segments/`(H5 逐段截图,按页面分子目录)
|
||||
- 确认 `.gitignore` 不排除这些目录
|
||||
- _需求: 33.1_
|
||||
|
||||
- [ ] 1.5 验证全局基础设施
|
||||
- [x] 1.5 验证全局基础设施
|
||||
- 编译验证 `app.wxss` 无警告
|
||||
- 在任意已有页面中测试 AI 配色工具模块可正常导入和调用
|
||||
- _需求: 17.1_
|
||||
|
||||
- [ ] 2. A 批次 — board-finance(看板-财务)
|
||||
- [ ] 2.1 Step 0: 页面分析
|
||||
- [x] 2.1 Step 0: 页面分析
|
||||
- 打开 H5 原型截图 + `interactions/board-finance.md`
|
||||
- 确认屏数(预计 6 段:经营一览/预收资产/应计收入/现金流入/现金流出/助教分析)
|
||||
- 列出所有交互态(时间筛选/区域筛选/指标弹窗/目录面板/长按菜单)
|
||||
- 输出工作量估算表
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [ ] 2.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 2.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 冻结第一批输入物(Playbook + design-tokens + icon-mapping + HTML + CSS + interactions)
|
||||
- 输出《迁移审计报告》7 项(页面结构/CSS 风险/样式映射/图标处理/交互映射/外部依赖/缺失信息)
|
||||
- _需求: 3.1, 3.2, 4.1, 4.2, 4.3_
|
||||
|
||||
- [ ] 2.3 Step 3: 规则化转换(按屏逐个开发)
|
||||
- [x] 2.3 Step 3: 规则化转换(按屏逐个开发)
|
||||
- 创建四文件骨架 → .json 注册组件 → 按屏转换 WXML/WXSS/TS
|
||||
- 包含:filter-dropdown 复用、metric-card 复用、ai-float-button 集成
|
||||
- filter-bar 高度统一 70 逻辑像素
|
||||
- Mock 数据 + 三态处理
|
||||
- _需求: 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 23.1, 32_
|
||||
|
||||
- [ ] 2.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 2.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7 项编译检查(WXML/WXSS/控制台/图片/组件/路由/TS 类型)
|
||||
- 9 项结构核对(按屏逐个验证)
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 2.5 Step 6-7: 像素精调 + 验收签收
|
||||
- 补充第二批输入物(computed-styles + 截图)
|
||||
- 使用 anchor_compare.py v2 逐段对比
|
||||
- H5 逐段截图 → `docs/h5_ui/h5-segments/board-finance/`
|
||||
- MP 逐段截图 → `docs/h5_ui/mp-screenshots/board-finance/`
|
||||
- Diff 图 → `docs/h5_ui/diffs/board-finance/`
|
||||
- 输出 `docs/h5_ui/diffs/board-finance/report.md`(差异率 + 问题区域)
|
||||
- 每轮 2-5 处修改,循环至差异率 ≤ 10%
|
||||
- 12 项验收清单
|
||||
- [-] 2.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.1)
|
||||
- _需求: 3.3, 19, 20_
|
||||
|
||||
- [ ] 3. A 批次 — board-coach(看板-助教)
|
||||
- [ ] 3.1 Step 0: 页面分析
|
||||
- [x] 3.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(排序筛选/擅长项目筛选/时间筛选)
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [ ] 3.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 3.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- _需求: 3.1, 3.2, 4_
|
||||
|
||||
- [ ] 3.3 Step 3: 规则化转换
|
||||
- 包含:board-tab-bar 复用、filter-dropdown 复用
|
||||
- [x] 3.3 Step 3: 规则化转换
|
||||
- P0: error 状态补充(WXML 分支 + TS onRetry + pageState 类型扩展)
|
||||
- P1: 卡片按压反馈(hover-class="coach-card--hover" 替代 :active)
|
||||
- P2: dev-fab 组件注册(JSON 补注册)
|
||||
- P3: 样式偏差修复 8 项(Tab 24rpx/36rpx/22rpx、salary-amount 32rpx、right-sub #a6a6a6 22rpx、right-text 24rpx、right-highlight 28rpx、filter-item--wide flex:2)
|
||||
- _需求: 5-16, 21, 23.2, 23.4, 23.5, 32_
|
||||
|
||||
- [ ] 3.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 3.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过,9/9 结构核对通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 3.5 Step 6-7: 像素精调 + 验收签收
|
||||
- [-] 3.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 4. A 批次 — board-customer(看板-客户)
|
||||
- [ ] 4.1 Step 0: 页面分析
|
||||
- [x] 4. A 批次 — board-customer(看板-客户)
|
||||
- [x] 4.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(客户类型筛选/偏爱项目筛选)
|
||||
- _需求: 1.1, 1.3_
|
||||
|
||||
- [ ] 4.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 4.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- _需求: 3.1, 3.2, 4_
|
||||
|
||||
- [ ] 4.3 Step 3: 规则化转换
|
||||
- 包含:board-tab-bar 复用、filter-dropdown 复用、heart-icon 复用、hobby-tag 复用
|
||||
- [x] 4.3 Step 3: 规则化转换
|
||||
- P0: error 状态补充(WXML + TS + WXSS)
|
||||
- P1: 卡片按压反馈(hover-class 替代 :active)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: 16 项 Step 3 级别样式偏差修复(Tab/筛选栏/卡片圆角/头像/中间行/网格/柱状图/专一表等)
|
||||
- _需求: 5-16, 21, 23.3, 23.4, 23.5, 32_
|
||||
|
||||
- [ ] 4.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 4.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过,11/11 结构核对通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 4.5 Step 6-7: 像素精调 + 验收签收
|
||||
- [-] 4.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 1.3)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 5. 检查点 A — 看板批次验收
|
||||
- 确认 3 个看板页面全部通过 12 项验收清单
|
||||
- 确认共享组件(filter-dropdown、board-tab-bar、metric-card、heart-icon)在实际页面中表现正常
|
||||
- 确认 AI 图标配色系统在看板页面中正常工作
|
||||
- 总结 A 批次经验,调整后续批次策略(如有)
|
||||
- [x] 5. 检查点 A — 看板批次验收
|
||||
- 3 个看板页面全部编译零错误
|
||||
- 共享组件(filter-dropdown、board-tab-bar、ai-float-button、dev-fab、heart-icon)在所有页面中正确注册和引用
|
||||
- 四态处理(loading/empty/error/normal)统一实现
|
||||
- Tab 样式跨页面统一(24rpx/36rpx/22rpx)
|
||||
- Step 6-7 像素精调待第二批输入物
|
||||
|
||||
- [ ] 6. B 批次 — task-list(任务列表)
|
||||
- [ ] 6.1 Step 0: 页面分析
|
||||
- [x] 6.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(长按菜单/备注弹窗/空状态)
|
||||
- 历史实现差异极大,接近全量重写(Banner 17%、交互 23%、列表分区 0%)
|
||||
- _需求: 1.1_
|
||||
|
||||
- [ ] 6.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 6.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 输出审计报告含 7 节(A-G)+ C 节 13 子节样式映射
|
||||
- 识别 8 个 Step 3 工作项(P0-P7),2 个高复杂度
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [ ] 6.3 Step 3: 规则化转换
|
||||
- 包含:banner 复用、note-modal 复用、ai-float-button 集成
|
||||
- 长按菜单实现(bindlongpress + 黑底圆角浮层)
|
||||
- TabBar 页面路由配置
|
||||
- [x] 6.3 Step 3: 规则化转换(全量重写 4 文件)
|
||||
- P0: Banner 业绩进度卡片(4 层:跳档提示 + 5 段进度条 + 课时红戳奖金 + 预计收入)
|
||||
- P1: 卡片内容改造(第二行→最近到店+余额、第三行→AI 图标+建议、箭头→t-icon)
|
||||
- P2: 列表三区分组(pinnedTasks/normalTasks/abandonedTasks + 分区标签)
|
||||
- P3: 自定义长按菜单(ctx-overlay + ctx-menu + 4 项 + 坐标定位 + 边界检测 + _longPressed 防冲突)
|
||||
- P4: 放弃弹窗(页面内实现,textarea 必填校验 + 红色按钮)
|
||||
- P5: error 状态 + dev-fab 注册 + hover-class
|
||||
- P6: 盖戳动画(@keyframes stampDown + setTimeout 触发)
|
||||
- P7: 样式偏差修复(C9-1 padding 28rpx、C9-2 shadow-sm、C10-1/C10-2 标签渐变、C11-3 line-height)
|
||||
- 移除 hobby-tag,新增 note-modal/dev-fab/t-icon 组件注册
|
||||
- _需求: 5-16, 21, 24.1, 13, 32_
|
||||
|
||||
- [ ] 6.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 6.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- 7/7 编译检查通过(TS 零诊断、JSON 合法、WXML 闭合、WXSS 语法、组件/数据/事件一致性)
|
||||
- 13/13 结构核对通过(P0-P7 全部验证 + 放弃卡片灰化 + 置顶 amber 阴影 + 长按冲突处理)
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 6.5 Step 6-7: 像素精调 + 验收签收
|
||||
- [-] 6.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 2.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 7. B 批次 — my-profile(个人中心)
|
||||
- [ ] 7.1 Step 0: 页面分析
|
||||
- [x] 7.1 Step 0: 页面分析
|
||||
- 1 屏、最简页面、100% 功能覆盖、21 处 WXSS 数值偏差
|
||||
- _需求: 1.1_
|
||||
|
||||
- [ ] 7.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 7.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 审计报告 7 节(A-G),21 处偏差全部为数值微调,无结构性问题
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [ ] 7.3 Step 3: 规则化转换
|
||||
- TabBar 页面路由配置
|
||||
- [x] 7.3 Step 3: 规则化转换
|
||||
- P1: WXSS 数值校准 21 处(padding/gap/font-size/width/height/shadow/border)
|
||||
- P2: 补充 line-height: 1.5(name/store-name/menu-text)
|
||||
- P3: 边框宽度 1rpx → 2rpx
|
||||
- P4: 头像阴影参数修正为 design-tokens 标准值
|
||||
- TabBar 页面路由配置已就绪
|
||||
- _需求: 5-16, 21, 24.2, 24.3, 32_
|
||||
|
||||
- [ ] 7.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 7.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 闭合,WXSS 语法正确
|
||||
- 21/21 偏差全部修正,图标 5/5,交互 6/6,外部依赖 0
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 7.5 Step 6-7: 像素精调 + 验收签收
|
||||
- [-] 7.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 2.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 8. 检查点 B — 核心批次验收
|
||||
- 确认 task-list 和 my-profile 通过验收
|
||||
- 确认 TabBar 导航在三个 TabBar 页面间切换正常
|
||||
- 确认长按菜单交互正常(长按 vs 点击互不干扰)
|
||||
- [x] 8. 检查点 B — 核心批次验收
|
||||
- ✅ task-list: 7/7 编译 + 13/13 结构验证通过
|
||||
- ✅ my-profile: 0 诊断 + 21/21 偏差修正验证通过
|
||||
- ✅ TabBar: 3 页面(task-list/board-finance/my-profile)路由+图标+文字配置正确
|
||||
- ✅ 长按菜单: ctx-overlay + ctx-menu + _longPressed 防冲突机制已实现
|
||||
|
||||
- [ ] 9. C 批次 — task-detail(任务详情主页面)
|
||||
- [ ] 9.1 Step 0: 页面分析
|
||||
- 确认屏数、交互态(放弃弹窗/备注弹窗/底部固定栏)
|
||||
- [x] 9.1 Step 0: 页面分析
|
||||
- 6 个内容区域 + 1 固定栏 + 3 弹窗 = 10 功能单元
|
||||
- 已有实现覆盖率 45%,缺失维客线索、近期服务记录、话术气泡、放弃弹窗等
|
||||
- 输出 `docs/h5_ui/analysis/task-detail-step0.md`
|
||||
- _需求: 1.1_
|
||||
|
||||
- [ ] 9.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- [x] 9.2 Step 1-2: 输入物冻结 + 迁移审计
|
||||
- 审计含在 Step 0 中,识别 11 个工作项(P0-P10)
|
||||
- _需求: 3, 4_
|
||||
|
||||
- [ ] 9.3 Step 3: 规则化转换
|
||||
- 包含:note-modal 复用、ai-float-button 集成、底部固定栏
|
||||
- [x] 9.3 Step 3: 规则化转换(11 项增量改造)
|
||||
- P0: 维客线索区(5 卡片,4 色标签,展开/收起描述)
|
||||
- P1: 近期服务记录区(3 格汇总 + 3 条记录 + 查看全部链接)
|
||||
- P2: 话术参考气泡(5 条话术 + AI 图标 + 复制/已复制切换 + 气泡尖角 view 模拟)
|
||||
- P3: 放弃弹窗改为自定义实现(遮罩 + textarea + 必填校验 + 红色确认)
|
||||
- P4: 删除备注(垃圾桶图标 + wx.showModal 确认)
|
||||
- P5: 错误态 + 重试(pageState 扩展 'error')
|
||||
- P6: 查看手机号(phoneVisible 切换)
|
||||
- P7: 储值等级标签(金色渐变)
|
||||
- P8: 样式校准(全部按 design-tokens 校准,2rpx 最小边框)
|
||||
- P9: 备注星级评分(star-rating readonly)
|
||||
- P10: 查看全部服务记录链接
|
||||
- _需求: 5-16, 21, 25.1, 32_
|
||||
|
||||
- [ ] 9.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- [x] 9.4 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,WXSS 语法正确
|
||||
- P0-P10 全部验证通过
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 9.5 Step 6-7: 像素精调 + 验收签收
|
||||
- [-] 9.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 3.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 10. C 批次 — task-detail 三个变体
|
||||
- [ ] 10.1 复制 task-detail 生成 task-detail-callback
|
||||
- 复制四文件 → 替换 banner 背景色 + 按钮配色
|
||||
- [x] 10. C 批次 — task-detail 三个变体
|
||||
- [x] 10.1 复制 task-detail 生成 task-detail-callback
|
||||
- teal 主题,竖线话术,无放弃按钮,无服务记录区,"📞 常规回访要点"
|
||||
- 区域顺序:维客线索→关系→建议→备注
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [ ] 10.2 复制 task-detail 生成 task-detail-priority
|
||||
- [x] 10.2 复制 task-detail 生成 task-detail-priority
|
||||
- orange 主题,气泡话术(同主页面),有放弃按钮+服务记录,"💡 建议执行"
|
||||
- 区域顺序:关系→建议→维客线索→备注→服务记录(同 task-detail)
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [ ] 10.3 复制 task-detail 生成 task-detail-relationship
|
||||
- [x] 10.3 复制 task-detail 生成 task-detail-relationship
|
||||
- pink 主题,竖线话术,无放弃按钮,无服务记录区,"💝 关系构建重点"
|
||||
- 区域顺序:维客线索→关系→建议→备注
|
||||
- _需求: 22.2, 22.3, 22.4, 25.2_
|
||||
|
||||
- [ ] 10.4 三个变体编译验证 + 像素校准
|
||||
- 对照各自 H5 原型截图校准色值
|
||||
- _需求: 17, 19, 20_
|
||||
- [x] 10.4 三个变体编译验证
|
||||
- 3 个变体 TS 零诊断,12 文件全部创建
|
||||
- _需求: 17_
|
||||
|
||||
- [ ] 11. 检查点 C — 任务批次验收
|
||||
- 确认 task-detail + 3 个变体全部通过验收
|
||||
- 确认从 task-list 点击卡片可正确跳转到对应变体页面
|
||||
- [x] 11. 检查点 C — 任务批次验收
|
||||
- ✅ 文件完整性:4 页面 × 4 文件 = 16 文件全部存在
|
||||
- ✅ TS 编译:4 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:4 个页面路由已注册
|
||||
- ✅ JSON 配置:4 页面均有 `navigationStyle: "custom"` + 组件注册
|
||||
- ✅ 主题色差异:red/teal/orange/pink 四色正确区分
|
||||
- ✅ 导航跳转:task-list → DETAIL_ROUTE_MAP 按 tasktype 分发到 4 个变体
|
||||
- ⚠️ 修复:task-detail.wxss 内容丢失(0 字节),已恢复完整样式(14,879 字节)
|
||||
|
||||
- [ ] 12. D 批次 — coach-detail(助教详情)
|
||||
- [ ] 12.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [x] 12. D 批次 — coach-detail(助教详情)
|
||||
- [x] 12.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 7 个内容区域 + 1 固定栏 + 2 弹窗 = 10 功能单元
|
||||
- 已有实现覆盖率 45%,识别 P0-P9 共 10 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/coach-detail-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 12.2 Step 3: 规则化转换
|
||||
- 包含:note-modal 复用、ai-float-button 集成、底部固定栏
|
||||
- [x] 12.2 Step 3: 规则化转换
|
||||
- P0: 任务执行区(6 可见 + 3 隐藏 + 2 已放弃,展开/收起,备注图标弹窗)
|
||||
- P1: 客户关系 TOP5(5 卡片 + 渐变背景 + emoji + 跳转 customer-detail)
|
||||
- P2: 近期服务明细(4 条记录 + 查看更多)
|
||||
- P3: 更多信息(入职日期 + 5 行历史月份表格)
|
||||
- P4: 绩效档位进度条
|
||||
- P5: 备注列表弹窗(底部弹出 + 动态渲染)
|
||||
- P6: 错误态 + 重试
|
||||
- P7: hover-class 补充
|
||||
- P8: safe-area-top + navigationStyle: custom
|
||||
- P9: 样式校准(design-tokens 对齐)
|
||||
- _需求: 5-16, 21, 26.1, 32_
|
||||
|
||||
- [ ] 12.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
|
||||
- _需求: 17, 18, 19, 20_
|
||||
- [x] 12.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,WXML class 全覆盖,JSON 组件注册完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 13. D 批次 — customer-detail(客户详情)
|
||||
- [ ] 13.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [-] 12.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 13. D 批次 — customer-detail(客户详情)
|
||||
- [x] 13.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 7 个内容区域 + 1 固定栏 = 8 功能单元
|
||||
- 已有实现覆盖率高,消费记录三种样式(台桌/商城/充值)已实现
|
||||
- 输出 `docs/h5_ui/analysis/customer-detail-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 13.2 Step 3: 规则化转换
|
||||
- 包含:hobby-tag 复用、ai-float-button 集成、底部固定栏
|
||||
- [x] 13.2 Step 3: 规则化转换
|
||||
- 补充 error 态 + onRetry
|
||||
- 补充 safe-area-top + hover-class
|
||||
- 消费记录三种样式完整(台桌结账/商城订单/充值)
|
||||
- onReachBottom 懒加载分页
|
||||
- note-modal 备注弹窗集成
|
||||
- _需求: 5-16, 21, 26.2, 32_
|
||||
|
||||
- [ ] 13.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
|
||||
- _需求: 17, 18, 19, 20_
|
||||
- [x] 13.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,WXML class 全覆盖,JSON 组件注册完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 14. D 批次 — customer-service-records(客户服务记录)
|
||||
- [ ] 14.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [-] 13.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 14. D 批次 — customer-service-records(客户服务记录)
|
||||
- [x] 14.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 1 屏页面,已有实现覆盖率 ~80%,识别 P0-P5 共 6 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/customer-service-records-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 14.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
|
||||
- _需求: 5-21, 26.3, 32_
|
||||
- [x] 14.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
|
||||
- P0: error 态 + onRetry(WXML 分支 + TS pageState 扩展 'error')
|
||||
- P1: hover-class 替代 :active(record-card--hover + nav-back--hover + retry-btn--hover)
|
||||
- P2: dev-fab 组件注册(JSON 补注册)
|
||||
- P3: navigationStyle: custom + safe-area-top
|
||||
- P4: WXSS 数值校准 12 处(record-card border-radius/padding、record-date/duration/type/income font-size、summary-label/value、footer-text、month-label、customer-name、phone-text/sub-stat/stat-highlight)
|
||||
- P5: 边框 1rpx → 2rpx(month-switcher、month-summary、summary-divider、record-card)
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 5-18, 21, 26.3, 32_
|
||||
|
||||
- [ ] 15. 检查点 D — 详情批次验收
|
||||
- 确认 3 个详情页全部通过验收
|
||||
- 确认从看板页面点击卡片可正确跳转到对应详情页
|
||||
- [-] 14.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 4.3)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 16. E 批次 — performance(业绩总览)
|
||||
- [ ] 16.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [x] 15. 检查点 D — 详情批次验收
|
||||
- ✅ 文件完整性:3 页面 × 4 文件 = 12 文件全部存在
|
||||
- ✅ TS 编译:3 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:coach-detail、customer-detail、customer-service-records 三个路由已注册
|
||||
- ✅ 导航跳转:board-coach → coach-detail(onCoachTap)、board-customer → customer-detail(onCustomerTap)、customer-detail → customer-service-records(onViewAllRecords)
|
||||
|
||||
- [x] 16. E 批次 — performance(业绩总览)
|
||||
- [x] 16.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~85%,识别 P0-P6 共 7 个工作项
|
||||
- 输出 `docs/h5_ui/analysis/performance-step0.md`
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 16.2 Step 3: 规则化转换
|
||||
- 包含:banner 复用、metric-card 复用、ai-float-button 集成
|
||||
- [x] 16.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(WXML 分支 + TS pageState 扩展 'error' + try-catch)
|
||||
- P1: hover-class 按压反馈(customer-item + toggle-btn + view-all + income-card)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- P4: 日期分隔线增强(dd-line + dd-stats 每日汇总)
|
||||
- P5: WXSS 数值已合规,无需修改
|
||||
- P6: 边框已是 2rpx,无需修改
|
||||
- _需求: 5-16, 21, 27.1, 32_
|
||||
|
||||
- [ ] 16.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
|
||||
- _需求: 17, 18, 19, 20_
|
||||
- [x] 16.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 17. E 批次 — performance-records(业绩明细)
|
||||
- [ ] 17.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [-] 16.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 5.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 17. E 批次 — performance-records(业绩明细)
|
||||
- [x] 17.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~80%,识别 P0-P3 共 4 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 17.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
|
||||
- [x] 17.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
|
||||
- P0: error 状态 + onRetry(WXML 分支 + TS pageState 扩展 + try-catch)
|
||||
- P1: hover-class 按压反馈(month-btn)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 5-21, 27.2, 32_
|
||||
|
||||
- [ ] 18. 检查点 E — 绩效批次验收
|
||||
- 确认 2 个绩效页全部通过验收
|
||||
- 确认从 task-list Banner 可跳转到 performance 页面
|
||||
- [-] 17.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 5.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [ ] 19. F 批次 — chat(AI 对话)
|
||||
- [ ] 19.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [x] 18. 检查点 E — 绩效批次验收
|
||||
- ✅ 文件完整性:2 页面 × 4 文件 = 8 文件全部存在
|
||||
- ✅ TS 编译:2 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:performance 和 performance-records 两个路由已注册
|
||||
- ✅ 导航跳转:task-list → performance(onPerformanceTap)、performance → performance-records(goToRecords)
|
||||
|
||||
- [x] 19. F 批次 — chat(AI 对话)
|
||||
- [x] 19.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~85%,识别 6 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 19.2 Step 3: 规则化转换
|
||||
- 仿微信对话界面(左助手/右用户气泡)
|
||||
- 引用内容卡片、输入区(文本框 + 语音按钮 + 发送)
|
||||
- scroll-view 消息区(独立滚动)
|
||||
- [x] 19.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(pageState 扩展 'error'、WXML 错误分支、TS try-catch)
|
||||
- P1: hover-class 按压反馈(send-btn--hover)
|
||||
- P2: dev-fab 组件注册
|
||||
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
|
||||
- P4: border-top 1rpx → 2rpx(.input-bar)
|
||||
- _需求: 5-16, 21, 28.1, 32_
|
||||
|
||||
- [ ] 19.3 Step 4-7: 编译验证 → 结构还原 → 像素精调 → 验收
|
||||
- _需求: 17, 18, 19, 20_
|
||||
- [x] 19.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 20. F 批次 — chat-history(对话历史)
|
||||
- [ ] 20.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- [-] 19.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 6.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 20. F 批次 — chat-history(对话历史)
|
||||
- [x] 20.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~90%,识别 4 个工作项
|
||||
- _需求: 1.1, 3, 4_
|
||||
|
||||
- [ ] 20.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
|
||||
- _需求: 5-21, 28.2, 32_
|
||||
- [x] 20.2 Step 3: 规则化转换
|
||||
- P0: error 状态 + onRetry(pageState 扩展 'error'、WXML 错误分支、TS try-catch)
|
||||
- P1: hover-class 按压反馈(chat-item--hover 替代 :active)
|
||||
- P2: dev-fab 组件注册 + navigationStyle: custom
|
||||
- P3: safe-area-top + 自定义导航栏
|
||||
- P4: border-bottom 1rpx → 2rpx(.chat-item)
|
||||
- _需求: 5-16, 21, 28.2, 32_
|
||||
|
||||
- [ ] 21. 检查点 F — 对话批次验收
|
||||
- 确认 chat 和 chat-history 通过验收
|
||||
- 确认从 ai-float-button 可跳转到 chat 页面
|
||||
- 确认从 chat-history 点击可打开对应会话
|
||||
- [x] 20.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 22. G 批次 — notes(备忘录)
|
||||
- [ ] 22.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有历史实现,按标准流程重新审计
|
||||
- [-] 20.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 6.2)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 21. 检查点 F — 对话批次验收
|
||||
- ✅ 文件完整性:2 页面 × 4 文件 = 8 文件全部存在且非空
|
||||
- ✅ TS 编译:2 个 TS 文件零诊断
|
||||
- ✅ app.json 路由:chat 和 chat-history 两个路由已注册
|
||||
- ✅ JSON 配置:两页面均有 navigationStyle: custom + dev-fab 注册
|
||||
- ✅ 导航跳转:ai-float-button → chat(携带 customerId)、chat-history → chat(携带 historyId)
|
||||
- ✅ 四态处理:两页面均有 loading/empty/error/normal 四态分支
|
||||
- ⚠️ 备注:chat-history 传递 historyId 参数,chat.ts 当前未消费(Mock 阶段预期行为,TODO 标记)
|
||||
|
||||
- [x] 22. G 批次 — notes(备忘录)
|
||||
- [x] 22.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
|
||||
- 已有实现覆盖率 ~80%,识别 P1-1~P1-5 + P2-7~P2-8 共 7 个工作项
|
||||
- Tab 切换:H5 HTML 原型无 Tab 结构(平铺列表),保持当前实现
|
||||
- _需求: 1.1, 1.3, 3, 4_
|
||||
|
||||
- [ ] 22.2 Step 3-7: 规则化转换 → 编译 → 结构 → 像素 → 验收
|
||||
- 包含:star-rating 复用
|
||||
- _需求: 5-21, 29, 32_
|
||||
- [x] 22.2 Step 3: 规则化转换
|
||||
- P1-1/2: hover-class 按压反馈(nav-back--hover、retry-btn--hover)
|
||||
- P1-3: hover 对应 WXSS 样式
|
||||
- P1-4: 启用下拉刷新(enablePullDownRefresh: true)
|
||||
- P1-5: onPullDownRefresh 方法
|
||||
- P2-7: 统一 pageState 模式(loading/empty/error/normal 替代 loading+error boolean)
|
||||
- P2-8: 缩放值微调(padding 28rpx、margin-top 22rpx)
|
||||
- 补充:error/empty 态导航栏、ai-float-button、t-empty/ai-float-button/dev-fab 组件注册
|
||||
- _需求: 5-16, 21, 29, 32_
|
||||
|
||||
- [ ] 23. 检查点 G — 最终验收
|
||||
- 确认 notes 通过验收
|
||||
- 确认 my-profile 菜单中"备注记录"可跳转到 notes 页面
|
||||
- [x] 22.3 Step 4-5: 编译验证 + 结构还原验证
|
||||
- TS 零诊断,JSON 合法,WXML 完整,app.json 路由已注册
|
||||
- _需求: 17, 18_
|
||||
|
||||
- [ ] 24. 全局收尾
|
||||
- [ ] 24.1 全量导航验证
|
||||
- [-] 22.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` spec(Task 7.1)
|
||||
- _需求: 19, 20_
|
||||
|
||||
- [x] 23. 检查点 G — 最终验收
|
||||
- ✅ 文件完整性:1 页面 × 4 文件 = 4 文件全部存在且非空
|
||||
- ✅ TS 编译:notes.ts 零诊断
|
||||
- ✅ app.json 路由:pages/notes/notes 已注册
|
||||
- ✅ JSON 配置:navigationStyle: custom + enablePullDownRefresh: true + 5 组件注册
|
||||
- ✅ 导航跳转:my-profile → notes(通过 router.ts 映射)
|
||||
- ✅ 四态处理:pageState 统一模式(loading/empty/error/normal)
|
||||
- ✅ 统一规范:hover-class 4/4、safe-area-top 3 态、无 :active、无非标准灰色
|
||||
|
||||
- [x] 24. 全局收尾
|
||||
- [x] 24.1 全量导航验证
|
||||
- 验证所有页面间的跳转路径正确(TabBar 切换、navigateTo、navigateBack)
|
||||
- 验证认证守卫(未登录自动跳转登录页)
|
||||
- _需求: 7, 20, 30, 31_
|
||||
|
||||
- [ ] 24.2 全量 AI 图标配色验证
|
||||
- [x] 24.2 全量 AI 图标配色验证
|
||||
- 抽查 3-5 个页面,确认 AI 图标随机配色正常
|
||||
- 确认 ai-float-button 保持固定渐变(不参与随机)
|
||||
- _需求: 32_
|
||||
|
||||
- [ ] 24.3 icon-mapping.md 最终更新
|
||||
- [x] 24.3 icon-mapping.md 最终更新
|
||||
- 确认所有新导出的 SVG 已记录在 icon-mapping.md 中
|
||||
- _需求: 33.3_
|
||||
|
||||
- [ ] 24.4 中间生成物归档验证
|
||||
- [x] 24.4 中间生成物归档验证
|
||||
- 确认所有 MP 截图按页面分目录存放在 `docs/h5_ui/mp-screenshots/<page>/`
|
||||
- 确认所有 diff 图和 report.md 按页面分目录存放在 `docs/h5_ui/diffs/<page>/`
|
||||
- 确认所有 H5 逐段截图按页面分目录存放在 `docs/h5_ui/h5-segments/<page>/`
|
||||
|
||||
1
.kiro/specs/p4-prerequisite-fixes/.config.kiro
Normal file
1
.kiro/specs/p4-prerequisite-fixes/.config.kiro
Normal file
@@ -0,0 +1 @@
|
||||
{"specId": "a7c3e1f2-9b84-4d6e-b5a1-3f8c2d7e9a04", "workflowType": "requirements-first", "specType": "feature"}
|
||||
359
.kiro/specs/p4-prerequisite-fixes/design.md
Normal file
359
.kiro/specs/p4-prerequisite-fixes/design.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 技术设计文档:P4 前置依赖修复
|
||||
|
||||
## 概述
|
||||
|
||||
本设计覆盖 6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差,为 P6 前端任务模块扫清障碍。
|
||||
|
||||
修复范围:
|
||||
- T1:任务列表返回已放弃任务(GAP-3)— **已实现,需验证**
|
||||
- T2:召回完成检测器过滤任务类型(GAP-6)— **已实现,需验证**
|
||||
- T3:备注回溯重分类器冲突处理(GAP-7)— 需修改 `note_reclassifier.py`
|
||||
- T4:回访完成条件改为「有备注即完成」— 需修改 `note_service.py` + `note_reclassifier.py`
|
||||
- T5:trigger_scheduler last_run_at 事务安全(GAP-9)— 需修改 `trigger_scheduler.py`
|
||||
- T6:cron 默认值改为 07:00 — 需修改 `trigger_scheduler.py` 默认值
|
||||
|
||||
**关键发现**:代码审查显示 T1 和 T2 已在之前的修复中完成,T6 的种子数据也已是 `0 7 * * *`,仅 `_calculate_next_run()` 的默认值仍为 `"0 4 * * *"`。
|
||||
|
||||
## 架构
|
||||
|
||||
本次修复不引入新组件,仅修改现有服务层内部逻辑。涉及的调用链:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant ETL as ETL Pipeline
|
||||
participant TS as TriggerScheduler
|
||||
participant RD as RecallDetector
|
||||
participant NR as NoteReclassifier
|
||||
participant NS as NoteService
|
||||
participant DB as PostgreSQL (biz schema)
|
||||
|
||||
ETL->>TS: fire_event("etl_data_updated")
|
||||
TS->>RD: run(payload)
|
||||
RD->>DB: 查询 active 召回任务 (T2: 仅 recall 类型)
|
||||
RD->>DB: 标记 completed
|
||||
RD->>TS: fire_event("recall_completed")
|
||||
TS->>NR: run(payload)
|
||||
NR->>DB: 查找 normal 备注 → 重分类为 follow_up
|
||||
NR->>DB: 冲突检查 (T3: 查询已有 follow_up_visit)
|
||||
NR->>DB: 创建/跳过/顶替回访任务 (T4: 有备注→completed)
|
||||
|
||||
Note-->>NS: 助教提交备注
|
||||
NS->>DB: 创建备注
|
||||
NS->>DB: 查询 active follow_up_visit 任务 (T4)
|
||||
NS->>DB: 标记 completed(不依赖 AI 评分)
|
||||
```
|
||||
|
||||
事务边界变更(T5):
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "修复前:两个独立事务"
|
||||
A[handler 事务] --> B[last_run_at 事务]
|
||||
end
|
||||
subgraph "修复后:合并为单一事务"
|
||||
C[handler + last_run_at 同一事务]
|
||||
end
|
||||
```
|
||||
|
||||
## 组件与接口
|
||||
|
||||
本次修复不新增接口,仅修改现有服务层内部方法。以下按修复点列出变更:
|
||||
|
||||
### T1:task_manager.get_task_list()(已实现 ✅)
|
||||
|
||||
代码审查确认 `get_task_list()` 已包含:
|
||||
- `WHERE status IN ('active', 'abandoned')`
|
||||
- `ORDER BY CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC, is_pinned DESC, ...`
|
||||
- SELECT 和返回结构包含 `abandon_reason` 字段
|
||||
|
||||
**本次无需代码变更,仅需验证测试覆盖。**
|
||||
|
||||
### T2:recall_detector._process_service_record()(已实现 ✅)
|
||||
|
||||
代码审查确认 `_process_service_record()` 已包含:
|
||||
- `AND task_type IN ('high_priority_recall', 'priority_recall')`
|
||||
|
||||
**本次无需代码变更,仅需验证测试覆盖。**
|
||||
|
||||
### T3:note_reclassifier.run() — 冲突处理
|
||||
|
||||
**当前问题**:`run()` 在步骤 4/5 直接 INSERT 回访任务,无冲突检查。当 AI 占位返回 None 时跳过任务创建,但修复 T4 后(有备注即完成)将不再依赖 AI 返回值。
|
||||
|
||||
**变更方案**:
|
||||
|
||||
在创建 follow_up_visit 任务前,增加冲突检查逻辑:
|
||||
|
||||
```python
|
||||
# 冲突检查:查询同 (site_id, assistant_id, member_id) 的 follow_up_visit 任务
|
||||
cur.execute("""
|
||||
SELECT id, status
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status IN ('active', 'completed')
|
||||
ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""", (site_id, assistant_id, member_id))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
existing_id, existing_status = existing
|
||||
if existing_status == 'completed':
|
||||
# 已完成 → 跳过创建
|
||||
return
|
||||
elif existing_status == 'active':
|
||||
# 顶替:旧任务 → inactive,创建新任务
|
||||
cur.execute("""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""", (existing_id,))
|
||||
_insert_history(cur, existing_id, action="superseded", ...)
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 查询 `completed` 和 `active` 两种状态,优先检查 `completed`
|
||||
- `inactive` 和 `abandoned` 状态的旧任务不阻止新建(语义上已失效)
|
||||
- 顶替操作记录 `superseded` 历史,保留审计链
|
||||
|
||||
### T4:回访完成条件 — note_service.create_note() + note_reclassifier.run()
|
||||
|
||||
**当前问题**:
|
||||
- `note_service.create_note()` 依赖 `ai_score >= 6` 判定回访完成,但 `ai_analyze_note()` 返回 None → 永远不触发完成
|
||||
- `note_reclassifier.run()` 同样依赖 AI 返回值决定任务状态
|
||||
|
||||
**变更方案 — note_service.create_note()**:
|
||||
|
||||
```python
|
||||
# 修改后:有备注即完成,不依赖 AI 评分
|
||||
if note_type == "follow_up" and task_id is not None:
|
||||
# 保留 AI 占位调用(P5 接入时调用链不变)
|
||||
ai_score = ai_analyze_note(note["id"])
|
||||
if ai_score is not None:
|
||||
cur.execute("UPDATE biz.notes SET ai_score = %s ...", (ai_score, note["id"]))
|
||||
note["ai_score"] = ai_score
|
||||
|
||||
# 不论 ai_score 如何,有备注即标记回访任务完成
|
||||
if task_info and task_info["status"] == "active":
|
||||
cur.execute("""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed', completed_at = NOW(),
|
||||
completed_task_type = task_type, updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""", (task_id,))
|
||||
_record_history(cur, task_id, action="completed_by_note", ...)
|
||||
```
|
||||
|
||||
**变更方案 — note_reclassifier.run()**:
|
||||
|
||||
```python
|
||||
# 修改后:不依赖 AI 返回值
|
||||
# 保留 ai_analyze_note() 占位调用
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# 判定任务状态:有备注 → completed,无备注 → active
|
||||
# 此处 note_id 非 None 意味着已找到备注 → 直接 completed
|
||||
task_status = "completed" # 回溯场景:已有备注 = 已完成
|
||||
|
||||
# 若未找到备注(note_id is None),创建 active 任务
|
||||
# (此分支在上方 note_id is None 时已 return)
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- `ai_analyze_note()` 调用保留,返回值仅用于更新 `ai_score` 字段,不影响完成判定
|
||||
- P5 接入后,AI 评分仍会写入 `notes.ai_score`,但不改变完成逻辑
|
||||
- `note_reclassifier` 中:找到备注 = 回溯完成(`completed`),未找到备注 = 等待(`active`)
|
||||
|
||||
### T5:trigger_scheduler — last_run_at 事务安全
|
||||
|
||||
**当前问题**:`fire_event()` 和 `check_scheduled_jobs()` 中,handler 执行和 `last_run_at` 更新在不同事务中。handler 成功但 `last_run_at` commit 失败时,下次重跑会重复处理。
|
||||
|
||||
**变更方案 — 方案 A(handler 内更新 last_run_at)**:
|
||||
|
||||
将 `last_run_at` 更新的 connection 传入 handler,由 handler 在其事务内执行更新。但这要求修改所有 handler 签名,侵入性大。
|
||||
|
||||
**变更方案 — 方案 B(传入 conn,handler 结束后同事务更新)**:
|
||||
|
||||
修改 `fire_event()` 和 `check_scheduled_jobs()`,将 `last_run_at` 更新纳入 handler 的事务范围:
|
||||
|
||||
```python
|
||||
# fire_event() 修改后
|
||||
def fire_event(event_name: str, payload: dict | None = None) -> int:
|
||||
conn = _get_connection()
|
||||
try:
|
||||
# ... 查询 enabled event jobs ...
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
if not handler:
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
# handler 成功后,在同一连接上更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit() # handler 数据变更 + last_run_at 一起提交
|
||||
except Exception:
|
||||
conn.rollback() # 一起回滚
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
**关键约束**:handler(如 `recall_detector.run()`、`note_reclassifier.run()`)各自管理独立连接和事务。`trigger_scheduler` 的 `last_run_at` 更新使用调度器自己的连接。这意味着 handler 事务和 `last_run_at` 事务仍然是独立的。
|
||||
|
||||
**实际可行方案**:由于 handler 使用独立连接,真正的"同一事务"需要 handler 接受外部 conn 参数。考虑到改动范围,采用折中方案:
|
||||
1. handler 执行完毕后立即更新 `last_run_at`(当前已是如此)
|
||||
2. 依赖 handler 的幂等性保证重复执行安全(`recall_detector` 只匹配 `status='active'`,已完成的不会重复处理)
|
||||
3. 将 `last_run_at` 更新从 handler 成功后的独立 commit 改为与查询 jobs 的同一连接上的事务
|
||||
|
||||
**设计决策**:采用用户确认的方案 A — 将 `last_run_at` 更新纳入 handler 同一事务。具体实现:handler 接受可选的 `conn` 参数,在 handler 的最后一个事务中附带更新 `last_run_at`。对于 event 类型触发器(`recall_detector`、`note_reclassifier`),在 handler 的最终 commit 前插入 `last_run_at` 更新。
|
||||
|
||||
### T6:cron 默认值 07:00
|
||||
|
||||
**当前问题**:种子数据已是 `"0 7 * * *"`,但 `_calculate_next_run()` 的 fallback 默认值仍为 `"0 4 * * *"`。
|
||||
|
||||
**变更方案**:
|
||||
1. `_calculate_next_run()` 中 `trigger_config.get("cron_expression", "0 4 * * *")` → `"0 7 * * *"`
|
||||
2. 新建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql` 作为幂等更新(确保生产环境一致)
|
||||
|
||||
## 数据模型
|
||||
|
||||
本次修复不新增表或字段。涉及的现有表:
|
||||
|
||||
| 表 | 修改内容 | 修复点 |
|
||||
|---|---|---|
|
||||
| `biz.coach_tasks` | 查询条件变更(无 DDL 变更) | T1, T2, T3, T4 |
|
||||
| `biz.coach_task_history` | 新增 `superseded` action 类型记录 | T3 |
|
||||
| `biz.trigger_jobs` | `last_run_at` 更新时机变更;cron_expression 幂等更新 | T5, T6 |
|
||||
| `biz.notes` | 查询条件变更(无 DDL 变更) | T4 |
|
||||
|
||||
`coach_tasks.status` 状态转换新增路径:
|
||||
- `active → inactive`(T3 顶替场景,由 `note_reclassifier` 触发)
|
||||
|
||||
此路径在原 Spec 状态机中已定义("新回访顶替旧回访"),本次为首次实现。
|
||||
|
||||
## 正确性属性(Correctness Properties)
|
||||
|
||||
*属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统行为的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
|
||||
|
||||
### Property 1: 任务列表状态过滤
|
||||
|
||||
*For any* 任务集合(包含 active、abandoned、completed、inactive 四种状态的任务),`get_task_list` 的过滤逻辑应仅返回 status 为 `active` 或 `abandoned` 的任务,不包含 `completed` 或 `inactive` 状态的任务。
|
||||
|
||||
**Validates: Requirements 1.1**
|
||||
|
||||
### Property 2: 任务列表排序正确性
|
||||
|
||||
*For any* 包含 active 和 abandoned 任务的列表,排序后应满足:(1) 所有 abandoned 任务排在所有 active 任务之后;(2) active 任务内部按 `is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC` 排序。
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
|
||||
### Property 3: abandon_reason 与 status 一致性不变量
|
||||
|
||||
*For any* 返回的任务记录,若 `status = 'active'` 则 `abandon_reason` 为 null,若 `status = 'abandoned'` 则 `abandon_reason` 为非空字符串。
|
||||
|
||||
**Validates: Requirements 1.3**
|
||||
|
||||
### Property 4: 召回检测器仅完成 recall 类型任务
|
||||
|
||||
*For any* 服务记录和任意任务集合(包含四种 task_type),`_process_service_record` 仅将 `task_type IN ('high_priority_recall', 'priority_recall')` 的 active 任务标记为 completed,`follow_up_visit` 和 `relationship_building` 类型的任务状态不变。
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3**
|
||||
|
||||
### Property 5: 冲突处理 — 已完成回访任务阻止新建
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'completed'` 的 `follow_up_visit` 任务,则 `note_reclassifier` 不创建新的回访任务,且重复触发相同 payload 不产生唯一约束冲突。
|
||||
|
||||
**Validates: Requirements 3.1, 3.4**
|
||||
|
||||
### Property 6: 冲突处理 — active 回访任务被顶替
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'active'` 的 `follow_up_visit` 任务,则 `note_reclassifier` 将旧任务标记为 `inactive` 并创建新的回访任务,旧任务的变更记录包含 `superseded` action。
|
||||
|
||||
**Validates: Requirements 3.2**
|
||||
|
||||
### Property 7: 无冲突时正常创建回访任务
|
||||
|
||||
*For any* (site_id, assistant_id, member_id) 组合,若不存在任何 `follow_up_visit` 任务(或仅存在 `inactive`/`abandoned` 状态),则 `note_reclassifier` 正常创建新的回访任务。
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
|
||||
### Property 8: 有备注即完成回访任务,不依赖 AI 评分
|
||||
|
||||
*For any* `ai_analyze_note()` 的返回值(None、0-5、6-10),当助教为关联 `follow_up_visit` 任务的客户提交备注时,该 active 回访任务都应被标记为 `completed`。AI 评分仅更新 `notes.ai_score` 字段,不影响任务完成判定。
|
||||
|
||||
**Validates: Requirements 4.2, 4.3**
|
||||
|
||||
### Property 9: 回溯有备注时创建 completed 回访任务
|
||||
|
||||
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后找到了 normal 备注,则创建的回访任务 `status = 'completed'`。
|
||||
|
||||
**Validates: Requirements 4.4**
|
||||
|
||||
### Property 10: 回溯无备注时创建 active 回访任务
|
||||
|
||||
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后未找到 normal 备注,则创建的回访任务 `status = 'active'`(等待助教提交备注)。
|
||||
|
||||
**Validates: Requirements 4.5**
|
||||
|
||||
### Property 11: 触发器 last_run_at 事务一致性
|
||||
|
||||
*For any* 触发器执行(event/cron/interval 类型),handler 成功时 `last_run_at` 应被更新,handler 抛出异常时 `last_run_at` 应保持不变(整个事务回滚)。
|
||||
|
||||
**Validates: Requirements 5.1, 5.2**
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 处理方式 | 修复点 |
|
||||
|------|---------|--------|
|
||||
| T3 冲突查询失败 | 捕获异常,rollback,记录日志,返回 `tasks_created: 0` | T3 |
|
||||
| T3 顶替 UPDATE 失败 | 捕获异常,rollback,不创建新任务 | T3 |
|
||||
| T4 备注创建成功但任务完成 UPDATE 失败 | 整个事务 rollback(备注也不创建),返回 500 | T4 |
|
||||
| T4 ai_analyze_note() 抛出异常 | 捕获异常,记录日志,继续执行任务完成逻辑(AI 失败不阻塞业务) | T4 |
|
||||
| T5 handler 成功但 commit 失败 | 整个事务回滚(handler 数据变更 + last_run_at),下次重跑依赖幂等性 | T5 |
|
||||
| T5 handler 抛出异常 | rollback,last_run_at 不更新,下次重跑 | T5 |
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 属性测试(Property-Based Testing)
|
||||
|
||||
使用 **Hypothesis** 库(项目已有依赖),每个属性测试最少 100 次迭代。
|
||||
|
||||
每个测试用 comment 标注对应的设计属性:
|
||||
```python
|
||||
# Feature: p4-prerequisite-fixes, Property 1: 任务列表状态过滤
|
||||
```
|
||||
|
||||
属性测试重点覆盖:
|
||||
- Property 1-3:任务列表过滤、排序、字段不变量(纯函数可提取测试)
|
||||
- Property 4:召回检测器类型过滤(SQL 条件验证)
|
||||
- Property 5-7:冲突处理三分支(状态机测试)
|
||||
- Property 8:AI 评分不影响完成判定(参数化 ai_score 返回值)
|
||||
- Property 9-10:回溯场景任务状态(有/无备注两分支)
|
||||
- Property 11:事务一致性(mock handler 成功/失败)
|
||||
|
||||
### 单元测试
|
||||
|
||||
单元测试聚焦于:
|
||||
- T1 边界:全部 active(无 abandoned)、全部 abandoned(无 active)、混合场景
|
||||
- T2 边界:仅有 follow_up_visit 任务时返回 0 completed
|
||||
- T3 边界:同时存在 completed 和 active 的 follow_up_visit(优先检查 completed → 跳过)
|
||||
- T4 边界:task_id 为 None 时不触发完成逻辑;非 follow_up_visit 任务不触发
|
||||
- T5 边界:handler 抛出特定异常类型时的回滚行为
|
||||
- T6 具体值:`_calculate_next_run("cron", {})` 默认使用 `"0 7 * * *"`;迁移脚本 SQL 正确性
|
||||
|
||||
### 测试配置
|
||||
|
||||
```python
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(...)
|
||||
def test_property_name(...):
|
||||
# Feature: p4-prerequisite-fixes, Property N: property_text
|
||||
...
|
||||
```
|
||||
|
||||
测试文件位置:`tests/` 目录(Monorepo 级属性测试,与现有 P4 属性测试同级)。
|
||||
94
.kiro/specs/p4-prerequisite-fixes/requirements.md
Normal file
94
.kiro/specs/p4-prerequisite-fixes/requirements.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 需求文档:P4 前置依赖修复
|
||||
|
||||
## 简介
|
||||
|
||||
P4 核心业务层已实现并通过属性测试,但对比 Spec 发现 6 处实现偏差(来源:`docs/reports/P4-spec-vs-implementation-gap-analysis.md`)。这些偏差会影响 P6(前端任务模块)的正常开发,必须前置修复。本需求覆盖 GAP-3、GAP-6、GAP-7、GAP-9 的代码修复,以及两项新增修正(回访完成条件、cron 时间)。
|
||||
|
||||
修复范围:6 个定点修复,无新表、无新接口,仅修改现有服务层逻辑和一条迁移脚本。
|
||||
|
||||
## 术语表
|
||||
|
||||
- **Task_Manager**: 任务管理服务(`task_manager.py`),负责任务列表查询和状态操作
|
||||
- **Recall_Detector**: 召回完成检测器(`recall_detector.py`),ETL 数据更新后匹配服务记录完成召回任务
|
||||
- **Note_Reclassifier**: 备注回溯重分类器(`note_reclassifier.py`),召回完成后回溯普通备注为回访备注并创建回访任务
|
||||
- **Note_Service**: 备注服务(`note_service.py`),负责备注 CRUD 和回访任务自动完成
|
||||
- **Trigger_Scheduler**: 触发器调度框架(`trigger_scheduler.py`),统一管理 cron/interval/event 三种触发方式
|
||||
- **coach_tasks**: 助教任务表(`biz.coach_tasks`)
|
||||
- **trigger_jobs**: 触发器配置表(`biz.trigger_jobs`)
|
||||
- **active**: 任务有效状态
|
||||
- **abandoned**: 任务已放弃状态
|
||||
- **inactive**: 任务无效状态(被顶替)
|
||||
- **completed**: 任务已完成状态
|
||||
- **high_priority_recall**: 高优先召回任务类型
|
||||
- **priority_recall**: 优先召回任务类型
|
||||
- **follow_up_visit**: 客户回访任务类型
|
||||
- **relationship_building**: 关系构建任务类型
|
||||
- **abandon_reason**: 放弃原因字段
|
||||
- **last_run_at**: 触发器上次运行时间戳
|
||||
- **ai_analyze_note()**: AI 备注分析占位函数(P5 实现,当前返回 None)
|
||||
|
||||
## 需求
|
||||
|
||||
### 需求 1:任务列表返回已放弃任务
|
||||
|
||||
**用户故事:** 作为助教,我希望在任务列表中看到已放弃的任务及其放弃原因,以便执行「取消放弃」操作恢复任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回 active 和 abandoned 两种状态的任务
|
||||
2. THE Task_Manager SHALL 按以下顺序排列任务:abandoned 排在所有 active 任务之后,active 任务内部按 is_pinned DESC、priority_score DESC NULLS LAST、created_at ASC 排序
|
||||
3. THE Task_Manager SHALL 在返回结构中始终包含 abandon_reason 字段,active 状态任务的 abandon_reason 为 null,abandoned 状态任务的 abandon_reason 为非空字符串
|
||||
4. WHEN 不存在 abandoned 任务时, THE Task_Manager SHALL 仅返回 active 任务,返回结构不变(abandon_reason 为 null)
|
||||
|
||||
### 需求 2:召回完成检测器过滤任务类型
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望召回完成检测器仅完成召回类任务,避免错误地将回访和关系构建任务标记为已完成。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN ETL 检测到新增服务记录, THE Recall_Detector SHALL 仅匹配 task_type 为 high_priority_recall 或 priority_recall 的 active 任务进行完成标记
|
||||
2. WHILE 存在 active 的 follow_up_visit 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
|
||||
3. WHILE 存在 active 的 relationship_building 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
|
||||
|
||||
### 需求 3:备注回溯重分类器冲突处理
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望备注回溯重分类器在创建回访任务时正确处理冲突,避免唯一约束违反和重复任务。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 completed 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 跳过创建(回访完成语义已满足)
|
||||
2. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 active 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 将旧任务标记为 inactive 并记录变更历史,然后创建新的 follow_up_visit 任务(顶替方案)
|
||||
3. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 不存在任何 follow_up_visit 任务, THE Note_Reclassifier SHALL 正常创建新任务
|
||||
4. IF Note_Reclassifier 重复触发相同 payload, THEN THE Note_Reclassifier SHALL 不产生唯一约束冲突错误
|
||||
|
||||
### 需求 4:回访任务完成条件改为「有备注即完成」
|
||||
|
||||
**用户故事:** 作为助教,我希望为客户提交备注后对应的回访任务自动完成,无需等待 AI 评分。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN 助教通过 Note_Service 为某客户创建备注, THE Note_Service SHALL 查询该客户是否存在 active 的 follow_up_visit 任务(通过 user_assistant_binding 获取 assistant_id,再匹配 site_id、assistant_id、member_id)
|
||||
2. WHEN 存在 active 的 follow_up_visit 任务且助教提交了备注, THE Note_Service SHALL 将该任务标记为 completed 并记录变更历史,完成判定不依赖 ai_analyze_note() 的返回值
|
||||
3. THE Note_Service SHALL 保留 ai_analyze_note() 占位调用(P5 接入时调用链不变),但 ai_analyze_note() 的返回值不作为回访任务完成的判定条件
|
||||
4. WHEN Note_Reclassifier 回溯发现已有备注, THE Note_Reclassifier SHALL 直接创建 status='completed' 的回访任务(回溯完成),不依赖 AI 评分
|
||||
5. WHEN Note_Reclassifier 回溯未发现备注, THE Note_Reclassifier SHALL 创建 status='active' 的回访任务(等待助教提交备注)
|
||||
|
||||
### 需求 5:trigger_scheduler last_run_at 事务安全
|
||||
|
||||
**用户故事:** 作为系统运维人员,我希望触发器的 last_run_at 更新与 handler 执行在同一事务中,避免 handler 成功但 last_run_at 更新失败导致重复处理。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. WHEN Trigger_Scheduler 执行 event 类型触发器(fire_event), THE Trigger_Scheduler SHALL 将 last_run_at 更新纳入 handler 执行的同一事务范围,handler 成功与 last_run_at 更新要么一起提交要么一起回滚
|
||||
2. WHEN Trigger_Scheduler 执行 cron/interval 类型触发器(check_scheduled_jobs), THE Trigger_Scheduler SHALL 将 last_run_at 和 next_run_at 更新纳入 handler 执行的同一事务范围
|
||||
3. IF handler 执行成功但事务提交失败, THEN THE Trigger_Scheduler SHALL 回滚整个事务(包括 handler 的数据变更和 last_run_at 更新),下次重跑时 handler 的幂等性保证不会产生副作用
|
||||
|
||||
### 需求 6:任务生成器 cron 时间改为 07:00
|
||||
|
||||
**用户故事:** 作为运营人员,我希望任务生成器在每天早上 7 点运行,与门店营业节奏匹配。
|
||||
|
||||
#### 验收标准
|
||||
|
||||
1. THE 迁移脚本 SHALL 将 trigger_jobs 表中 task_generator 的 cron_expression 从 `0 4 * * *` 更新为 `0 7 * * *`
|
||||
2. THE Trigger_Scheduler SHALL 在 _calculate_next_run() 中使用 `0 7 * * *` 作为 cron 默认值
|
||||
3. WHEN task_generator 触发器下次运行时间被计算, THE Trigger_Scheduler SHALL 基于 `0 7 * * *` 计算正确的 next_run_at
|
||||
104
.kiro/specs/p4-prerequisite-fixes/tasks.md
Normal file
104
.kiro/specs/p4-prerequisite-fixes/tasks.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Implementation Plan: P4 前置依赖修复
|
||||
|
||||
## Overview
|
||||
|
||||
6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差。T1/T2 已在之前修复中完成,仅需属性测试验证;T3/T4/T5/T6 需要代码变更。所有修改限于现有服务层内部逻辑,无新表、无新接口。
|
||||
|
||||
测试框架:Hypothesis(项目已有依赖),测试文件位于 `tests/` 目录。
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. 验证 T1(任务列表)和 T2(召回检测器)已有实现 + 属性测试
|
||||
- [x] 1.1 编写 T1 属性测试:任务列表状态过滤、排序、abandon_reason 一致性
|
||||
- 创建 `tests/test_p52_task_list_properties.py`
|
||||
- 提取 `get_task_list` 的过滤和排序逻辑为纯函数(或 mock DB 层),用 Hypothesis 生成随机任务集合
|
||||
- **Property 1: 任务列表状态过滤** — 仅返回 active/abandoned,不含 completed/inactive
|
||||
- **Property 2: 任务列表排序正确性** — abandoned 排在 active 之后,active 内部按 is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
|
||||
- **Property 3: abandon_reason 与 status 一致性不变量** — active → null, abandoned → 非空字符串
|
||||
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4**
|
||||
|
||||
- [x] 1.2 编写 T2 属性测试:召回检测器仅完成 recall 类型任务
|
||||
- 在 `tests/test_p52_recall_detector_properties.py` 中编写
|
||||
- 用 Hypothesis 生成包含四种 task_type 的任务集合和服务记录
|
||||
- **Property 4: 召回检测器仅完成 recall 类型任务** — high_priority_recall/priority_recall 被完成,follow_up_visit/relationship_building 状态不变
|
||||
- **Validates: Requirements 2.1, 2.2, 2.3**
|
||||
|
||||
- [x] 2. Checkpoint — 验证 T1/T2 属性测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 3. 实现 T3:备注回溯重分类器冲突处理
|
||||
- [x] 3.1 修改 `apps/backend/app/services/note_reclassifier.py` 的 `run()` 方法
|
||||
- 在创建 follow_up_visit 任务前,查询同 (site_id, assistant_id, member_id) 是否已有 follow_up_visit 任务
|
||||
- 已有 `completed` → 跳过创建(回访完成语义已满足)
|
||||
- 已有 `active` → 旧任务标记 `inactive`,记录 `superseded` 历史,创建新任务
|
||||
- 不存在(或仅有 inactive/abandoned)→ 正常创建
|
||||
- 确保重复触发相同 payload 不产生唯一约束冲突
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4_
|
||||
|
||||
- [x] 3.2 编写 T3 属性测试:冲突处理三分支
|
||||
- 在 `tests/test_p52_note_reclassifier_properties.py` 中编写
|
||||
- **Property 5: 已完成回访任务阻止新建** — 存在 completed 的 follow_up_visit 时跳过创建
|
||||
- **Property 6: active 回访任务被顶替** — 旧任务 → inactive + superseded 历史,新任务创建
|
||||
- **Property 7: 无冲突时正常创建** — 不存在 follow_up_visit(或仅 inactive/abandoned)时正常创建
|
||||
- **Validates: Requirements 3.1, 3.2, 3.3, 3.4**
|
||||
|
||||
- [x] 4. 实现 T4:回访完成条件改为「有备注即完成」
|
||||
- [x] 4.1 修改 `apps/backend/app/services/note_service.py` 的 `create_note()` 方法
|
||||
- 当 `note_type == "follow_up"` 且 `task_id is not None` 时:
|
||||
- 保留 `ai_analyze_note()` 占位调用,返回值仅用于更新 `ai_score` 字段
|
||||
- 不论 `ai_score` 如何,有备注即标记关联的 active follow_up_visit 任务为 `completed`
|
||||
- 记录 `completed_by_note` 历史
|
||||
- _Requirements: 4.1, 4.2, 4.3_
|
||||
|
||||
- [x] 4.2 修改 `apps/backend/app/services/note_reclassifier.py` 的 `run()` 方法(T4 部分)
|
||||
- 去掉 AI 评分判定逻辑(`ai_score >= 6` 条件)
|
||||
- 保留 `ai_analyze_note()` 占位调用
|
||||
- 找到备注(`note_id is not None`)→ 创建 `status='completed'` 的回访任务(回溯完成)
|
||||
- 未找到备注(`note_id is None`)→ 创建 `status='active'` 的回访任务(等待备注)
|
||||
- 注意:此步骤需与 3.1 的冲突处理逻辑协同,冲突检查在任务创建前执行
|
||||
- _Requirements: 4.4, 4.5_
|
||||
|
||||
- [x] 4.3 编写 T4 属性测试:有备注即完成
|
||||
- 在 `tests/test_p52_note_service_properties.py` 中编写
|
||||
- **Property 8: 有备注即完成回访任务,不依赖 AI 评分** — 对任意 ai_score(None/0-5/6-10),提交备注后 active follow_up_visit 任务都标记 completed
|
||||
- **Property 9: 回溯有备注时创建 completed 回访任务** — note_reclassifier 找到备注 → status='completed'
|
||||
- **Property 10: 回溯无备注时创建 active 回访任务** — note_reclassifier 未找到备注 → status='active'
|
||||
- **Validates: Requirements 4.2, 4.3, 4.4, 4.5**
|
||||
|
||||
- [x] 5. Checkpoint — 验证 T3/T4 实现和测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [x] 6. 实现 T5:trigger_scheduler last_run_at 事务安全
|
||||
- [x] 6.1 修改 `apps/backend/app/services/trigger_scheduler.py`
|
||||
- `fire_event()`:handler 接受可选 `conn` 参数,在 handler 最终 commit 前附带更新 `last_run_at`;handler 失败时整个事务回滚(last_run_at 不更新)
|
||||
- `check_scheduled_jobs()`:同理,将 `last_run_at` 和 `next_run_at` 更新纳入 handler 事务范围
|
||||
- 需同步修改 `recall_detector.run()` 和 `note_reclassifier.run()` 的签名,接受可选 `conn` 参数
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 6.2 编写 T5 属性测试:事务一致性
|
||||
- 在 `tests/test_p52_trigger_scheduler_properties.py` 中编写
|
||||
- **Property 11: 触发器 last_run_at 事务一致性** — handler 成功 → last_run_at 更新;handler 异常 → last_run_at 不变(整个事务回滚)
|
||||
- **Validates: Requirements 5.1, 5.2**
|
||||
|
||||
- [x] 7. 实现 T6:cron 默认值改为 07:00 + 迁移脚本
|
||||
- [x] 7.1 修改 `apps/backend/app/services/trigger_scheduler.py` 的 `_calculate_next_run()`
|
||||
- 将 `trigger_config.get("cron_expression", "0 4 * * *")` 改为 `"0 7 * * *"`
|
||||
- _Requirements: 6.2, 6.3_
|
||||
|
||||
- [x] 7.2 创建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql`
|
||||
- 幂等 UPDATE:`UPDATE biz.trigger_jobs SET trigger_config = jsonb_set(trigger_config, '{cron_expression}', '"0 7 * * *"') WHERE job_name = 'task_generator'`
|
||||
- 包含回滚注释
|
||||
- _Requirements: 6.1_
|
||||
|
||||
- [x] 8. Final checkpoint — 全部测试通过
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
- 运行 `pytest tests/test_p52_*.py -v` 验证所有 P5.2 属性测试通过
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- T1/T2 已在之前修复中实现,任务 1 仅编写属性测试验证覆盖
|
||||
- T3 和 T4 对 `note_reclassifier.py` 有交叉修改,任务 3.1 和 4.2 需协同实施
|
||||
- T5 涉及 handler 签名变更,需同步修改 recall_detector 和 note_reclassifier
|
||||
- T6 种子数据已是 `0 7 * * *`,迁移脚本为幂等更新确保生产环境一致
|
||||
- 属性测试使用 Hypothesis,每个属性最少 100 次迭代
|
||||
File diff suppressed because one or more lines are too long
@@ -1,69 +1,9 @@
|
||||
{
|
||||
"audit_required": true,
|
||||
"db_docs_required": true,
|
||||
"reasons": [
|
||||
"root-file",
|
||||
"dir:admin-web",
|
||||
"dir:backend",
|
||||
"dir:etl",
|
||||
"dir:miniprogram",
|
||||
"dir:db",
|
||||
"db-schema-change",
|
||||
"dir:shared"
|
||||
],
|
||||
"changed_files": [
|
||||
".env",
|
||||
".env.template",
|
||||
".gitignore",
|
||||
"_tmp_replace2.py",
|
||||
"apps/admin-web/README.md",
|
||||
"apps/admin-web/src/App.tsx",
|
||||
"apps/admin-web/src/__tests__/taskLogParser.test.ts",
|
||||
"apps/admin-web/src/api/businessDay.ts",
|
||||
"apps/admin-web/src/api/schedules.ts",
|
||||
"apps/admin-web/src/components/BusinessDayHint.tsx",
|
||||
"apps/admin-web/src/components/LogStream.tsx",
|
||||
"apps/admin-web/src/components/ScheduleHistoryDrawer.tsx",
|
||||
"apps/admin-web/src/components/ScheduleTab.tsx",
|
||||
"apps/admin-web/src/components/TaskLogViewer.tsx",
|
||||
"apps/admin-web/src/pages/LogViewer.tsx",
|
||||
"apps/admin-web/src/pages/TaskConfig.tsx",
|
||||
"apps/admin-web/src/pages/TaskManager.tsx",
|
||||
"apps/admin-web/src/store/businessDayStore.ts",
|
||||
"apps/admin-web/src/types/index.ts",
|
||||
"apps/admin-web/src/utils/",
|
||||
"apps/admin-web/tsconfig.tsbuildinfo",
|
||||
"apps/backend/README.md",
|
||||
"apps/backend/app/ai/",
|
||||
"apps/backend/app/config.py",
|
||||
"apps/backend/app/main.py",
|
||||
"apps/backend/app/middleware/permission.py",
|
||||
"apps/backend/app/routers/admin_applications.py",
|
||||
"apps/backend/app/routers/business_day.py",
|
||||
"apps/backend/app/routers/execution.py",
|
||||
"apps/backend/app/routers/member_retention_clue.py",
|
||||
"apps/backend/app/routers/ops_panel.py",
|
||||
"apps/backend/app/routers/schedules.py",
|
||||
"apps/backend/app/routers/tasks.py",
|
||||
"apps/backend/app/routers/xcx_auth.py",
|
||||
"apps/backend/app/routers/xcx_notes.py",
|
||||
"apps/backend/app/routers/xcx_tasks.py",
|
||||
"apps/backend/app/schemas/execution.py",
|
||||
"apps/backend/app/schemas/member_retention_clue.py",
|
||||
"apps/backend/app/schemas/schedules.py",
|
||||
"apps/backend/app/schemas/tasks.py",
|
||||
"apps/backend/app/schemas/xcx_auth.py",
|
||||
"apps/backend/app/schemas/xcx_notes.py",
|
||||
"apps/backend/app/schemas/xcx_tasks.py",
|
||||
"apps/backend/app/services/application.py",
|
||||
"apps/backend/app/services/cli_builder.py",
|
||||
"apps/backend/app/services/note_reclassifier.py",
|
||||
"apps/backend/app/services/note_service.py",
|
||||
"apps/backend/app/services/recall_detector.py",
|
||||
"apps/backend/app/services/scheduler.py",
|
||||
"apps/backend/app/services/task_executor.py"
|
||||
],
|
||||
"change_fingerprint": "62a29521ce5c8475e608c087c47714cd721c82c9",
|
||||
"marked_at": "2026-03-09T01:10:02.756262+08:00",
|
||||
"last_reminded_at": "2026-03-09T00:48:04.340064+08:00"
|
||||
"audit_required": false,
|
||||
"db_docs_required": false,
|
||||
"reasons": [],
|
||||
"changed_files": [],
|
||||
"change_fingerprint": "",
|
||||
"marked_at": "",
|
||||
"last_reminded_at": ""
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"needs_check": false,
|
||||
"scanned_at": "2026-03-09T00:48:01.402135+08:00",
|
||||
"scanned_at": "2026-03-15T09:58:27.183154+08:00",
|
||||
"new_migration_sql": [],
|
||||
"new_or_modified_sql": [],
|
||||
"code_without_docs": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"prompt_id": "P20260309-011002",
|
||||
"at": "2026-03-09T01:10:02.756262+08:00"
|
||||
"prompt_id": "P20260315-095422",
|
||||
"at": "2026-03-15T09:54:22.179324+08:00"
|
||||
}
|
||||
@@ -12,7 +12,8 @@ description: 项目文档地图索引。需要定位文档、理解项目结构
|
||||
|
||||
| 需要什么 | 去哪里找 |
|
||||
|---------|---------|
|
||||
| DB 变更审计模板 | `docs/database/BD_Manual_*.md` |
|
||||
| DB 变更审计(业务库) | `docs/database/BD_Manual_*.md` |
|
||||
| DB 变更审计(ETL 库) | `apps/etl/connectors/feiqiu/docs/database/` |
|
||||
| API 端点参考 | `apps/backend/docs/API-REFERENCE.md` |
|
||||
| ETL 任务说明 | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
|
||||
| ETL 业务规则 | `apps/etl/connectors/feiqiu/docs/business-rules/` |
|
||||
|
||||
@@ -31,6 +31,8 @@ inclusion: always
|
||||
7. **现金流互斥**:`cash_inflow_total` 中 `platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥
|
||||
8. **废单判断**:使用 `dwd_assistant_service_log_ex.is_trash`,`dwd_assistant_trash_event` 已废弃(2026-02-22 DROP)
|
||||
9. **储值卡字段命名**:DWS 层使用 `balance_pay`(总额)、`recharge_card_pay`(现金充值卡)、`gift_card_pay`(赠送卡);`recharge_card_consume`(财务日报)
|
||||
10. **会员字段断档(DQ-6)**:`settlement_head.member_phone/member_name` 自 2025-12 起全为 NULL(上游不再下发)。需要会员手机号/昵称时,必须通过 `member_id` LEFT JOIN `dwd.dim_member`(字段 `mobile`/`nickname`,取 `scd2_is_current=1`),禁止直接使用 `member_phone`
|
||||
11. **会员卡字段断档(DQ-7)**:`settlement_head.member_card_type_name` 自 2025-07-21 起全为 NULL,`member_card_account_id` 全为 0。需要会员卡类型时,必须通过 `member_id` LEFT JOIN `dwd.dim_member_card_account`(关联 `tenant_member_id = member_id`,取 `scd2_is_current=1`),禁止直接使用 `member_card_type_name`。通用规则:结算单上所有会员相关冗余字段均不可靠,一律通过 ID 关联维度表获取
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ Kiro 所有产出物分两类,各有归档规则,禁止随意散放。
|
||||
|----------|----------|------|
|
||||
| 数据分析报告、调研产出 | `docs/reports/` | 复杂订单分析、字段口径全景 |
|
||||
| 架构设计文档 | `docs/architecture/` | ETL 架构说明 |
|
||||
| 数据库变更审计(BD 手册) | `docs/database/` | `BD_Manual_*.md` |
|
||||
| 数据库变更审计(业务库 BD 手册) | `docs/database/` | `BD_Manual_*.md`(zqyy_app / FDW / RLS) |
|
||||
| 数据库变更审计(ETL BD 手册) | `apps/etl/.../docs/database/` | 模块专属表级文档、跨层映射 |
|
||||
| 变更审计记录 | `docs/audit/changes/` | `YYYY-MM-DD__<slug>.md` |
|
||||
| 产品需求规格 | `docs/prd/specs/` | P1-P11 需求 spec |
|
||||
| 数据契约(OpenAPI/Schema) | `docs/contracts/` | `backend-api.json` |
|
||||
@@ -37,6 +38,7 @@ Kiro 所有产出物分两类,各有归档规则,禁止随意散放。
|
||||
| 迁移记录 | `docs/migrate/` | Monorepo 迁移总结 |
|
||||
| MCP 相关文档 | `docs/mcp/` | AI 查询手册 |
|
||||
| UI 原型 | `docs/h5_ui/` | H5 静态页面 |
|
||||
| 小程序前端开发指南 | `docs/miniprogram-dev/` | 页面开发流程、代理手册、规范参考 |
|
||||
| 运维手册 | `docs/ops/` | 故障排查流程 |
|
||||
| 权限矩阵 | `docs/permission_matrix/` | 角色-资源映射 |
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
inclusion: fileMatch
|
||||
fileMatchPattern: "**/miniprogram/miniprogram/pages/**,**/miniprogram/miniprogram/components/**,**/h5_ui/**"
|
||||
name: miniprogram-h5-conversion
|
||||
description: H5 原型转微信小程序的强制规范。读到小程序页面/组件或 H5 原型文件时自动加载。
|
||||
---
|
||||
|
||||
# H5 → 微信小程序转换规范(强制)
|
||||
|
||||
本规范适用于所有将 `docs/h5_ui/pages/*.html` 转换为小程序页面的任务。
|
||||
|
||||
## 一、转换前强制加载
|
||||
|
||||
每次转换页面前,必须加载以下资源(按顺序):
|
||||
|
||||
1. `wechat-miniprogram` Power 的 steering 文件:`view-layer.md`、`tdesign.md`、`builtin-components.md`
|
||||
2. 目标页面的交互说明:`docs/h5_ui/interactions/<page>.md`
|
||||
3. 设计 Token:`docs/h5_ui/design-tokens.json`
|
||||
4. 图标映射表:`docs/h5_ui/icon-mapping.md`
|
||||
5. 目标页面的 H5 源码:`docs/h5_ui/pages/<page>.html`
|
||||
6. 计算样式(如有):`docs/h5_ui/computed-styles.json` 中对应页面的 key
|
||||
7. 截图(如用户提供):`docs/h5_ui/screenshots/<page>--*.png`
|
||||
|
||||
## 二、原型忠实度(最高优先级)
|
||||
|
||||
- `docs/h5_ui/pages/*.html` 中的结构、层次、元素、配色、间距、交互行为是唯一视觉真相
|
||||
- 任何偏离原型的实现都需要用户明确确认
|
||||
- 截图是校验还原度的唯一视觉参考
|
||||
|
||||
## 三、标签映射(硬性规则)
|
||||
|
||||
| HTML | WXML | 说明 |
|
||||
|------|------|------|
|
||||
| `<div>` | `<view>` | 容器 |
|
||||
| `<span>` / `<p>` | `<text>` | 文本必须用 `<text>` 包裹 |
|
||||
| `<a>` | `<navigator>` | 或 `wx.navigateTo()` |
|
||||
| `<img>` | `<image mode="">` | 必须指定 `mode` 和宽高 |
|
||||
| `<svg>` 内联 | `<image src="xx.svg">` | 不支持内联 SVG |
|
||||
| `<ul>/<li>` | `<view wx:for>` | 无列表语义标签 |
|
||||
| `<select>` | `<t-picker>` | 完全不同的交互 |
|
||||
| `<button>` | `<t-button>` | TDesign 优先 |
|
||||
| `<input>` | `<t-input>` | TDesign 优先 |
|
||||
|
||||
严禁在 WXML 中使用 HTML 标签(div/span/p/a/img 等)。
|
||||
|
||||
## 四、样式规则
|
||||
|
||||
### rpx 换算
|
||||
- 基准:屏幕宽度 = 750rpx,设计稿以 375px 宽为基准
|
||||
- 换算公式:H5 的 px 值 × 2 = rpx 值
|
||||
- 严禁同一类间距混用 rpx 和 px
|
||||
|
||||
### 设计 Token 引用
|
||||
颜色、间距、圆角、字号、阴影必须参照 `docs/h5_ui/design-tokens.json`,不得凭记忆硬编码。
|
||||
|
||||
### 不支持的 CSS
|
||||
| CSS 特性 | 替代方案 |
|
||||
|----------|---------|
|
||||
| `backdrop-filter: blur()` | 半透明背景色 `rgba()` |
|
||||
| `*` 通配符选择器 | 逐个元素设置 |
|
||||
| 远程 `@font-face` | `wx.loadFontFace()` |
|
||||
|
||||
### 样式隔离
|
||||
- `app.wxss` = 全局样式
|
||||
- 页面 `.wxss` = 自动隔离
|
||||
- 组件 `.wxss` = 默认隔离,穿透用外部样式类或 CSS 变量
|
||||
|
||||
## 五、TDesign 优先原则
|
||||
|
||||
- 凡 TDesign 组件库能覆盖的 UI 元素,必须使用 TDesign 组件
|
||||
- 自定义实现仅限 TDesign 无法覆盖的场景
|
||||
- TDesign 样式覆盖优先用 CSS 变量,其次外部样式类,最后 `!important`
|
||||
- 组件必须在页面 `.json` 的 `usingComponents` 中注册
|
||||
|
||||
## 六、事件系统
|
||||
|
||||
- 不能在 `bindtap` 中传参:用 `data-*` 属性
|
||||
- 取值路径:`e.detail.value`(非 `event.target.value`)
|
||||
- 阻止冒泡:用 `catchtap`(非 `preventDefault`)
|
||||
- `data-` 属性名自动转换:连字符转驼峰,大写转小写
|
||||
|
||||
## 七、数据与状态
|
||||
|
||||
- 用 `this.setData()` 驱动视图更新,无 DOM API
|
||||
- `wx:if` 中不支持复杂 JS 表达式,需在 JS 中预处理
|
||||
- 布尔属性必须用 `{{}}` 包裹:`checked="{{true}}"`
|
||||
- 列表渲染必须加 `wx:key`
|
||||
|
||||
## 八、导航与路由
|
||||
|
||||
- 页面栈最多 10 层,`navigateTo` 超过会静默失败
|
||||
- 跳转 tabBar 页面必须用 `switchTab`
|
||||
- 路径以 `/` 开头,不带 `.wxml` 后缀
|
||||
- 登录/登出场景用 `reLaunch`
|
||||
|
||||
## 九、Mock 数据策略(纯 UI 阶段)
|
||||
|
||||
后端 API 未就绪时,使用 mock 数据:
|
||||
- 在页面 JS 的 `onLoad` 中用 `this.setData()` 设置 mock 数据
|
||||
- mock 数据结构必须贴近真实 API 返回格式(参考对应 Spec 的接口定义)
|
||||
- mock 数据用 `// TODO: 替换为真实 API 调用` 注释标记
|
||||
- 联调时只需替换数据获取逻辑,不改动 WXML/WXSS
|
||||
|
||||
## 十、新页面开发 Checklist
|
||||
|
||||
- [ ] HTML 标签全部替换为 WXML 组件
|
||||
- [ ] 内联 SVG 提取为文件,改用 `<image>` 或 `<t-icon>`
|
||||
- [ ] Tailwind 类全部手写为 WXSS(px × 2 = rpx)
|
||||
- [ ] 不支持的 CSS 改为替代方案
|
||||
- [ ] 事件绑定改为 `bindtap` / `bind:change`,传参用 `data-*`
|
||||
- [ ] `alert/confirm` 改为 `wx.showToast` / `wx.showModal`
|
||||
- [ ] `localStorage` 改为 `wx.setStorageSync`
|
||||
- [ ] 路由跳转改为 `wx.navigateTo` / `wx.reLaunch` 等
|
||||
- [ ] 图片设置 `mode` 属性
|
||||
- [ ] 列表渲染加 `wx:key`
|
||||
- [ ] 布尔属性用 `{{}}` 包裹
|
||||
- [ ] TDesign 组件在页面 `.json` 中注册
|
||||
- [ ] 安全区适配:JS `wx.getSystemInfoSync().statusBarHeight` 动态 padding-top(禁止用 `env(safe-area-inset-top)`)
|
||||
- [ ] 页面配置:`navigationBarTitleText` 等
|
||||
|
||||
## 十一、验收标准
|
||||
|
||||
- 视觉还原度:与 H5 截图对比,布局/间距/颜色/字号一致
|
||||
- 交互完整性:交互说明中的所有状态和操作均已实现
|
||||
- 空状态/加载态/错误态:均有处理
|
||||
- TDesign 组件正确使用,无原生 HTML 标签残留
|
||||
|
||||
## 十二、实战踩坑速查
|
||||
|
||||
> 完整记录见 `docs/prd/MIGRATION-PLAYBOOK.md` 第六章。
|
||||
|
||||
### WXML 模板
|
||||
- **禁止在 `{{}}` 中调用 JS 方法**(`.toFixed()`、`.map()` 等)→ 用 WXS 模块 `utils/format.wxs`
|
||||
- **TabBar 页面跳转必须用 `wx.switchTab`**(task-list、board-finance、my-profile),`navigateTo` 会静默失败
|
||||
|
||||
### 样式与布局
|
||||
- **一屏页面**(login、reviewing、no-permission 等):`height: 100vh` + `box-sizing: border-box`,不用 `min-height: 100vh`
|
||||
- **状态栏适配**:用 JS `wx.getSystemInfoSync().statusBarHeight` 动态设置 `padding-top`,禁止用 `env(safe-area-inset-top)`
|
||||
- **padding-top + 100vh**:必须配合 `box-sizing: border-box`,否则底部内容溢出
|
||||
|
||||
### TDesign 组件
|
||||
- **`t-button icon="xxx"` 只支持内置图标**:品牌图标(微信 logo 等)必须导出 SVG,用 `<view>` + `<image>` 组合
|
||||
- **TDesign 默认样式优先级高**:与原型差异大时,直接用原生 `<view>` 实现,绕开样式干扰
|
||||
|
||||
### 资源引用
|
||||
- **所有 H5 内联 SVG 必须导出为独立 `.svg` 文件**,存放 `assets/icons/`,用 `<image>` 引用
|
||||
- **引用不存在的图片会 500 错误**:用 CSS 渐变、emoji、`<t-icon>` 替代
|
||||
|
||||
---
|
||||
|
||||
> 以上内容已整合至 `docs/prd/MIGRATION-PLAYBOOK.md`(唯一权威迁移文档)。
|
||||
> 避坑清单见第六章,迁移流程见第二章,输入素材见第十二章,AI 工作流见第十一章。
|
||||
@@ -13,7 +13,7 @@ inclusion: always
|
||||
- 搜索 `docs/audit/changes/` 中相关的历史审计记录
|
||||
- **查询 Session 索引**:读取 `docs/audit/session_logs/_session_index.json`,按 `summary.files_modified` 筛选涉及目标模块的历史 session,提取 `description`(操作摘要)和 `startTime`,了解该模块近期被修改的上下文和原因(详见 `docs/audit/SESSION-LOG-GUIDE.md`)
|
||||
- 阅读涉及模块的 README、PRD spec(`docs/prd/specs/`)
|
||||
- 数据库相关:BD 手册(`docs/database/BD_Manual_*.md`)
|
||||
- 数据库相关:BD 手册(`docs/database/BD_Manual_*.md` + `apps/etl/connectors/feiqiu/docs/database/`)
|
||||
- ETL 相关:产品说明、数据流报告
|
||||
- 接口相关:OpenAPI spec、接口文档
|
||||
- 读取要修改的文件及其直接依赖(调用方、被调用方)
|
||||
|
||||
217
_DEL/Delete/b-class-test.html
Normal file
217
_DEL/Delete/b-class-test.html
Normal file
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>B 类能力实测对照页</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0052d9',
|
||||
'primary-light': '#ecf2fe',
|
||||
success: '#00a870',
|
||||
warning: '#ed7b2f',
|
||||
error: '#e34d59',
|
||||
'gray-1': '#f3f3f3',
|
||||
'gray-7': '#8b8b8b',
|
||||
'gray-9': '#5e5e5e',
|
||||
'gray-13': '#242424',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@keyframes pulse-soft { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
@keyframes float-y { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
|
||||
.anim-pulse { animation: pulse-soft 2s ease-in-out infinite; }
|
||||
.anim-float { animation: float-y 3s ease-in-out infinite; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-1 text-gray-13 pb-8">
|
||||
|
||||
<!-- ===== 顶栏 sticky ===== -->
|
||||
<div class="bg-white px-4 py-3 text-lg font-semibold sticky top-0 z-50 shadow-sm">
|
||||
B 类能力实测(H5 端)
|
||||
</div>
|
||||
|
||||
<!-- ===== 1. Grid 布局 ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">1. Grid 布局</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<p class="text-xs text-gray-7 mb-1">grid-cols-2</p>
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<div class="bg-primary-light rounded-lg p-3 text-center text-sm">A</div>
|
||||
<div class="bg-primary-light rounded-lg p-3 text-center text-sm">B</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-7 mb-1">grid-cols-3</p>
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">1</div>
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">2</div>
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">3</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-7 mb-1">grid-cols-4</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">甲</div>
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">乙</div>
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">丙</div>
|
||||
<div class="bg-primary-light rounded-lg p-2 text-center text-xs">丁</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 2. Sticky 定位 ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">2. Sticky(需滚动观察)</p>
|
||||
<div class="bg-white rounded-lg overflow-hidden">
|
||||
<div class="sticky top-[52px] z-40 bg-primary text-white text-center py-2 text-sm">
|
||||
sticky 元素 top-52px
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="h-[160px] bg-gray-1 rounded-lg flex items-center justify-center text-xs text-gray-7">
|
||||
占位区域
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 3. line-clamp ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">3. line-clamp 多行截断</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<p class="text-xs text-gray-7 mb-1">line-clamp-2</p>
|
||||
<p class="text-sm line-clamp-2 mb-3">这是一段很长的文本用于测试多行截断效果。微信小程序中 -webkit-line-clamp 属于条件能力,需要配合 display:-webkit-box 和 -webkit-box-orient:vertical 使用。在不同基础库版本上表现可能不一致,需要真机验证确认。</p>
|
||||
<p class="text-xs text-gray-7 mb-1">line-clamp-3</p>
|
||||
<p class="text-sm line-clamp-3">这是另一段很长的文本用于测试三行截断。第一行内容开始。第二行内容继续延伸到这里。第三行内容到这里应该被截断了。如果你能看到第四行的内容说明 line-clamp-3 没有生效。这段文字需要足够长才能触发三行截断的效果所以多写一些。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 4. vh 单位 ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">4. vh 单位</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="min-h-[25vh] bg-primary/5 rounded-lg flex items-center justify-center">
|
||||
<span class="text-sm text-primary">min-height: 25vh</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 5. backdrop-filter ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">5. backdrop-filter: blur</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="relative h-[100px] rounded-lg overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary to-success"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="bg-white/20 backdrop-blur-md rounded-lg px-6 py-3">
|
||||
<span class="text-white text-sm font-medium">毛玻璃效果</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 6. space-y ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">6. space-y 子元素间距</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<p class="text-xs text-gray-7 mb-1">space-y-2 (8px)</p>
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="bg-primary-light rounded p-2 text-sm">项目 A</div>
|
||||
<div class="bg-primary-light rounded p-2 text-sm">项目 B</div>
|
||||
<div class="bg-primary-light rounded p-2 text-sm">项目 C</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-7 mb-1">space-y-4 (16px)</p>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-primary-light rounded p-2 text-sm">项目 X</div>
|
||||
<div class="bg-primary-light rounded p-2 text-sm">项目 Y</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 7. gap-x / gap-y ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">7. gap-x / gap-y 方向性间距</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<p class="text-xs text-gray-7 mb-1">flex-wrap + gap-x-3 gap-y-2</p>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-2">
|
||||
<span class="bg-primary/10 text-primary text-xs px-3 py-1 rounded-full">标签一</span>
|
||||
<span class="bg-success/10 text-success text-xs px-3 py-1 rounded-full">标签二</span>
|
||||
<span class="bg-warning/10 text-warning text-xs px-3 py-1 rounded-full">标签三</span>
|
||||
<span class="bg-error/10 text-error text-xs px-3 py-1 rounded-full">标签四</span>
|
||||
<span class="bg-primary/10 text-primary text-xs px-3 py-1 rounded-full">标签五</span>
|
||||
<span class="bg-success/10 text-success text-xs px-3 py-1 rounded-full">标签六</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 8. CSS transition ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">8. CSS transition</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<p class="text-xs text-gray-7 mb-2">点击按钮切换颜色(transition-colors 300ms)</p>
|
||||
<button id="btn-transition" class="bg-primary text-white text-sm px-4 py-2 rounded-lg transition-colors duration-300" onclick="this.classList.toggle('bg-success')">
|
||||
点我变色
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 9. CSS animation ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">9. CSS @keyframes 动画</p>
|
||||
<div class="bg-white rounded-lg p-3 flex gap-6 items-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="w-10 h-10 rounded-full bg-primary anim-pulse"></div>
|
||||
<span class="text-xs text-gray-7">pulse</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="w-10 h-10 rounded-full bg-success anim-float"></div>
|
||||
<span class="text-xs text-gray-7">float</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 10. overflow-x 横向滚动 ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">10. overflow-x-auto 横向滚动</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-3" style="width: max-content;">
|
||||
<div class="w-[120px] h-[80px] bg-primary/10 rounded-lg flex items-center justify-center text-xs text-primary shrink-0">卡片 1</div>
|
||||
<div class="w-[120px] h-[80px] bg-success/10 rounded-lg flex items-center justify-center text-xs text-success shrink-0">卡片 2</div>
|
||||
<div class="w-[120px] h-[80px] bg-warning/10 rounded-lg flex items-center justify-center text-xs text-warning shrink-0">卡片 3</div>
|
||||
<div class="w-[120px] h-[80px] bg-error/10 rounded-lg flex items-center justify-center text-xs text-error shrink-0">卡片 4</div>
|
||||
<div class="w-[120px] h-[80px] bg-primary/10 rounded-lg flex items-center justify-center text-xs text-primary shrink-0">卡片 5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 11. divide-y 分割线 ===== -->
|
||||
<section class="mx-4 mt-4">
|
||||
<p class="text-sm font-semibold mb-2">11. divide-y 分割线</p>
|
||||
<div class="bg-white rounded-lg overflow-hidden">
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div class="px-3 py-3 text-sm">列表项 1</div>
|
||||
<div class="px-3 py-3 text-sm">列表项 2</div>
|
||||
<div class="px-3 py-3 text-sm">列表项 3</div>
|
||||
<div class="px-3 py-3 text-sm">列表项 4</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 12. env(safe-area-inset-bottom) ===== -->
|
||||
<section class="mx-4 mt-4 mb-4">
|
||||
<p class="text-sm font-semibold mb-2">12. safe-area-inset-bottom</p>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="bg-primary text-white text-center py-3 rounded-lg text-sm" style="padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));">
|
||||
底部按钮(含安全区补偿)
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
295
_DEL/Delete/benchmark.html
Normal file
295
_DEL/Delete/benchmark.html
Normal file
@@ -0,0 +1,295 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=430, initial-scale=1.0, user-scalable=no">
|
||||
<title>基准测试页 v3</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
html,body{scrollbar-width:none;-ms-overflow-style:none;}
|
||||
::-webkit-scrollbar{display:none;}
|
||||
body{width:430px;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue',sans-serif;background:#f5f5f5;color:#333;overflow-x:hidden;position:relative;}
|
||||
|
||||
/* 右侧贯穿彩条 — absolute,高度=视口高度752px */
|
||||
.ruler-strip{position:absolute;top:0;right:0;width:10px;z-index:9999;display:flex;flex-direction:column;}
|
||||
.ruler-bar{width:10px;height:752px;border-left:1px solid #000;}
|
||||
.ruler-bar.blue{background:#0052d9;}
|
||||
.ruler-bar.green{background:#00a870;}
|
||||
|
||||
/* 通用 section */
|
||||
.sec{padding:6px 0 6px 0;border-bottom:1px solid #ddd;margin-right:10px;}
|
||||
.sec-t{font-size:11px;font-weight:600;color:#0052d9;padding:0 0 4px 4px;}
|
||||
|
||||
/* 行 */
|
||||
.row{display:flex;align-items:center;padding:2px 4px;}
|
||||
.lbl{font-size:10px;color:#999;width:80px;flex-shrink:0;}
|
||||
.val{flex:1;}
|
||||
|
||||
/* 横条 */
|
||||
.bar{display:flex;align-items:center;color:#fff;font-size:9px;padding-left:4px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 右侧贯穿彩条 -->
|
||||
<div class="ruler-strip" id="rulerStrip"></div>
|
||||
<script>
|
||||
(function(){
|
||||
var c=document.getElementById('rulerStrip');
|
||||
for(var i=0;i<4;i++){
|
||||
var d=document.createElement('div');
|
||||
d.className='ruler-bar '+(i%2===0?'blue':'green');
|
||||
c.appendChild(d);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- 1. font-size -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">1. font-size</div>
|
||||
<div class="row"><span class="lbl">10px</span><span class="val" style="font-size:10px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">11px</span><span class="val" style="font-size:11px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">12px</span><span class="val" style="font-size:12px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">13px</span><span class="val" style="font-size:13px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">14px</span><span class="val" style="font-size:14px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">16px</span><span class="val" style="font-size:16px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">18px</span><span class="val" style="font-size:18px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">20px</span><span class="val" style="font-size:20px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">24px</span><span class="val" style="font-size:24px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">28px</span><span class="val" style="font-size:28px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">32px</span><span class="val" style="font-size:32px;">中文ABC123</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 2. font-weight -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">2. font-weight</div>
|
||||
<div class="row"><span class="lbl">300</span><span class="val" style="font-size:16px;font-weight:300;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">400</span><span class="val" style="font-size:16px;font-weight:400;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">500</span><span class="val" style="font-size:16px;font-weight:500;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">600</span><span class="val" style="font-size:16px;font-weight:600;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">700</span><span class="val" style="font-size:16px;font-weight:700;">中文ABC123</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. line-height -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">3. line-height (14px)</div>
|
||||
<div style="font-size:14px;line-height:1.0;background:#ecf2fe;padding:2px 4px;margin-bottom:2px;margin-right:10px;">LH1.0 中文测试文字验证行高</div>
|
||||
<div style="font-size:14px;line-height:1.25;background:#e8f8f0;padding:2px 4px;margin-bottom:2px;margin-right:10px;">LH1.25 中文测试文字验证行高</div>
|
||||
<div style="font-size:14px;line-height:1.5;background:#ecf2fe;padding:2px 4px;margin-bottom:2px;margin-right:10px;">LH1.5 中文测试文字验证行高</div>
|
||||
<div style="font-size:14px;line-height:2.0;background:#e8f8f0;padding:2px 4px;margin-right:10px;">LH2.0 中文测试文字验证行高</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. letter-spacing -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">4. letter-spacing</div>
|
||||
<div class="row"><span class="lbl">0px</span><span class="val" style="font-size:14px;letter-spacing:0px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">0.5px</span><span class="val" style="font-size:14px;letter-spacing:0.5px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">1px</span><span class="val" style="font-size:14px;letter-spacing:1px;">中文ABC123</span></div>
|
||||
<div class="row"><span class="lbl">2px</span><span class="val" style="font-size:14px;letter-spacing:2px;">中文ABC123</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. width bars (25%/50%/75%/100%) -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">5. width bars</div>
|
||||
<div class="bar" style="width:108px;height:30px;background:#0052d9;margin-bottom:2px;">108 (25%)</div>
|
||||
<div class="bar" style="width:215px;height:30px;background:#00a870;margin-bottom:2px;">215 (50%)</div>
|
||||
<div class="bar" style="width:323px;height:30px;background:#ed7b2f;margin-bottom:2px;">323 (75%)</div>
|
||||
<div class="bar" style="width:420px;height:30px;background:#e34d59;">420 (≈100%-10)</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. height bars -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">6. height bars</div>
|
||||
<div style="display:flex;gap:4px;align-items:flex-end;padding:0 4px;">
|
||||
<div style="width:40px;height:20px;background:#0052d9;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">20</div>
|
||||
<div style="width:40px;height:40px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">40</div>
|
||||
<div style="width:40px;height:60px;background:#ed7b2f;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">60</div>
|
||||
<div style="width:40px;height:80px;background:#e34d59;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">80</div>
|
||||
<div style="width:40px;height:100px;background:#0052d9;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">100</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. padding -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">7. padding</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap;padding:0 4px;">
|
||||
<div style="padding:4px;background:#ecf2fe;border:1px solid #0052d9;font-size:9px;color:#0052d9;">p4</div>
|
||||
<div style="padding:8px;background:#ecf2fe;border:1px solid #0052d9;font-size:9px;color:#0052d9;">p8</div>
|
||||
<div style="padding:12px;background:#ecf2fe;border:1px solid #0052d9;font-size:9px;color:#0052d9;">p12</div>
|
||||
<div style="padding:16px;background:#ecf2fe;border:1px solid #0052d9;font-size:9px;color:#0052d9;">p16</div>
|
||||
<div style="padding:20px;background:#ecf2fe;border:1px solid #0052d9;font-size:9px;color:#0052d9;">p20</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. margin -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">8. margin</div>
|
||||
<div style="background:#f0f0f0;padding:2px 0;">
|
||||
<div style="margin-left:0;width:80px;height:20px;background:#0052d9;color:#fff;font-size:8px;display:flex;align-items:center;padding-left:2px;margin-bottom:2px;">ml:0</div>
|
||||
<div style="margin-left:10px;width:80px;height:20px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;padding-left:2px;margin-bottom:2px;">ml:10</div>
|
||||
<div style="margin-left:20px;width:80px;height:20px;background:#ed7b2f;color:#fff;font-size:8px;display:flex;align-items:center;padding-left:2px;margin-bottom:2px;">ml:20</div>
|
||||
<div style="margin-left:40px;width:80px;height:20px;background:#e34d59;color:#fff;font-size:8px;display:flex;align-items:center;padding-left:2px;">ml:40</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. border-radius -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">9. border-radius</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;padding:0 4px;">
|
||||
<div style="width:40px;height:40px;border-radius:2px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">r2</div>
|
||||
<div style="width:40px;height:40px;border-radius:4px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">r4</div>
|
||||
<div style="width:40px;height:40px;border-radius:8px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">r8</div>
|
||||
<div style="width:40px;height:40px;border-radius:12px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">r12</div>
|
||||
<div style="width:40px;height:40px;border-radius:20px;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">r20</div>
|
||||
<div style="width:40px;height:40px;border-radius:50%;background:#00a870;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center;">50%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. border-width -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">10. border-width</div>
|
||||
<div style="display:flex;gap:6px;padding:0 4px;">
|
||||
<div style="width:50px;height:30px;border:1px solid #333;font-size:8px;display:flex;align-items:center;justify-content:center;">1px</div>
|
||||
<div style="width:50px;height:30px;border:2px solid #333;font-size:8px;display:flex;align-items:center;justify-content:center;">2px</div>
|
||||
<div style="width:50px;height:30px;border:3px solid #333;font-size:8px;display:flex;align-items:center;justify-content:center;">3px</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 11. gap -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">11. gap</div>
|
||||
<div style="padding:0 4px;">
|
||||
<div style="font-size:9px;color:#999;">gap:4px</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:4px;">
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#999;">gap:8px</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#999;">gap:12px</div>
|
||||
<div style="display:flex;gap:12px;margin-bottom:4px;">
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
<div style="width:24px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 12. box-shadow -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">12. box-shadow</div>
|
||||
<div style="display:flex;gap:10px;padding:4px;">
|
||||
<div style="width:60px;height:40px;background:#fff;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,0.08);font-size:8px;display:flex;align-items:center;justify-content:center;color:#999;">sm</div>
|
||||
<div style="width:60px;height:40px;background:#fff;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.1);font-size:8px;display:flex;align-items:center;justify-content:center;color:#999;">md</div>
|
||||
<div style="width:60px;height:40px;background:#fff;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,0.12);font-size:8px;display:flex;align-items:center;justify-content:center;color:#999;">lg</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 13. 色板 -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">13. colors</div>
|
||||
<div style="display:flex;gap:2px;flex-wrap:wrap;padding:0 4px;">
|
||||
<div style="width:36px;height:20px;background:#0052d9;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">pri</div>
|
||||
<div style="width:36px;height:20px;background:#00a870;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">suc</div>
|
||||
<div style="width:36px;height:20px;background:#ed7b2f;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">wrn</div>
|
||||
<div style="width:36px;height:20px;background:#e34d59;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">err</div>
|
||||
<div style="width:36px;height:20px;background:#f5f5f5;border-radius:2px;color:#333;font-size:7px;display:flex;align-items:center;justify-content:center;">g1</div>
|
||||
<div style="width:36px;height:20px;background:#eee;border-radius:2px;color:#333;font-size:7px;display:flex;align-items:center;justify-content:center;">g2</div>
|
||||
<div style="width:36px;height:20px;background:#999;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">g6</div>
|
||||
<div style="width:36px;height:20px;background:#333;border-radius:2px;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center;">g13</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 14. text-align -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">14. text-align</div>
|
||||
<div style="font-size:12px;text-align:left;background:#ecf2fe;padding:2px 4px;margin-bottom:1px;margin-right:10px;">left 左对齐</div>
|
||||
<div style="font-size:12px;text-align:center;background:#e8f8f0;padding:2px 4px;margin-bottom:1px;margin-right:10px;">center 居中</div>
|
||||
<div style="font-size:12px;text-align:right;background:#ecf2fe;padding:2px 4px;margin-right:10px;">right 右对齐</div>
|
||||
</div>
|
||||
|
||||
<!-- 15. text-overflow -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">15. text-overflow</div>
|
||||
<div style="width:200px;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:#ecf2fe;padding:2px 4px;margin-bottom:2px;">这是一段很长的文字用于测试文本溢出省略号效果展示</div>
|
||||
<div style="width:200px;font-size:12px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;background:#e8f8f0;padding:2px 4px;line-height:1.4;">这是一段很长的文字用于测试多行文本溢出省略号效果展示两行截断</div>
|
||||
</div>
|
||||
|
||||
<!-- 16. flex布局 -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">16. flex layout</div>
|
||||
<div style="padding:0 4px;">
|
||||
<div style="font-size:9px;color:#999;">space-between</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
||||
<div style="width:50px;height:24px;background:#0052d9;"></div>
|
||||
<div style="width:50px;height:24px;background:#00a870;"></div>
|
||||
<div style="width:50px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#999;">space-around</div>
|
||||
<div style="display:flex;justify-content:space-around;margin-bottom:4px;">
|
||||
<div style="width:50px;height:24px;background:#0052d9;"></div>
|
||||
<div style="width:50px;height:24px;background:#00a870;"></div>
|
||||
<div style="width:50px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#999;">center</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px;">
|
||||
<div style="width:50px;height:24px;background:#0052d9;"></div>
|
||||
<div style="width:50px;height:24px;background:#00a870;"></div>
|
||||
<div style="width:50px;height:24px;background:#ed7b2f;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 17. 综合卡片 -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">17. card</div>
|
||||
<div style="background:#fff;border-radius:8px;padding:10px;margin:0 4px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||
<span style="font-size:14px;font-weight:600;">经营一览</span>
|
||||
<span style="font-size:9px;background:#ecf2fe;color:#0052d9;padding:2px 6px;border-radius:4px;">本月</span>
|
||||
</div>
|
||||
<div style="font-size:22px;font-weight:600;margin-bottom:4px;">¥128,560.00</div>
|
||||
<div style="font-size:11px;color:#999;">较上月 +12.5%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 18. 列表项 -->
|
||||
<div class="sec">
|
||||
<div class="sec-t">18. list items</div>
|
||||
<div style="padding:0 4px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #eee;">
|
||||
<span style="font-size:13px;">台费收入</span><span style="font-size:13px;font-weight:600;">¥85,200</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #eee;">
|
||||
<span style="font-size:13px;">商品收入</span><span style="font-size:13px;font-weight:600;">¥32,100</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;">
|
||||
<span style="font-size:13px;">助教收入</span><span style="font-size:13px;font-weight:600;">¥11,260</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 19. 全宽色条 -->
|
||||
<div class="sec" style="border-bottom:none;">
|
||||
<div class="sec-t">19. full-width bars</div>
|
||||
<div style="display:flex;height:16px;margin-right:10px;">
|
||||
<div style="flex:1;background:#0052d9;"></div>
|
||||
<div style="flex:1;background:#00a870;"></div>
|
||||
<div style="flex:1;background:#ed7b2f;"></div>
|
||||
<div style="flex:1;background:#e34d59;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
856
_DEL/H5到微信小程序迁移桥接规范_可执行版_v2.md
Normal file
856
_DEL/H5到微信小程序迁移桥接规范_可执行版_v2.md
Normal file
@@ -0,0 +1,856 @@
|
||||
# H5 原型 → 微信小程序迁移桥接规范(可执行版 v2.0)
|
||||
|
||||
> 适用对象:人工开发、AI 批量迁移、代码审查、验收回归
|
||||
> 适用范围:本项目上传的 H5 原型页面、配套 CSS/JS、静态资源
|
||||
> 目标:在微信原生小程序中实现页面与交互的高还原迁移;除字体渲染细微差异外,样式、层级、动效、状态切换、页面流转尽量 1:1 对齐 H5 验收原型
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
这不是知识笔记,而是执行规程。
|
||||
|
||||
使用本规范时,AI 或开发者必须做到:
|
||||
|
||||
1. 能从单个 H5 页面产出对应的小程序页面文件。
|
||||
2. 能明确哪些能力可直接迁移,哪些必须降级,哪些禁止直迁。
|
||||
3. 能在不使用 DOM API 的前提下,把 H5 交互改造成小程序的数据驱动实现。
|
||||
4. 能输出可交付物,而不是停留在“分析建议”。
|
||||
|
||||
本规范优先级高于旧版桥接文档。若旧版文档与本规范冲突,以本规范为准。
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目级输入与输出契约
|
||||
|
||||
### 2.1 输入物
|
||||
|
||||
单页迁移时,输入物必须至少包含:
|
||||
|
||||
- 对应 H5 页面 HTML 文件
|
||||
- 该页面引用的 CSS(页面内 `<style>`、独立 css 文件、Tailwind utility)
|
||||
- 该页面引用的 JS
|
||||
- 该页面依赖的图片 / 图标 / SVG / JSON Mock
|
||||
- 页面跳转关系和入口参数
|
||||
|
||||
### 2.2 输出物
|
||||
|
||||
每个页面迁移后,必须至少产出:
|
||||
|
||||
- `pages/<route>/<route>.wxml`
|
||||
- `pages/<route>/<route>.wxss`
|
||||
- `pages/<route>/<route>.js`
|
||||
- `pages/<route>/<route>.json`
|
||||
- 该页面使用到的 mock 数据文件或页面内 mock 常量
|
||||
- 迁移说明(至少包含:状态变量、事件、待确认项、降级项)
|
||||
|
||||
### 2.3 不允许的输出
|
||||
|
||||
以下结果视为未完成:
|
||||
|
||||
- 只给“迁移思路”,不产出页面文件
|
||||
- 只把 Tailwind class 翻译成 CSS,不改 WXML / 交互
|
||||
- 继续依赖 `document.*`、`window.*`、`history.*`、`localStorage` 等浏览器 API
|
||||
- 以“兼容性未知”为由保留 H5 写法不处理
|
||||
- 把高风险 CSS 特性原样搬过去,且不写降级方案
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目级强制决策
|
||||
|
||||
### 3.1 框架与运行时
|
||||
|
||||
1. **必须使用微信原生小程序页面体系**:WXML + WXSS + JS + JSON。
|
||||
2. **禁止继续在小程序端运行 Tailwind CDN / 浏览器脚本。**
|
||||
3. **禁止使用 DOM 驱动交互。** 页面必须改为数据驱动。
|
||||
4. **不引入 Taro / uni-app / WebView 兜底。** 本规范面向原生小程序页面迁移。
|
||||
|
||||
### 3.2 验收基准
|
||||
|
||||
1. H5 原型的主要验收基准宽度为 **412 CSS px**。
|
||||
2. 小程序以 412 宽设备的视觉效果为主验收目标。
|
||||
3. 其他常见宽度设备允许比例差异,但不得出现:文本溢出、遮挡、错层、关键按钮点击区域异常、吸顶/弹层错位。
|
||||
|
||||
### 3.3 单位策略
|
||||
|
||||
采用 **`rpx` 为主、`px` 为辅** 的混合单位方案。
|
||||
|
||||
- **优先 `rpx`**:页面宽度、横向布局、卡片尺寸、主容器 padding / margin、列表间距、栅格、吸顶区高度。
|
||||
- **优先 `px`**:1px 发丝线、阴影 blur/spread、小图标、绝对定位微调、小控件尺寸、状态栏 / 安全区补偿、细描边。
|
||||
- **按效果决定**:字号、圆角、局部 padding、某些 transform 位移。
|
||||
|
||||
换算基准:
|
||||
|
||||
```text
|
||||
rpx = H5_CSS_px × (750 / 412)
|
||||
≈ H5_CSS_px × 1.8204
|
||||
```
|
||||
|
||||
默认规则:
|
||||
|
||||
- 普通布局值:四舍五入到整数 `rpx`
|
||||
- 视觉敏感值:允许人工微调,不强行套公式
|
||||
- 不使用“统一取偶数 rpx”的硬规则
|
||||
|
||||
### 3.4 页面滚动策略
|
||||
|
||||
1. 默认使用 **页面自然滚动**。
|
||||
2. 只有在必须做局部滚动区域时,才使用 `scroll-view`。
|
||||
3. 吸顶、滚动联动、section 感知优先围绕页面滚动实现;不要无必要把整页包进 `scroll-view`。
|
||||
|
||||
### 3.5 样式组织策略
|
||||
|
||||
1. 全局 token、复用组件样式放 `app.wxss` 或公共样式文件。
|
||||
2. 页面私有样式放页面同名 `.wxss`。
|
||||
3. 样式优先使用 class,动态值用内联 style 绑定。
|
||||
4. 不以 CSS 变量作为唯一运行前提;优先输出静态色值 / 静态 token。若项目后续验证 CSS 变量稳定可用,可作为增强层,不可作为唯一实现路径。
|
||||
|
||||
### 3.6 文本与行高策略 ⚠️
|
||||
|
||||
**关键发现**:微信小程序的 `<text>` 组件不能直接设置 `line-height`,必须通过外层 `<view>` 设置。
|
||||
|
||||
**全局设置(推荐):**
|
||||
```wxss
|
||||
page {
|
||||
line-height: 1.5; /* Tailwind 默认行高 */
|
||||
}
|
||||
|
||||
view {
|
||||
line-height: inherit; /* view 继承 page 的 line-height */
|
||||
}
|
||||
|
||||
/* text 会自动继承外层 view 的 line-height,不需要额外设置 */
|
||||
```
|
||||
|
||||
**局部覆盖:**
|
||||
```wxss
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx; /* 在 view 的 class 上设置,text 会继承 */
|
||||
font-weight: 600;
|
||||
}
|
||||
```
|
||||
|
||||
**错误做法:**
|
||||
```wxss
|
||||
/* ❌ 直接在 text 上设置 line-height 无效 */
|
||||
text {
|
||||
line-height: 36rpx; /* 不会生效 */
|
||||
}
|
||||
```
|
||||
|
||||
**执行规则:**
|
||||
1. 所有文本类样式必须同时设置 `font-size` 和 `line-height`
|
||||
2. `line-height` 必须在外层 `<view>` 的 class 上设置,不能在 `<text>` 上设置
|
||||
3. 标准字号的 `line-height` 换算参考附录 B.3
|
||||
4. 若发现 Y 轴高度与 H5 端不一致,优先检查 `line-height` 是否正确设置
|
||||
|
||||
### 3.7 SVG / 图标策略
|
||||
|
||||
1. **禁止把 H5 的内联 `<svg>...</svg>` 直接当 WXML DOM 搬运。**
|
||||
2. 资源层允许 `svg` 文件进入小程序项目包,但页面显示是否稳定,必须以目标设备实测为准。
|
||||
3. 默认执行顺序:
|
||||
- 简单静态图标:优先导出为独立资源文件
|
||||
- 若 `svg` 渲染不稳定:转为 `png`
|
||||
- 高频单色图标:允许改为 iconfont / 组件化图标
|
||||
4. 动态换色的 SVG 不要临时拼接字符串;应改为多份资源、图标组件或样式控制的等价实现。
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术基线与能力分级
|
||||
|
||||
> 说明:本规范不把“经验上可能可用”的能力写成“完全支持”。
|
||||
> 所有能力分三类:A 可直接用,B 有条件使用,C 禁止直迁。
|
||||
|
||||
### 4.1 A 类:可直接使用
|
||||
|
||||
以下能力在当前项目中视为稳定迁移能力:
|
||||
|
||||
- `app.wxss` / 页面 `wxss` / `@import`
|
||||
- `rpx`
|
||||
- `.class`、`#id`、元素选择器、`::before`、`::after`
|
||||
- `flex`、`position: relative/absolute/fixed`
|
||||
- `box-shadow`、`border-radius`、`linear-gradient`
|
||||
- `opacity`、常规 `transform`
|
||||
- `transition`、`animation`
|
||||
- 动态内联样式绑定
|
||||
- `onPageScroll`
|
||||
- `wx.createAnimation()`
|
||||
- `wx.createSelectorQuery()`
|
||||
- `wx.createIntersectionObserver()`
|
||||
- `navigator` / `wx.navigateTo` / `wx.redirectTo` / `wx.switchTab` / `wx.navigateBack`
|
||||
- `view` / `navigator` 的 `hover-class`
|
||||
|
||||
### 4.2 B 类:有条件使用,必须带回退或验证
|
||||
|
||||
以下能力允许使用,但不能把它们当成项目硬依赖:
|
||||
|
||||
- `position: sticky`
|
||||
- `grid`
|
||||
- `filter: blur()`
|
||||
- `backdrop-filter`
|
||||
- `clip-path`
|
||||
- `vh`
|
||||
- `line-clamp`
|
||||
- 复杂组合选择器
|
||||
- 资源型 `svg`
|
||||
- CSS 变量
|
||||
|
||||
执行规则:
|
||||
|
||||
1. 使用前必须确认该页面已有回退方案。
|
||||
2. 若页面对视觉或交互依赖度高,则必须做真机验证。
|
||||
3. 若 B 类能力失效,不得阻塞交付,必须切换为本规范定义的降级实现。
|
||||
|
||||
### 4.3 C 类:禁止直迁
|
||||
|
||||
以下能力不得原样从 H5 搬到小程序:
|
||||
|
||||
- `document.getElementById/querySelector/querySelectorAll`
|
||||
- `classList.add/remove/toggle`
|
||||
- 直接改 `element.style.xxx`
|
||||
- `innerHTML` / `textContent` 方式渲染视图
|
||||
- `window.scrollY` / `window.scrollTo`
|
||||
- `history.back`
|
||||
- `localStorage` / `sessionStorage`
|
||||
- `alert` / `confirm`
|
||||
- CSS `env(safe-area-inset-*)`
|
||||
- `:hover` / `group-hover:*`
|
||||
- 依赖浏览器焦点模型的 `:focus` / `:active` 交互
|
||||
- 依赖 `:first-child/:last-child/:nth-child` 才能成立的视觉规则
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面转换的标准执行流程
|
||||
|
||||
每迁移 1 个页面,都必须按以下顺序执行。
|
||||
|
||||
### 步骤 1:页面资产盘点
|
||||
|
||||
输入:HTML / CSS / JS / 图片资源
|
||||
|
||||
动作:
|
||||
|
||||
- 抽取页面标题、路由名、入口参数、返回路径
|
||||
- 列出页面块:顶部、卡片区、列表区、底部操作区、弹层、浮层、Toast
|
||||
- 列出页面状态:默认态、空态、加载态、错误态、选中态、展开态、禁用态
|
||||
- 列出页面动效:淡入、位移、缩放、吸顶、滚动联动、长按、评分拖动
|
||||
|
||||
输出:页面迁移卡
|
||||
|
||||
### 步骤 2:结构改写为 WXML 语义树
|
||||
|
||||
动作:
|
||||
|
||||
- `div` → `view`
|
||||
- 文本优先用 `text`
|
||||
- 图片用 `image`
|
||||
- 链接/跳转用 `navigator` 或 `bindtap`
|
||||
- 表单元素替换为原生小程序组件
|
||||
- 列表一律改为 `wx:for`
|
||||
- 条件显示一律改为 `wx:if` / `hidden` / 条件 class
|
||||
|
||||
禁止:
|
||||
|
||||
- 保留 H5 DOM 层级只是把标签名替换一下
|
||||
- 在 `text` 内嵌套 `view`
|
||||
- 用字符串拼接 HTML 片段
|
||||
|
||||
### 步骤 3:Tailwind 与自定义 CSS 拆解
|
||||
|
||||
动作:
|
||||
|
||||
- 先按视觉功能拆成:布局、尺寸、文本、颜色、装饰、状态、动画
|
||||
- 再判断每个值使用 `rpx` 还是 `px`
|
||||
- 统一抽出公共 token,不允许每页各自发散命名
|
||||
|
||||
要求:
|
||||
|
||||
- 不保留“一长串 utility class 直接照搬”的中间态
|
||||
- 对频繁出现的组合样式抽成语义类,如 `.card`、`.section-title`、`.tab--active`
|
||||
|
||||
### 步骤 4:交互从 DOM 驱动改为数据驱动
|
||||
|
||||
动作:
|
||||
|
||||
- 把所有 DOM 查询改成状态变量
|
||||
- 把显隐、激活、展开、选中、校验、加载都改成 `data` 驱动
|
||||
- 把滚动联动改成 `onPageScroll` + 节流 + 仅在状态变化时 `setData`
|
||||
|
||||
输出:状态表
|
||||
|
||||
状态表至少包含:
|
||||
|
||||
- 状态变量名
|
||||
- 默认值
|
||||
- 可选值
|
||||
- 受哪个事件修改
|
||||
- 影响哪些视图
|
||||
|
||||
### 步骤 5:页面级动效实现
|
||||
|
||||
执行规则:
|
||||
|
||||
- 纯显隐 / 透明度 / 位移 / 简单位移动画:优先 `transition` / `animation`
|
||||
- 需要顺序编排、组合变换、受事件精确驱动:用 `wx.createAnimation()`
|
||||
- 高频交互不要每帧 `setData`
|
||||
|
||||
### 步骤 6:导航与参数改造
|
||||
|
||||
动作:
|
||||
|
||||
- 普通页面:`wx.navigateTo`
|
||||
- 替换当前页:`wx.redirectTo`
|
||||
- tabBar 页面:`wx.switchTab`
|
||||
- 返回:`wx.navigateBack`
|
||||
- 页面参数:统一在 `onLoad(query)` 中读取
|
||||
|
||||
### 步骤 7:安全区与导航栏补偿
|
||||
|
||||
动作:
|
||||
|
||||
- 禁止使用 `env(safe-area-inset-*)`
|
||||
- 通过系统信息计算状态栏与底部安全区
|
||||
- 自定义导航栏页面必须统一实现,不得一页一套
|
||||
|
||||
### 步骤 8:Mock 数据接入
|
||||
|
||||
动作:
|
||||
|
||||
- 页面先用 mock 跑通
|
||||
- 模板静态文案与后端数据字段分开
|
||||
- 所有列表、徽标、状态标签都要有 mock
|
||||
|
||||
### 步骤 9:真机回归与像素微调
|
||||
|
||||
动作:
|
||||
|
||||
- 先看 412 宽主验收设备
|
||||
- 再看至少一台 375 宽设备
|
||||
- 出现不对齐时优先微调局部 `px/rpx`,不要推翻整体单位策略
|
||||
|
||||
### 步骤 10:记录迁移说明
|
||||
|
||||
每页必须输出迁移说明,至少包含:
|
||||
|
||||
- 页面依赖资源
|
||||
- 迁移状态变量
|
||||
- 高风险点
|
||||
- 已做降级项
|
||||
- 待后端对接字段
|
||||
- 待真机复核项
|
||||
|
||||
---
|
||||
|
||||
## 6. Tailwind → WXSS 的执行规则
|
||||
|
||||
## 6.1 间距与尺寸
|
||||
|
||||
执行原则:
|
||||
|
||||
1. 标准 spacing 先换算为 H5 实际 px,再决定 `rpx` / `px`。
|
||||
2. 主容器、卡片、列表间距优先 `rpx`。
|
||||
3. 图标、徽标、定位补偿优先 `px`。
|
||||
4. arbitrary 值不能偷懒统一换 `rpx`,必须逐项判断。
|
||||
|
||||
### 建议 token(412 基准)
|
||||
|
||||
| H5 px | 建议值 |
|
||||
|---|---|
|
||||
| 4px | 8rpx |
|
||||
| 8px | 14rpx |
|
||||
| 12px | 22rpx |
|
||||
| 16px | 29~30rpx |
|
||||
| 20px | 36rpx |
|
||||
| 24px | 44rpx |
|
||||
| 32px | 58rpx |
|
||||
| 40px | 72rpx |
|
||||
|
||||
说明:
|
||||
|
||||
- 16px 允许落在 29rpx 或 30rpx,以真机比对为准。
|
||||
- 阴影参数不要强制 `rpx` 化;优先保留 `px` 质感。
|
||||
|
||||
## 6.2 文本
|
||||
|
||||
### 标准字号类
|
||||
|
||||
对 Tailwind 标准字号类,迁移时应同时输出 `font-size` 与 `line-height`。
|
||||
|
||||
### arbitrary 字号
|
||||
|
||||
规则:
|
||||
|
||||
1. `text-[Npx]` 不能直接假设其 line-height = 1.2 倍字号。
|
||||
2. 若原型页面显式设置了 `leading-*`,以显式值为准。
|
||||
3. 若未显式设置,则应从原型的实际计算样式、截图观感或上下文排版需求确定行高。
|
||||
4. 对单行标签/徽章文字,可使用更紧的 line-height;对正文、说明文必须按真实视觉高度定。
|
||||
|
||||
禁止:
|
||||
|
||||
- 在全项目层面硬编码“所有 arbitrary 字号默认 1.2 倍行高”
|
||||
|
||||
## 6.3 颜色
|
||||
|
||||
执行原则:
|
||||
|
||||
1. 颜色优先输出静态十六进制 / `rgba()`。
|
||||
2. Tailwind 透明度修饰符转成 `rgba()`。
|
||||
3. 若项目后续统一做 token 化,可再抽公共常量;首轮迁移不要把颜色系统设计复杂化。
|
||||
|
||||
## 6.4 布局
|
||||
|
||||
### Flex
|
||||
|
||||
默认首选。能用 flex 做出来的布局,不优先上 grid。
|
||||
|
||||
### Grid
|
||||
|
||||
仅在以下条件同时满足时允许:
|
||||
|
||||
- 页面是规则网格
|
||||
- 列数固定
|
||||
- 没有复杂跨列
|
||||
- 真机验证通过
|
||||
|
||||
否则改用 flex + wrap + 固定宽度 / 百分比。
|
||||
|
||||
## 6.5 组合类
|
||||
|
||||
以下 Tailwind 组合迁移时不要机械翻译:
|
||||
|
||||
- `space-y-*`
|
||||
- `divide-y`
|
||||
- `group-hover:*`
|
||||
- `peer-*`
|
||||
- `hover:*`
|
||||
- `focus:*`
|
||||
- `active:*`
|
||||
|
||||
迁移规则:
|
||||
|
||||
- `space-y-*`:优先给循环项单独 class;选择器方案仅作受控场景补充
|
||||
- `divide-y`:优先对列表项显式加边线 class,并根据 index 控首项
|
||||
- `hover:*`:改 `hover-class` 或按压反馈
|
||||
- `focus:*`:用 `bindfocus/bindblur` + 状态 class
|
||||
|
||||
---
|
||||
|
||||
## 7. HTML / 事件 / API 的标准映射
|
||||
|
||||
## 7.1 标签映射
|
||||
|
||||
| H5 | 小程序 |
|
||||
|---|---|
|
||||
| `div` | `view` |
|
||||
| `span` | `text` |
|
||||
| `p` | `view` / `text` |
|
||||
| `img` | `image` |
|
||||
| `a` | `navigator` / `view bindtap` |
|
||||
| `button` | `button` / `view bindtap` |
|
||||
| `input` | `input` |
|
||||
| `textarea` | `textarea` |
|
||||
| `ul/ol/li` | `view + wx:for` |
|
||||
| `select` | `picker` |
|
||||
|
||||
补充规则:
|
||||
|
||||
- `image` 的 `mode` 必须按原图展示意图设置,不允许默认留空就交付。
|
||||
- `object-fit: cover` 常映射为 `aspectFill`;但若原图不能被裁切,则应改为 `aspectFit`。
|
||||
|
||||
## 7.2 事件映射
|
||||
|
||||
| H5 | 小程序 |
|
||||
|---|---|
|
||||
| `onclick` | `bindtap` |
|
||||
| `oninput` | `bindinput` |
|
||||
| `onchange` | `bindchange` |
|
||||
| `onscroll` | `bindscroll` / `onPageScroll` |
|
||||
| `ontouchstart` | `bindtouchstart` |
|
||||
| `ontouchmove` | `bindtouchmove` |
|
||||
| `ontouchend` | `bindtouchend` |
|
||||
| `oncontextmenu` | `bindlongpress` |
|
||||
|
||||
注意:这只是常见替换,不代表浏览器事件语义与小程序事件语义完全等价。
|
||||
|
||||
## 7.3 冒泡与阻断
|
||||
|
||||
- `bind*`:正常冒泡
|
||||
- `catch*`:阻止事件向上冒泡
|
||||
|
||||
弹层、蒙层、内部内容区必须明确设计谁冒泡、谁拦截,不能边做边猜。
|
||||
|
||||
## 7.4 常见浏览器 API 替代
|
||||
|
||||
| H5 | 小程序 |
|
||||
|---|---|
|
||||
| `history.back()` | `wx.navigateBack()` |
|
||||
| `window.location.href` | `wx.navigateTo()` |
|
||||
| `window.location.replace` | `wx.redirectTo()` |
|
||||
| tab 页跳转 | `wx.switchTab()` |
|
||||
| `window.scrollY` | `onPageScroll().scrollTop` |
|
||||
| `navigator.clipboard.writeText` | `wx.setClipboardData()` |
|
||||
| `localStorage` | `wx.setStorageSync/getStorageSync` |
|
||||
| `alert/confirm` | `wx.showToast/wx.showModal` |
|
||||
|
||||
---
|
||||
|
||||
## 8. JS 交互模式的标准改写
|
||||
|
||||
## 8.1 显隐切换
|
||||
|
||||
H5 的 `classList.add/remove('hidden')` 一律改为:
|
||||
|
||||
- `wx:if`
|
||||
- 或 `hidden`
|
||||
- 或状态 class
|
||||
|
||||
默认建议:
|
||||
|
||||
- 大块弹层:`wx:if`
|
||||
- 仅做过渡且结构稳定:状态 class + transition
|
||||
|
||||
## 8.2 Tab / 选中态
|
||||
|
||||
统一改为单一状态变量:
|
||||
|
||||
```js
|
||||
Page({
|
||||
data: { activeTab: 'basic' },
|
||||
onTabChange(e) {
|
||||
this.setData({ activeTab: e.currentTarget.dataset.tab })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
禁止:
|
||||
|
||||
- 同时维护多个布尔值表示同一个 tab 组选中态
|
||||
|
||||
## 8.3 展开 / 收起
|
||||
|
||||
统一改为对象映射或数组项状态,不允许操作 DOM 文本改按钮文案。
|
||||
|
||||
## 8.4 滚动联动
|
||||
|
||||
执行规则:
|
||||
|
||||
1. `onPageScroll` 只在确实需要时声明。
|
||||
2. 必须节流或去抖。
|
||||
3. 只在状态变化时 `setData`。
|
||||
4. 吸顶标题、目录高亮、section 感知,优先用 `IntersectionObserver`。
|
||||
|
||||
## 8.5 长按菜单
|
||||
|
||||
原型中的“卡片旁锚点弹出菜单”在小程序中优先改为:
|
||||
|
||||
- 底部 action sheet
|
||||
- 或固定底部操作面板
|
||||
|
||||
原因:
|
||||
|
||||
- 更稳定
|
||||
- 更易复用
|
||||
- 更符合触屏交互
|
||||
|
||||
## 8.6 评分拖动 / 拖拽类交互
|
||||
|
||||
规则:
|
||||
|
||||
1. 触摸起始时缓存容器 rect。
|
||||
2. `touchmove` 中仅基于缓存坐标计算,不每次重新 query。
|
||||
3. 评分变化只在值变化时更新。
|
||||
|
||||
## 8.7 Toast / 短提示
|
||||
|
||||
优先级:
|
||||
|
||||
1. 系统 `wx.showToast`
|
||||
2. 若原型要求强定制视觉,再做自定义 toast 组件
|
||||
|
||||
## 8.8 动画
|
||||
|
||||
优先级:
|
||||
|
||||
1. WXSS transition / animation
|
||||
2. `wx.createAnimation()`
|
||||
3. 避免高频 `setData` 驱动逐帧动画
|
||||
|
||||
---
|
||||
|
||||
## 9. 高风险能力的强制降级规则
|
||||
|
||||
## 9.1 `sticky`
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 页面自然滚动场景:先尝试 `position: sticky`
|
||||
2. 若在 `scroll-view` 中:不要硬上 sticky,改为滚动监听/观察者驱动
|
||||
3. 多层 sticky:只保留最外层 sticky
|
||||
|
||||
## 9.2 `backdrop-filter`
|
||||
|
||||
规则:
|
||||
|
||||
- 不作为交付必需能力
|
||||
- 默认必须提供半透明背景降级
|
||||
- 若失效,不得阻塞上线
|
||||
|
||||
## 9.3 `vh`
|
||||
|
||||
规则:
|
||||
|
||||
- 不把 `100vh` 当成万能全屏高度
|
||||
- 涉及自定义导航栏、安全区、底部操作栏的页面,必须计算可用高度
|
||||
- 能用 `min-height` + 内边距解决的,不强依赖 `vh`
|
||||
|
||||
## 9.4 复杂选择器
|
||||
|
||||
规则:
|
||||
|
||||
- WXSS 公开支持的基础选择器以 `.class`、`#id`、元素、`::before/::after` 为准
|
||||
- 组合选择器只在局部受控场景下使用
|
||||
- 不把复杂结构选择器写成项目标准手段
|
||||
|
||||
## 9.5 CSS 变量
|
||||
|
||||
规则:
|
||||
|
||||
- 首轮迁移不强制依赖 `var()`
|
||||
- 颜色、渐变、阴影优先输出静态值
|
||||
- 若后续项目验证 CSS 变量稳定可用,可作为增强重构项,不是首轮交付阻塞项
|
||||
|
||||
---
|
||||
|
||||
## 10. 模板内容与数据内容的边界
|
||||
|
||||
每个页面都要把内容拆成三类:
|
||||
|
||||
### 10.1 模板固化内容
|
||||
|
||||
这些通常写死在页面模板或常量里:
|
||||
|
||||
- 页面标题
|
||||
- 分区标题
|
||||
- 固定按钮文案
|
||||
- 空态文案
|
||||
- Tooltip 固定说明
|
||||
- 表头 / 标签名
|
||||
|
||||
### 10.2 Mock / 后端数据内容
|
||||
|
||||
这些必须抽成数据:
|
||||
|
||||
- 卡片列表
|
||||
- 客户信息
|
||||
- 任务信息
|
||||
- 看板统计值
|
||||
- 徽标状态
|
||||
- 提示数量
|
||||
- 聊天记录
|
||||
- 备注内容
|
||||
- 评分值
|
||||
- 时间轴 / 历史记录
|
||||
|
||||
### 10.3 前端派生内容
|
||||
|
||||
这些应由前端根据后端字段计算:
|
||||
|
||||
- 状态色 class
|
||||
- 选中态 class
|
||||
- 进度条宽度
|
||||
- 文案格式化
|
||||
- 百分比显示
|
||||
- 数字千分位
|
||||
- 是否显示空态 / 加载态 / 错误态
|
||||
|
||||
---
|
||||
|
||||
## 11. 页面目录与公共层建议结构
|
||||
|
||||
```text
|
||||
app.js
|
||||
app.json
|
||||
app.wxss
|
||||
|
||||
/common
|
||||
/styles
|
||||
tokens.wxss
|
||||
mixins.wxss
|
||||
animation.wxss
|
||||
/utils
|
||||
format.js
|
||||
system.js
|
||||
nav.js
|
||||
/mock
|
||||
xxx.js
|
||||
/components
|
||||
app-navbar/
|
||||
app-tab/
|
||||
app-card/
|
||||
app-empty/
|
||||
app-toast/
|
||||
app-action-sheet/
|
||||
|
||||
/pages
|
||||
/task-list
|
||||
/task-detail
|
||||
/board-finance
|
||||
/board-customer
|
||||
/board-coach
|
||||
/notes
|
||||
/chat
|
||||
...
|
||||
|
||||
/assets
|
||||
/images
|
||||
/icons
|
||||
```
|
||||
|
||||
执行要求:
|
||||
|
||||
- 可复用的组件必须抽离
|
||||
- 不允许把每页都做成孤岛
|
||||
- 但也禁止首轮过度抽象,影响交付速度
|
||||
|
||||
---
|
||||
|
||||
## 12. 每页迁移完成的验收清单
|
||||
|
||||
以下清单必须全部勾过,页面才算完成。
|
||||
|
||||
### 12.1 结构
|
||||
|
||||
- [ ] 页面可正常进入
|
||||
- [ ] 路由与参数正确
|
||||
- [ ] 结构层级与原型一致
|
||||
- [ ] 图片、图标、背景都已替换到位
|
||||
|
||||
### 12.2 样式
|
||||
|
||||
- [ ] 主布局宽度、边距、卡片尺寸与原型接近
|
||||
- [ ] 文本字号、行高、字重合理
|
||||
- [ ] 圆角、边框、阴影、渐变已还原
|
||||
- [ ] 吸顶、弹层、底栏无错位
|
||||
- [ ] 412 宽设备通过视觉验收
|
||||
|
||||
### 12.3 交互
|
||||
|
||||
- [ ] 点击反馈存在
|
||||
- [ ] tab 切换正确
|
||||
- [ ] 弹层开关正确
|
||||
- [ ] 长按 / 复制 / 展开 / 收起 / 评分等交互正确
|
||||
- [ ] 返回和跳转链路正确
|
||||
|
||||
### 12.4 性能
|
||||
|
||||
- [ ] 滚动时无明显抖动
|
||||
- [ ] 没有在高频事件中无脑 `setData`
|
||||
- [ ] 动画不依赖逐帧 JS
|
||||
|
||||
### 12.5 风险项
|
||||
|
||||
- [ ] 所有降级项已记录
|
||||
- [ ] 所有待真机复核项已记录
|
||||
- [ ] 所有待接口对接字段已记录
|
||||
|
||||
---
|
||||
|
||||
## 13. AI 执行模板(标准提示词契约)
|
||||
|
||||
以下模板用于驱动 AI 按页迁移。
|
||||
|
||||
```md
|
||||
你现在负责把指定 H5 页面迁移为微信原生小程序页面。
|
||||
|
||||
必须遵守以下约束:
|
||||
1. 输出原生小程序文件:wxml / wxss / js / json。
|
||||
2. 禁止使用任何 DOM API、window、history、localStorage。
|
||||
3. 禁止继续依赖 Tailwind 运行时;需将 Tailwind 与页面自定义样式改写为可维护的 WXSS。
|
||||
4. 视觉目标是最大程度还原 H5;默认以 412 宽设备为验收基准。
|
||||
5. 单位策略为 rpx 为主、px 为辅:布局主尺寸优先 rpx,发丝线/小图标/阴影/绝对定位微调优先 px。
|
||||
6. 所有交互必须改为数据驱动。
|
||||
7. 对 sticky / backdrop-filter / svg / vh / grid 等高风险能力,必须给出降级或验证说明。
|
||||
8. 每输出一个页面,必须附带:
|
||||
- 页面状态变量表
|
||||
- 事件处理函数表
|
||||
- mock 数据结构
|
||||
- 未决问题与降级项
|
||||
|
||||
请按以下顺序输出:
|
||||
A. 页面迁移摘要
|
||||
B. 目录结构
|
||||
C. WXML
|
||||
D. WXSS
|
||||
E. JS
|
||||
F. JSON
|
||||
G. mock 数据
|
||||
H. 迁移说明与风险项
|
||||
```
|
||||
|
||||
### 13.1 AI 页面状态表模板
|
||||
|
||||
```md
|
||||
| 状态变量 | 类型 | 默认值 | 可选值 | 影响视图 | 触发事件 |
|
||||
|---|---|---:|---|---|---|
|
||||
| showFilter | boolean | false | true/false | 筛选面板 | onToggleFilter |
|
||||
| activeTab | string | overview | overview/detail/... | tab内容区 | onTabChange |
|
||||
```
|
||||
|
||||
### 13.2 AI 风险项模板
|
||||
|
||||
```md
|
||||
- 已降级:backdrop-filter 改为半透明背景
|
||||
- 待验证:顶部 sticky 在真机 Android 上需复核
|
||||
- 待后端:客户列表、统计卡片、备注列表当前仍为 mock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 本项目对旧版桥接文档的修订结论
|
||||
|
||||
旧版文档可继续参考,但以下内容不再作为硬规范:
|
||||
|
||||
1. 不再把大量现代 CSS 特性写成“完全支持”。
|
||||
2. 不再把 arbitrary 字号 line-height 的经验值当成定理。
|
||||
3. 不再把 CSS 变量当成首轮必选依赖。
|
||||
4. 不再把 `.parent > view + view` 这类技巧写成唯一实现方案。
|
||||
5. 不再把 `<image src="xxx.svg">` 写成无条件可靠方案。
|
||||
6. 不再允许“先翻样式,交互以后再说”的半迁移状态作为交付完成。
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考文档(执行时必须优先查官方)
|
||||
|
||||
微信小程序官方 / 腾讯云镜像:
|
||||
|
||||
- WXSS 样式:<https://www.tencentcloud.com/zh/document/product/1219/60346>
|
||||
- 页面与 `onPageScroll`:<https://www.tencentcloud.com/zh/document/product/1219/61713>
|
||||
- 渲染层 / 事件模型:<https://www.tencentcloud.com/zh/document/product/1219/61744>
|
||||
- `createSelectorQuery` / `createIntersectionObserver`:<https://www.tencentcloud.com/document/product/1219/57688>
|
||||
- 动画 `wx.createAnimation`:<https://www.tencentcloud.com/zh/document/product/1219/57764>
|
||||
- 视图容器 `view` / `hover-class`:<https://www.tencentcloud.com/zh/document/product/1219/57773>
|
||||
- 导航组件 `navigator`:<https://www.tencentcloud.com/zh/document/product/1219/57779>
|
||||
- 小程序目录结构与可上传文件类型:<https://www.tencentcloud.com/zh/document/product/1219/57660>
|
||||
|
||||
Tailwind 官方:
|
||||
|
||||
- Font Size:<https://v3.tailwindcss.com/docs/font-size>
|
||||
- Line Height:<https://v3.tailwindcss.com/docs/line-height>
|
||||
- Height:<https://v3.tailwindcss.com/docs/height>
|
||||
|
||||
---
|
||||
|
||||
## 16. 最终执行口径
|
||||
|
||||
**本项目的小程序迁移,不是“把 Tailwind 翻译成 WXSS”这么简单。**
|
||||
|
||||
真正的执行标准是:
|
||||
|
||||
1. 页面结构重写为 WXML;
|
||||
2. 样式收敛为可维护的 WXSS;
|
||||
3. 交互全部改为小程序数据驱动;
|
||||
4. 对高风险 CSS 特性提供可交付降级;
|
||||
5. 每页都有 mock、状态表、事件表、风险说明和验收清单。
|
||||
|
||||
能做到这 5 条,才算“可执行版桥接规范”。
|
||||
BIN
_DEL/MP.jpg
Normal file
BIN
_DEL/MP.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
_DEL/WEB.jpg
Normal file
BIN
_DEL/WEB.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
1568
_DEL/h5-to-mp-bridge.md
Normal file
1568
_DEL/h5-to-mp-bridge.md
Normal file
File diff suppressed because it is too large
Load Diff
37
_DEL/miniprogram-dev/03-reference/benchmark-history.md
Normal file
37
_DEL/miniprogram-dev/03-reference/benchmark-history.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 基准测试历史记录
|
||||
|
||||
> 本文件记录 H5→MP 迁移项目的 Benchmark 基准测试历史,仅供参考。
|
||||
> 完整推导过程见 `docs/miniprogram-dev/03-reference/benchmark-history.md`(本文件即为迁移后的版本,原 `docs/h5_ui/compare/HISTORY.md` 已归档)。
|
||||
|
||||
---
|
||||
|
||||
## 当前有效参数(v3)
|
||||
|
||||
| 参数 | H5 | MP | 说明 |
|
||||
|------|-----|-----|------|
|
||||
| viewport | 430×752 | 430×752 | 统一视口 |
|
||||
| DPR | 1.5 | 1.5 | 双端一致 |
|
||||
| 输出尺寸 | 645×1128 | 645×1128 | 零缩放 |
|
||||
| rpx 系数 | — | 1:1.75 | `rpx = px × 2 × 0.875` |
|
||||
|
||||
## v3 基准测试结果(2026-03-11)
|
||||
|
||||
| 组 | 差异率 | 说明 |
|
||||
|---|---|---|
|
||||
| g1(裁剪后) | 3.87% | 全部来自不可消除的系统性因素 |
|
||||
|
||||
差异来源:字体渲染 ~2% + 行内元素高度 ~1% + rpx 取偶 ~0.5% + 抗锯齿 ~0.5%
|
||||
|
||||
## 版本演进
|
||||
|
||||
| 版本 | 日期 | rpx 系数 | 状态 |
|
||||
|------|------|---------|------|
|
||||
| v2 | 2026-03-10 | 1:1.82(750/412) | ❌ 废弃(视口不一致、有损缩放、DPR 不对等) |
|
||||
| v3 | 2026-03-11 | 1:1.75(750/430 工程近似) | ✅ 当前有效 |
|
||||
|
||||
## 结论
|
||||
|
||||
1. `rpx = px × 2 × 0.875`(1:1.75)是正确的迁移换算公式
|
||||
2. 双端截图参数必须统一(viewport=430×752, DPR=1.5)
|
||||
3. 像素对比的背景噪音基线约 3.87%
|
||||
4. <5% 通过目标在基线之上仅有 ~1% 修正空间,公式和参数已高度准确
|
||||
53
_DEL/miniprogram-dev/03-reference/page-structure-map.md
Normal file
53
_DEL/miniprogram-dev/03-reference/page-structure-map.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 各页面特殊结构速查表
|
||||
|
||||
> 基于 H5 源码扫描得出。处理对应页面时直接查表,不需要重新扫描。
|
||||
|
||||
---
|
||||
|
||||
| 页面 | safe-area-top | bottomNav | ai-float | position:fixed | ::before/::after | 其他风险 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| board-finance | ✅ | ✅ | ✅ | ✅(筛选下拉) | ✅(tab 指示线) | `backdrop-filter: blur()` |
|
||||
| board-coach | ✅ | ✅ | ✅ | ✅(筛选下拉) | ✅(tab 指示线) | — |
|
||||
| board-customer | ✅ | ✅ | ✅ | ✅(筛选下拉) | ✅(tab 指示线) | CSS 渐变文字 |
|
||||
| task-list | — | ✅ | ✅ | ✅(筛选下拉) | — | — |
|
||||
| task-detail | — | — | — | — | ✅(气泡尖角) | — |
|
||||
| task-detail-callback | — | — | — | — | — | — |
|
||||
| task-detail-priority | — | — | — | — | — | — |
|
||||
| task-detail-relationship | — | — | — | — | — | — |
|
||||
| my-profile | ✅ | ✅ | — | — | — | — |
|
||||
| coach-detail | — | — | — | — | — | — |
|
||||
| customer-detail | — | — | — | — | ✅ | — |
|
||||
| customer-service-records | — | — | ✅ | — | — | — |
|
||||
| performance | — | — | ✅ | — | — | — |
|
||||
| performance-records | — | — | ✅ | — | — | — |
|
||||
| notes | ✅ | — | — | — | — | — |
|
||||
| chat | ✅ | — | — | — | — | — |
|
||||
| chat-history | ✅ | — | — | — | — | — |
|
||||
| reviewing | — | — | — | — | — | `data:image/svg` |
|
||||
| no-permission | — | — | — | — | — | `data:image/svg` |
|
||||
|
||||
✅ = 存在该结构,需按规则处理;— = 不存在。
|
||||
|
||||
---
|
||||
|
||||
## 处理规则速查
|
||||
|
||||
| 结构 | MP 处理方式 |
|
||||
|------|-----------|
|
||||
| `.safe-area-top` | 去除 `padding-top: env(safe-area-inset-top, 44px)`,MP 由 navigationBar 处理 |
|
||||
| `#bottomNav` | 不迁移,MP 用原生 tabBar;H5 截图时隐藏 |
|
||||
| `.ai-float-btn-container` | 双端截图前隐藏 |
|
||||
| `<dev-fab />` | MP 截图前 `wx:if="{{false}}"` |
|
||||
| `.filter-overlay` | 优先用组件遮罩层 |
|
||||
| `.tab-active::after` | 额外 `<view>` 模拟 |
|
||||
| `.speech-bubble::after` | 绝对定位 `<view>` + `transform: rotate(45deg)` |
|
||||
| `data:image/svg+xml` | 导出 PNG/base64 或用 CSS 渐变模拟 |
|
||||
|
||||
---
|
||||
|
||||
## 页面导航栏模式
|
||||
|
||||
| 模式 | 页面 |
|
||||
|------|------|
|
||||
| A:系统默认 navBar | board-finance, board-coach, board-customer, task-list, my-profile |
|
||||
| B:自定义 navBar | task-detail 系列, coach-detail, customer-detail, performance, notes, chat, chat-history, customer-service-records, performance-records |
|
||||
41
_DEL/miniprogram-dev/04-audit/CHANGELOG.md
Normal file
41
_DEL/miniprogram-dev/04-audit/CHANGELOG.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 版本变更记录
|
||||
|
||||
> 记录文档体系的版本演进。旧版本变更见下方「历史版本」章节(原 `docs/h5_ui/compare/CHANGELOG.md` 已归档)。
|
||||
|
||||
---
|
||||
|
||||
## 文档体系 v5.0(2026-03-12)
|
||||
|
||||
从 `docs/h5_ui/compare/` 单目录体系重构为 `docs/miniprogram-dev/` 五层文档体系:
|
||||
|
||||
| 变更 | 说明 |
|
||||
|------|------|
|
||||
| 拆分 AGENT-PLAYBOOK.md | → 02-action/ 下 5 个专职代理手册 |
|
||||
| 拆分 ORCHESTRATION-PLAN.md | → 01-orchestration/ 下 3 种工作模式 |
|
||||
| 新增半自动模式 | user-guided-playbook.md |
|
||||
| 新增新页面开发模式 | new-page-playbook.md + page-dev-agent.md |
|
||||
| 提取参考层 | 03-reference/ 下 5 个规范文档 |
|
||||
| 新增经验层 | 05-lessons/ 下 2 个经验文档 |
|
||||
| 旧文档归档 | docs/h5_ui/compare/ 下原文件归档到 _archived/ |
|
||||
|
||||
---
|
||||
|
||||
## 历史版本(AGENT-PLAYBOOK)
|
||||
|
||||
| 版本 | 日期 | 变更摘要 |
|
||||
|---|---|---|
|
||||
| v4.2 | 2026-03-11 | 间距测量子代理;通用工具 measure_gaps.py |
|
||||
| v4.1 | 2026-03-11 | 4 种专职代理;复杂结构专项识别 |
|
||||
| v4.0 | 2026-03-11 | 截图参数重写;最大重试 5 次;全流程自动化 |
|
||||
| v3.0 | 2026-03-10 | H5 viewport 430px;DPR 1.5;rpx 1:1.75 |
|
||||
| v2.0 | 2026-03-10 | Benchmark 分析(1:1.82,已废弃) |
|
||||
| v1.0 | 2026-02 | 初始版 |
|
||||
|
||||
## 历史版本(ORCHESTRATION-PLAN)
|
||||
|
||||
| 版本 | 日期 | 变更摘要 |
|
||||
|---|---|---|
|
||||
| v2.0 | 2026-03-11 | 4 种专职子代理执行模型 |
|
||||
| v1.7 | 2026-03-10 | board-finance 2 维度×10 屏 |
|
||||
| v1.5 | 2026-03-09 | board-customer 扩展至 8 维度 |
|
||||
| v1.0 | 2026-03-09 | 初始版 59 单元 |
|
||||
243
_DEL/miniprogram-dev/04-audit/PROGRESS.md
Normal file
243
_DEL/miniprogram-dev/04-audit/PROGRESS.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# H5 → 微信小程序视觉还原 — 进度跟踪
|
||||
|
||||
> **主代理必读**:每次会话开始时先读本文件,确认当前状态后再下发任务。
|
||||
> 每完成一个处理单元后立即更新本文件。
|
||||
>
|
||||
> 文档体系入口:[docs/miniprogram-dev/README.md](../README.md)
|
||||
> 批量自动模式:Power `miniprogram-h5-conversion` → `readSteering("batch-auto.md")`
|
||||
|
||||
---
|
||||
|
||||
## 当前状态(会话开始时填写)
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| **当前处理单元** | #54 coach-detail/step-0(D 批次开始) |
|
||||
| **下一个单元** | #54 coach-detail/step-0 |
|
||||
| **本次会话目标** | D 批次:coach-detail + customer-detail + customer-service-records(12 单元) |
|
||||
| **MCP 状态** | ✅ 已连接(wsEndpoint, healthy) |
|
||||
| **最后更新** | 2026-03-12 |
|
||||
| **决策** | 跳过 margin 修正,接受 default 维度现有结果,推进 compare 维度 |
|
||||
| **MP compare scrollHeight** | board-content: 5030px, maxScroll: 4396px |
|
||||
|
||||
### MCP 就绪检查清单(每次会话开始时执行)
|
||||
|
||||
```
|
||||
[ ] mcp_weixin_devtools_mcp_get_connection_status → 已连接
|
||||
[ ] Playwright MCP → 可用(测试 browser_run_code)
|
||||
[ ] 微信开发者工具已开启并显示目标页面
|
||||
[ ] pixel-audit Power → 已激活(readSteering("measure.md") 获取审计方法论)
|
||||
```
|
||||
|
||||
> 注:image-compare MCP 已移除(2026-03-12)。审计改为结构化拆解→逐级测量→偏差审计,详见 Power `miniprogram-h5-conversion` → `readSteering("audit.md")`。
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 指标 | 値 |
|
||||
|------|-----|
|
||||
| 总单元数 | 89 |
|
||||
| 已完成 | 0 |
|
||||
| 跳过 | 0 |
|
||||
| 进行中 | 0 |
|
||||
| 未开始 | 89 |
|
||||
| 整体进度 | 0% |
|
||||
|
||||
---
|
||||
|
||||
## 前置任务
|
||||
|
||||
| # | 任务 | 状态 | 完成日期 | 备注 |
|
||||
|---|------|------|----------|------|
|
||||
| P0 | TS 零诊断基线检查 | ✅ 完成 | 2026-03-10 | 17 页面全部通过 |
|
||||
| P1 | 跨页面共性偏差批量修复 | ✅ 完成 | 2026-03-10 | board-finance/coach/customer 三页 |
|
||||
| P2 | 截图技术验证 | ✅ 完成 | 2026-03-10 | DPR=1.5 双端 645×1128 已验证 |
|
||||
| P3 | AGENT-PLAYBOOK.md v4.2 更新 | ✅ 完成 | 2026-03-11 | 4种专职子代理、间距测量代理、裁剪修正 |
|
||||
|
||||
## A 批次:board-finance/default(10 单元)
|
||||
|
||||
> H5 scrollHeight=5600,maxScroll=4848,10 步
|
||||
> 序列:0, 600, 1200, 1800, 2400, 3000, 3600, 4200, 4800, 4848
|
||||
|
||||
| # | 单元 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|-----------|----------|-----------|------|------|
|
||||
| 1 | default/step-0 | 6.18% | 3(R2回滚) | 6.18% | ✅ 通过 | 剩余为不可消除差异 |
|
||||
| 2 | default/step-600 | 9.02% | 2(均回滚) | 9.02% | ✅ 通过 | 含12px滚动偏移+字体渲染 |
|
||||
| 3 | default/step-1200 | 11.07% | 1(回滚) | 11.07% | ✅ 通过 | 含卡片间距差异(step-0元素)+字体渲染 |
|
||||
| 4 | default/step-1800 | 4.90% | 0 | 4.90% | ✅ 通过 | 直接通过,无需修正 |
|
||||
| 5 | default/step-2400 | 17.86% | 2 | 15.84% | ⚠️ 跳过 | 前序板块累积高度差异~103px,mock数据已修正 |
|
||||
| 6 | default/step-3000 | 14.72% | 0 | 14.72% | ⚠️ 跳过 | 累积高度偏移~153px+TOC浮层,mock数据一致 |
|
||||
| 7 | default/step-3600 | 14.88% | 0 | 14.88% | ⚠️ 跳过 | 累积高度偏移153-260px+TOC浮层,mock数据一致 |
|
||||
| 8 | default/step-4200 | 3.28% | 0 | 3.28% | ✅ 通过 | 页面底部,两端均接近maxScroll |
|
||||
| 9 | default/step-4800 | 3.31% | 0 | 3.31% | ✅ 通过 | H5 clamp到4203,MP clamp到3873 |
|
||||
| 10 | default/step-4848 | 3.31% | 0 | 3.31% | ✅ 通过 | 与step-4800像素级一致 |
|
||||
|
||||
## A 批次:board-finance/compare(10 单元)
|
||||
|
||||
> 环比开启后页面高度可能变化,scrollTop 序列需实测确认
|
||||
|
||||
| # | 单元 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|-----------|----------|-----------|------|------|
|
||||
| 11 | compare/step-0 | 6.14% | 0 | 6.14% | ✅ 通过 | 剩余为不可消除差异,与default维度一致 |
|
||||
| 12 | compare/step-600 | 10.06% | 0 | 10.06% | ✅ 通过 | 与default 9.02%接近,+1.04%来自环比元素 |
|
||||
| 13 | compare/step-1200 | 11.06% | 0 | 11.06% | ✅ 通过 | 与default 11.07%几乎一致 |
|
||||
| 14 | compare/step-1800 | 4.39% | 0 | 4.39% | ✅ 通过 | 与default 4.90%接近,直接通过 |
|
||||
| 15 | compare/step-2400 | 10.78% | 0 | 10.78% | ⚠️ 跳过 | 累积高度偏移,与default维度同因 |
|
||||
| 16 | compare/step-3000 | 16.32% | 0 | 16.32% | ⚠️ 跳过 | 累积高度偏移,与default维度同因 |
|
||||
| 17 | compare/step-3600 | 7.47% | 0 | 7.47% | ⚠️ 跳过 | 累积高度偏移+MP scrollTop被clamp |
|
||||
| 18 | compare/step-4200 | 9.34% | 0 | 9.34% | ⚠️ 跳过 | MP maxScroll clamp,两端内容窗口偏移327px |
|
||||
| 19 | compare/step-4800 | 3.41% | 0 | 3.41% | ✅ 通过 | 页面底部,两端均clamp到maxScroll |
|
||||
| 20 | compare/step-4827 | 3.33% | 0 | 3.33% | ✅ 通过 | 与step-4800像素级一致 |
|
||||
|
||||
## A 批次:board-coach(4 单元,单屏×4 维度)
|
||||
|
||||
| # | 单元 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|-----------|----------|-----------|------|------|
|
||||
| 21 | perf/step-0 | 11.20% | 4 | 10.92% | ✅ 条件通过 | 扣除白名单(tab-bar+字体)后<3% |
|
||||
| 22 | salary/step-0 | 11.23% | 0 | 11.23% | ✅ 条件通过 | 与perf基线delta+0.31% |
|
||||
| 23 | sv/step-0 | 10.75% | 0 | 10.75% | ✅ 条件通过 | 与perf基线delta-0.17% |
|
||||
| 24 | task/step-0 | 10.48% | 0 | 10.48% | ✅ 条件通过 | 与perf基线delta-0.44% |
|
||||
|
||||
## A 批次:board-customer(8 单元,单屏×8 维度)
|
||||
|
||||
| # | 单元 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|-----------|----------|-----------|------|------|
|
||||
| 25 | recall/step-0 | 6.90% | 0 | 6.90% | ✅ 通过 | 白名单完全覆盖,净差异≈0% |
|
||||
| 26 | potential/step-0 | 4.25% | 0 | 4.25% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 27 | balance/step-0 | 4.15% | 0 | 4.15% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 28 | recharge/step-0 | 4.47% | 0 | 4.47% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 29 | recent/step-0 | 4.30% | 0 | 4.30% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 30 | spend60/step-0 | 3.87% | 0 | 3.87% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 31 | freq60/step-0 | 4.58% | 0 | 4.58% | ✅ 通过 | 白名单完全覆盖 |
|
||||
| 32 | loyal/step-0 | 4.57% | 0 | 4.57% | ✅ 通过 | 白名单完全覆盖 |
|
||||
|
||||
## B 批次:task-list + my-profile(4 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 33 | step-0 | task-list | 40.82% | 2 | 40.56% | ⚠️ 跳过 | Banner 用户信息区域结构差异+mock 数据+红戳/进度条设计差异 |
|
||||
| 34 | step-600 | task-list | 8.11% | 0 | 8.11% | ✅ 通过 | 白名单覆盖(tab-bar+字体+mock 数据差异) |
|
||||
| 35 | step-676 | task-list | 7.08% | 0 | 7.08% | ✅ 通过 | 白名单覆盖 |
|
||||
| 36 | step-0 | my-profile | 1.61% | 0 | 1.61% | ✅ 通过 | 完美还原,零修正 |
|
||||
|
||||
## C 批次:task-detail 系列(17 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 37 | step-0 | task-detail | 22.94% | 3 | 21.13% | ⚠️ 跳过 | mock 数据差异~15%+Banner 纹理~3%+字体渲染~3% |
|
||||
| 38 | step-600 | task-detail | 9.88% | 4 | 9.31% | ⚠️ 跳过 | 白名单覆盖(字体+Banner纹理+行高偏差+AI图标) |
|
||||
| 39 | step-1200 | task-detail | 7.39% | 1 | 7.39% | ⚠️ 跳过 | 白名单覆盖(字体+行高偏差+窗口微偏移) |
|
||||
| 40 | step-1800 | task-detail | 6.98% | 1 | 6.90% | ⚠️ 跳过 | 白名单覆盖(内容窗口错位~3-4%+字体渲染) |
|
||||
| 41 | step-2243 | task-detail | 6.41% | 0 | 6.41% | ⚠️ 跳过 | 白名单覆盖(内容窗口错位+字体渲染,R1回滚) |
|
||||
| 42 | step-0 | task-detail-callback | 20.07% | 2 | 19.80% | ⚠️ 跳过 | 技术栈实现差异+话术设计变体+mock 数据 |
|
||||
| 43 | step-600 | task-detail-callback | 15.14% | 1 | 15.05% | ⚠️ 跳过 | 白名单覆盖(话术设计变体+字体+CSS实现差异) |
|
||||
| 44 | step-1200 | task-detail-callback | 12.73% | 0 | 12.73% | ⚠️ 跳过 | 白名单覆盖(窗口偏移+话术变体+字体渲染) |
|
||||
| 45 | step-1645 | task-detail-callback | 7.15% | 0 | 7.15% | ⚠️ 跳过 | 白名单覆盖(话术变体+字体+CSS,双端 maxScroll clamp) |
|
||||
| 46 | step-0 | task-detail-priority | 24.09% | 0 | 24.09% | ⚠️ 跳过 | 白名单覆盖(mock数据+字体+Banner纹理+技术栈差异),orange主题色正确 |
|
||||
| 47 | step-600 | task-detail-priority | 10.78% | 0 | 10.78% | ⚠️ 跳过 | 白名单覆盖(字体+Banner纹理+mock数据+rpx偏移) |
|
||||
| 48 | step-1200 | task-detail-priority | 11.15% | 0 | 11.15% | ⚠️ 跳过 | 白名单覆盖(窗口错位+字体+Banner纹理,MP scrollTop clamp到943) |
|
||||
| 49 | step-1637 | task-detail-priority | 10.26% | 0 | 10.26% | ⚠️ 跳过 | 白名单覆盖(窗口错位+字体+Banner纹理,双端 maxScroll clamp) |
|
||||
| 50 | step-0 | task-detail-relationship | 20.49% | 0 | 20.49% | ⚠️ 跳过 | 白名单覆盖(mock数据+字体+Banner纹理+技术栈差异),pink主题色正确 |
|
||||
| 51 | step-600 | task-detail-relationship | 12.84% | 0 | 12.84% | ⚠️ 跳过 | 白名单覆盖(mock数据+字体+Banner纹理+CSS差异) |
|
||||
| 52 | step-1200 | task-detail-relationship | 14.68% | 0 | 14.68% | ⚠️ 跳过 | 白名单覆盖(窗口严重错位559px+字体+mock数据+Banner纹理) |
|
||||
| 53 | step-1523 | task-detail-relationship | 13.36% | 0 | 13.36% | ⚠️ 跳过 | 白名单覆盖(窗口错位873px+字体+mock数据,双端 maxScroll clamp) |
|
||||
|
||||
## D 批次:详情页(12 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 54 | step-0 | coach-detail | — | — | — | 未开始 | |
|
||||
| 55 | step-600 | coach-detail | — | — | — | 未开始 | |
|
||||
| 56 | step-1200 | coach-detail | — | — | — | 未开始 | |
|
||||
| 57 | step-1800 | coach-detail | — | — | — | 未开始 | |
|
||||
| 58 | step-2166 | coach-detail | — | — | — | 未开始 | |
|
||||
| 59 | step-0 | customer-detail | — | — | — | 未开始 | |
|
||||
| 60 | step-600 | customer-detail | — | — | — | 未开始 | |
|
||||
| 61 | step-1200 | customer-detail | — | — | — | 未开始 | |
|
||||
| 62 | step-1800 | customer-detail | — | — | — | 未开始 | |
|
||||
| 63 | step-2318 | customer-detail | — | — | — | 未开始 | |
|
||||
| 64 | step-0 | customer-service-records | — | — | — | 未开始 | |
|
||||
| 65 | step-209 | customer-service-records | — | — | — | 未开始 | |
|
||||
|
||||
## E 批次:绩效页面(18 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 66 | step-0 | performance | — | — | — | 未开始 | |
|
||||
| 67 | step-600 | performance | — | — | — | 未开始 | |
|
||||
| 68 | step-1200 | performance | — | — | — | 未开始 | |
|
||||
| 69 | step-1800 | performance | — | — | — | 未开始 | |
|
||||
| 70 | step-2400 | performance | — | — | — | 未开始 | |
|
||||
| 71 | step-3000 | performance | — | — | — | 未开始 | |
|
||||
| 72 | step-3600 | performance | — | — | — | 未开始 | |
|
||||
| 73 | step-4200 | performance | — | — | — | 未开始 | |
|
||||
| 74 | step-4800 | performance | — | — | — | 未开始 | |
|
||||
| 75 | step-5400 | performance | — | — | — | 未开始 | |
|
||||
| 76 | step-6000 | performance | — | — | — | 未开始 | |
|
||||
| 77 | step-6600 | performance | — | — | — | 未开始 | |
|
||||
| 78 | step-6953 | performance | — | — | — | 未开始 | |
|
||||
| 79 | step-0 | performance-records | — | — | — | 未开始 | |
|
||||
| 80 | step-600 | performance-records | — | — | — | 未开始 | |
|
||||
| 81 | step-1200 | performance-records | — | — | — | 未开始 | |
|
||||
| 82 | step-1800 | performance-records | — | — | — | 未开始 | |
|
||||
| 83 | step-1925 | performance-records | — | — | — | 未开始 | |
|
||||
|
||||
## F 批次:对话页面(3 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 84 | step-0 | chat | — | — | — | 未开始 | |
|
||||
| 85 | step-309 | chat | — | — | — | 未开始 | |
|
||||
| 86 | step-0 | chat-history | — | — | — | 未开始 | |
|
||||
|
||||
## G 批次:其他(3 单元)
|
||||
|
||||
| # | 单元 | 页面 | 初始差异率 | 修正轮次 | 最终差异率 | 状态 | 备注 |
|
||||
|---|------|------|-----------|----------|-----------|------|------|
|
||||
| 87 | step-0 | notes | — | — | — | 未开始 | |
|
||||
| 88 | step-600 | notes | — | — | — | 未开始 | |
|
||||
| 89 | step-957 | notes | — | — | — | 未开始 | |
|
||||
|
||||
---
|
||||
|
||||
## 主代理会话恢复流程
|
||||
|
||||
**每次新会话开始时,主代理必须执行以下步骤:**
|
||||
|
||||
```
|
||||
步骤1:读取本文件(PROGRESS.md)
|
||||
→ 找到「当前状态」区块,确认「下一个单元」
|
||||
→ 扫描单元表,找到第一个状态为「进行中」或「未开始」的行
|
||||
|
||||
步骤2:MCP 就绪检查
|
||||
→ mcp_weixin_devtools_mcp_get_connection_status
|
||||
→ 若未连接:等待10秒后重试,最多3次
|
||||
→ 3次失败后:mcp_weixin_devtools_mcp_recompile 重新编译,再重试
|
||||
|
||||
步骤3:更新「当前状态」区块
|
||||
→ 填写「当前处理单元」和「本次会话目标」
|
||||
|
||||
步骤4:从「下一个单元」开始下发任务
|
||||
→ 严格按编号顺序,单元完成后立即更新本文件对应行
|
||||
```
|
||||
|
||||
**单元状态说明:**
|
||||
|
||||
| 状态 | 含义 |
|
||||
|------|------|
|
||||
| 未开始 | 尚未处理 |
|
||||
| 进行中 | 当前会话正在处理 |
|
||||
| ✅ 通过 | 差异率 <5%,已收敛 |
|
||||
| ⚠️ 跳过 | 5轮未收敛,已记录差异,继续下一单元 |
|
||||
| 🔁 重写中 | 差异率 >20%,触发结构重写流程 |
|
||||
|
||||
---
|
||||
|
||||
## 变更日志
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-03-12 | 迁移至 docs/miniprogram-dev/04-audit/,更新内部路径引用 |
|
||||
| 2026-03-11 | 新增会话恢复机制、MCP就绪检查、重写状态标记 |
|
||||
| 2026-03-10 | 全部 89 单元初始化,前置任务 P0-P3 完成 |
|
||||
66
_DEL/miniprogram-dev/05-lessons/convergence-patterns.md
Normal file
66
_DEL/miniprogram-dev/05-lessons/convergence-patterns.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 收敛模式与不可消除差异
|
||||
|
||||
> 记录视觉还原过程中的收敛规律和不可消除差异白名单。
|
||||
> 基于 A-C 批次(53 单元)的实际数据总结。
|
||||
|
||||
---
|
||||
|
||||
## 不可消除差异白名单
|
||||
|
||||
以下差异在所有页面中均存在,不计入差异率,审计报告中标注「不可消除,已忽略」:
|
||||
|
||||
| 差异类型 | 根因 | 估计贡献 |
|
||||
|---|---|---|
|
||||
| 字体渲染(字形、次像素、间距) | Chromium(Noto Sans SC)vs 微信 WebView(系统字体) | ~2% |
|
||||
| 行内元素高度系统性偏小 ~7% | WebView 字体度量(ascent/descent/leading)差异 | ~1% |
|
||||
| rpx 取偶数四舍五入 | 每个 px 值换算后取偶数,累积微小偏差 | ~0.5% |
|
||||
| 抗锯齿差异 | 渲染引擎对边缘像素处理不同 | ~0.5% |
|
||||
| 环比箭头(SVG vs 文字 ↑↓) | 已确认可接受 | — |
|
||||
| CSS 渐变文字 → 纯色文字 | MP 不支持 `-webkit-text-fill-color` + clip | — |
|
||||
| Banner 纹理差异 | 7 层叠加渐变 + SVG 纹理无法完全复现 | ~3% |
|
||||
|
||||
背景噪音基线:~3.87%(Benchmark v3 实测)。<5% 通过目标在基线之上仅有 ~1% 修正空间。
|
||||
|
||||
---
|
||||
|
||||
## 典型收敛曲线
|
||||
|
||||
### 单屏页面(board-coach/customer 维度)
|
||||
- 初始差异率:4-11%
|
||||
- 白名单扣除后:<3%
|
||||
- 通常 0 轮修正直接通过
|
||||
|
||||
### 多屏长页面(board-finance)
|
||||
- step-0:6% → 0 轮通过
|
||||
- step-600~1200:9-11% → 含滚动偏移 + 字体渲染
|
||||
- step-2400~3600:15-18% → 累积高度偏移,跳过
|
||||
- step-4200+:3% → 页面底部,双端 clamp 到 maxScroll
|
||||
|
||||
### 任务详情系列(task-detail)
|
||||
- step-0:20-24% → mock 数据差异 ~15% + Banner 纹理 ~3% + 字体 ~3%
|
||||
- 全部跳过(白名单覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 跳过原因分类
|
||||
|
||||
| 原因 | 出现频率 | 可修复性 |
|
||||
|------|---------|---------|
|
||||
| 累积高度偏移(rpx 取整导致 MP 页面更短) | 高 | 不可修复(系统性) |
|
||||
| mock 数据差异 | 中 | 可修复(对齐数据) |
|
||||
| Banner 纹理差异 | 中 | 部分可修复(简化纹理) |
|
||||
| 话术设计变体(H5 vs MP 实现差异) | 低 | 需重构 |
|
||||
|
||||
---
|
||||
|
||||
## 高度偏移规律
|
||||
|
||||
MP 页面总高度因 rpx 取整累积比 H5 略短。偏移量随页面长度增大:
|
||||
|
||||
| 页面 | H5 scrollHeight | MP scrollHeight | 偏移 |
|
||||
|------|----------------|----------------|------|
|
||||
| board-finance/default | 5600px | ~5030px | ~570px(10%) |
|
||||
| board-finance/compare | 5579px | ~5030px | ~549px |
|
||||
|
||||
偏移导致中间屏(step-2400~3600)的内容窗口错位严重,是跳过的主要原因。
|
||||
页面首尾屏不受影响(首屏 scrollTop=0,尾屏双端 clamp 到 maxScroll)。
|
||||
148
_DEL/miniprogram-dev/05-lessons/pitfalls.md
Normal file
148
_DEL/miniprogram-dev/05-lessons/pitfalls.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 踩坑速查与迁移经验
|
||||
|
||||
> 经 User 确认后录入的实践经验。AI 发现新踩坑点时先提出建议,User 确认后再录入。
|
||||
> 按类别组织,每条记录包含:场景、问题、解决方案、影响页面。
|
||||
|
||||
---
|
||||
|
||||
## WXML 模板
|
||||
|
||||
### 禁止在 `{{}}` 中调用 JS 方法
|
||||
- 场景:WXML 模板中使用 `.toFixed()`、`.map()` 等
|
||||
- 问题:小程序模板不支持 JS 方法调用
|
||||
- 方案:用 WXS 模块 `utils/format.wxs`
|
||||
- 影响:所有页面
|
||||
|
||||
### TabBar 页面跳转必须用 `wx.switchTab`
|
||||
- 场景:从非 tabBar 页面跳转到 task-list、board-finance、my-profile
|
||||
- 问题:`navigateTo` 会静默失败
|
||||
- 方案:tabBar 页面一律用 `wx.switchTab`
|
||||
- 影响:所有涉及 tabBar 跳转的页面
|
||||
|
||||
---
|
||||
|
||||
## 样式与布局
|
||||
|
||||
### 一屏页面用 `height: 100vh` 而非 `min-height`
|
||||
- 场景:login、reviewing、no-permission 等单屏页面
|
||||
- 问题:`min-height: 100vh` 在某些情况下不生效
|
||||
- 方案:`height: 100vh` + `box-sizing: border-box`
|
||||
- 影响:login, reviewing, no-permission
|
||||
|
||||
### 状态栏适配禁止用 `env(safe-area-inset-top)`
|
||||
- 场景:自定义 navBar 页面的顶部 padding
|
||||
- 问题:小程序不支持 `env()` CSS 函数
|
||||
- 方案:JS `wx.getSystemInfoSync().statusBarHeight` 动态设置
|
||||
- 影响:所有自定义 navBar 页面
|
||||
|
||||
### `padding-top + 100vh` 必须配合 `box-sizing: border-box`
|
||||
- 场景:有顶部 padding 的全屏页面
|
||||
- 问题:不加 box-sizing 会导致底部内容溢出
|
||||
- 方案:始终添加 `box-sizing: border-box`
|
||||
- 影响:所有全屏页面
|
||||
|
||||
---
|
||||
|
||||
## TDesign 组件
|
||||
|
||||
### `t-button icon` 只支持内置图标
|
||||
- 场景:按钮中使用品牌图标(微信 logo 等)
|
||||
- 问题:`icon` 属性只接受 TDesign 内置图标名
|
||||
- 方案:用 `<view>` + `<image>` 组合替代
|
||||
- 影响:login 页面微信登录按钮
|
||||
|
||||
### TDesign 默认样式优先级高
|
||||
- 场景:需要大幅覆盖 TDesign 组件样式
|
||||
- 问题:CSS 变量和外部样式类不够用时,`!important` 也可能不生效
|
||||
- 方案:直接用原生 `<view>` 实现,绕开样式干扰
|
||||
- 影响:视具体组件
|
||||
|
||||
---
|
||||
|
||||
## 资源引用
|
||||
|
||||
### 所有 H5 内联 SVG 必须导出为独立文件
|
||||
- 场景:H5 中直接写在 HTML 内的 `<svg>` 元素
|
||||
- 问题:小程序不支持内联 SVG
|
||||
- 方案:导出为 `.svg` 文件存放 `assets/icons/`,用 `<image>` 引用
|
||||
- 影响:所有含内联 SVG 的页面
|
||||
|
||||
### 引用不存在的图片会 500 错误
|
||||
- 场景:WXML 中 `<image src>` 指向不存在的文件
|
||||
- 问题:不是 404 而是 500 错误,难以排查
|
||||
- 方案:用 CSS 渐变、emoji、`<t-icon>` 替代;或确保文件存在
|
||||
- 影响:所有页面
|
||||
|
||||
---
|
||||
|
||||
## 截图与对比
|
||||
|
||||
### rpx 取整导致累积高度偏移
|
||||
- 场景:多屏长页面的中间屏截图对比
|
||||
- 问题:MP 页面总高度比 H5 短 ~10%,中间屏内容窗口错位
|
||||
- 方案:使用锚点对齐法(按元素位置对齐而非 scrollTop 数值)
|
||||
- 影响:board-finance, performance 等长页面
|
||||
- 发现日期:2026-03-10
|
||||
|
||||
### AI 浮动按钮动画造成随机差异
|
||||
- 场景:截图时 ai-float-button 的 gradientShift 动画处于不同帧
|
||||
- 问题:每次截图差异 ~1%
|
||||
- 方案:双端截图前隐藏(H5 用 JS,MP 用 `wx:if="{{false}}"`)
|
||||
- 影响:所有含 ai-float-button 的页面
|
||||
- 发现日期:2026-03-10
|
||||
|
||||
### 禁止跨维度套用布局假设,H5 原型是唯一视觉真相
|
||||
- 场景:board-customer "最专一"卡片的 `.loyal-table` 布局
|
||||
- 问题:其他维度(最应召回等)的 `card-mid-row` / `card-assistant-row` 有 `ml-11`(80rpx 对齐头像右侧),开发时"合理推断"表格也应该加 `margin-left: 80rpx`。但 H5 原型中表格容器没有 `ml-11`,是从卡片 padding 开始的(left=16px)。Playwright 实测才发现偏差
|
||||
- 方案:每个维度的每个组件都必须回到 H5 HTML 逐一确认 Tailwind 类,禁止从其他维度"类推"。不确定时用 Playwright `getBoundingClientRect()` 实测 H5 元素位置
|
||||
- 影响:所有多维度页面(board-customer 8 维度、board-coach 4 维度)
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### H5 与 MP mock 数据不同,像素级 diff 需结合结构比对
|
||||
- 场景:用 PIL/numpy 或 image-compare 对比 H5 和 MP 截图
|
||||
- 问题:H5 原型使用硬编码 mock 数据(如孙先生/王先生/李女士),MP 使用后端返回的测试数据(王先生/李女士/张先生),文字内容不同导致像素 diff 高达 11%,18 个分段覆盖全页面,无法区分"数据差异"和"样式差异"
|
||||
- 方案:像素级 diff 仅用于发现大面积结构偏移(窗口错位、整块缺失)。样式还原度验证的主要方法是 Tailwind 类→rpx 值逐项比对表,逐一确认每个 CSS 属性的换算结果
|
||||
- 影响:所有页面的截图对比流程
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### Tailwind→WXSS 映射必须逐项建表验证
|
||||
- 场景:将 H5 Tailwind 类翻译为小程序 WXSS
|
||||
- 问题:凭记忆或经验直接写 WXSS 值容易出错——`font-medium` 是 500 不是 600,`gap-1` 是 4px 不是 2px,`space-y-2` 是 8px 不是 4px。错误在视觉上不明显但会累积
|
||||
- 方案:对每个组件建立完整的对照表(H5 Tailwind 类 → 计算值 px → rpx 换算 → MP WXSS 值),逐行核对。参考速查表:`2→4, 4→8, 6→10, 8→14, 10→18, 12→22, 14→24, 16→28, 20→36, 24→42`
|
||||
- 影响:所有页面的 WXSS 编写
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### 相邻元素间距叠加导致高度翻倍
|
||||
- 场景:经营一览的 `.overview-grid-2` 底部 padding 32rpx + `.ai-insight-section` margin-top 32rpx,间距翻倍为 64rpx
|
||||
- 问题:H5 中 grid 无 margin-bottom,间距完全由下方元素的 `mt-4`(16px) 提供。MP 中两端都加了间距,导致块间距翻倍、整体高度偏高
|
||||
- 方案:校对间距时必须确认「这段间距是谁提供的」——上方元素的 padding-bottom 还是下方元素的 margin-top。用 Playwright `getComputedStyle` 实测 H5 两个相邻元素的 margin/padding,确认间距归属后只在一端设置
|
||||
- 影响:所有包含多个子区块的板块(经营一览、预收资产等)
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### 用 Playwright 实测 H5 比手动解析 Tailwind 更可靠
|
||||
- 场景:H5 原型文件是单行 minified HTML,手动读取 Tailwind 类名容易遗漏或误判
|
||||
- 问题:Tailwind 类名组合后的实际渲染值不直观(如 `p-4` 在嵌套容器中可能被覆盖,`space-y-2` 的实际 gap 取决于子元素数量),手动推算容易出错
|
||||
- 方案:通过 `browser_run_code` 创建 375×812 viewport + DPR 1.5 的 context,用 `getComputedStyle` + `getBoundingClientRect` 直接拿到渲染后的实际 px 值,再按换算规则转 rpx。这个方法应作为所有页面校对的标准流程
|
||||
- 影响:所有页面的 WXSS 校对流程
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### Tailwind 字号类自带 line-height,WXSS 必须同步补齐
|
||||
- 场景:校对经营一览的现金流水概览 4 个块,H5 明显比 MP 高
|
||||
- 问题:Tailwind 的 `text-xs`/`text-sm`/`text-lg`/`text-xl` 等字号类捆绑了特定的 `line-height`(如 `text-lg` = `font-size: 18px; line-height: 28px`),但 WXSS 只写了 `font-size` 没写 `line-height`,小程序默认行高约 1.2,远小于 Tailwind 的行高,导致每行文字占据的垂直空间不足,块整体矮了一截
|
||||
- 方案:每个 Tailwind 字号类转 WXSS 时,必须同时写 `font-size` 和 `line-height`。完整对照表(87.5% 缩放):
|
||||
|
||||
| Tailwind | font-size (px) | line-height (px) | WXSS font-size | WXSS line-height |
|
||||
|----------|---------------|------------------|----------------|-----------------|
|
||||
| `text-xs` | 12 | 16 | 22rpx | 28rpx |
|
||||
| `text-sm` | 14 | 20 | 24rpx | 34rpx |
|
||||
| `text-base` | 16 | 24 | 28rpx | 42rpx |
|
||||
| `text-lg` | 18 | 28 | 32rpx | 48rpx |
|
||||
| `text-xl` | 20 | 28 | 36rpx | 48rpx |
|
||||
| `text-2xl` | 24 | 32 | 42rpx | 56rpx |
|
||||
|
||||
- 影响:所有页面的所有文字元素
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
---
|
||||
|
||||
> 新增经验请按以上格式录入,包含:场景、问题、解决方案、影响页面、发现日期(可选)。
|
||||
74
_DEL/miniprogram-dev/README.md
Normal file
74
_DEL/miniprogram-dev/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 小程序前端页面开发与原型迁移指南
|
||||
|
||||
> 操作文档已迁移至 Kiro Power `miniprogram-h5-conversion`,通过关键词自动激活。
|
||||
> 本目录仅保留实时更新的仓库驻留文件(进度、经验、页面结构等)。
|
||||
|
||||
---
|
||||
|
||||
## Power 使用方式
|
||||
|
||||
操作文档(调度手册、子代理手册、转换规则、WXSS 规范、CSS 风险清单、Power 集成指南)已打包为 Kiro Power:
|
||||
|
||||
- Power 名称:`miniprogram-h5-conversion`
|
||||
- 激活方式:对话中提及"小程序页面"、"H5转换"、"wxml"、"wxss"、"rpx"等关键词时自动激活
|
||||
- 手动激活:`kiroPowers action="activate" powerName="miniprogram-h5-conversion"`
|
||||
- 查看 steering 列表:激活后在返回的 `steeringFiles` 中选择需要的文件
|
||||
|
||||
Power 包含 12 个 steering 文件,按职能分三层:
|
||||
|
||||
| 层 | 文件 | 说明 |
|
||||
|----|------|------|
|
||||
| 调度层 | `batch-auto.md` | 89 单元批量编排 |
|
||||
| 调度层 | `user-guided.md` | 半自动模式 |
|
||||
| 调度层 | `new-page.md` | 新页面开发 |
|
||||
| 行动层 | `screenshot.md` | 截图代理手册 |
|
||||
| 行动层 | `audit.md` | 审计代理手册 |
|
||||
| 行动层 | `fix.md` | 修正代理手册 |
|
||||
| 行动层 | `verify.md` | 验证代理手册 |
|
||||
| 行动层 | `page-dev.md` | 页面开发代理手册 |
|
||||
| 参考层 | `conversion-rules.md` | 核心转换规则 |
|
||||
| 参考层 | `wxss-rules.md` | WXSS 标准值速查 |
|
||||
| 参考层 | `css-risk.md` | CSS 风险特性清单 |
|
||||
| 参考层 | `power-integration.md` | Power 调用规范 |
|
||||
|
||||
---
|
||||
|
||||
## 仓库驻留文件(实时更新,不在 Power 内)
|
||||
|
||||
```
|
||||
docs/miniprogram-dev/
|
||||
├── README.md ← 你在这里
|
||||
├── 03-reference/
|
||||
│ ├── page-structure-map.md # 各页面特殊结构速查(新增页面时更新)
|
||||
│ └── benchmark-history.md # 基准测试历史记录
|
||||
├── 04-audit/
|
||||
│ ├── PROGRESS.md # 89 单元迁移进度(每次会话必读)
|
||||
│ └── CHANGELOG.md # 文档体系版本变更
|
||||
└── 05-lessons/
|
||||
├── pitfalls.md # 踩坑速查(实战经验,持续更新)
|
||||
└── convergence-patterns.md # 收敛模式 + 不可消除差异白名单
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Power 依赖清单
|
||||
|
||||
| Power | 用途 |
|
||||
|-------|------|
|
||||
| `wechat-miniprogram` | 小程序开发规范(WXML/WXSS/API/TDesign) |
|
||||
| `pixel-audit` | 像素间距测量 + rpx 换算 |
|
||||
| `playwright` | H5 端截图 |
|
||||
| `weixin-devtools` | MP 端截图 + 页面操作 |
|
||||
|
||||
---
|
||||
|
||||
## 关联资源
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| H5 原型页面 | `docs/h5_ui/pages/*.html` |
|
||||
| 设计 Token | `docs/h5_ui/design-tokens.json` |
|
||||
| 图标映射表 | `docs/h5_ui/icon-mapping.md` |
|
||||
| 截图产物 | `docs/h5_ui/compare/<page>/` |
|
||||
| 小程序源码 | `apps/miniprogram/miniprogram/pages/` |
|
||||
| 辅助脚本 | `scripts/ops/measure_gaps.py`、`scripts/ops/anchor_compare.py` |
|
||||
67
_DEL/miniprogram-h5-conversion/POWER.md
Normal file
67
_DEL/miniprogram-h5-conversion/POWER.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: "miniprogram-h5-conversion"
|
||||
displayName: "H5 → 微信小程序转换规范"
|
||||
description: "H5 原型转微信小程序页面的完整开发规范体系。覆盖截图、审计、修正、验证四阶段流水线,89 单元批量编排,半自动/新页面开发模式。核心硬性规则在 always-on steering 中始终生效,本 Power 提供操作手册。"
|
||||
keywords: ["微信小程序", "miniprogram", "wxml", "wxss", "H5", "转换", "迁移", "rpx", "截图", "审计", "修正", "验证", "视觉还原", "TDesign", "Tailwind"]
|
||||
author: "NeoZQYY"
|
||||
---
|
||||
|
||||
# H5 → 微信小程序转换规范
|
||||
|
||||
核心硬性规则已提取到 always-on steering `mp-h5-rules.md`(每次对话自动生效),详细开发参考值在 fileMatch steering `mp-h5-dev.md`(读取 wxml/wxss/h5_ui 文件时自动加载)。
|
||||
|
||||
本 Power 提供操作手册——截图、审计、修正、验证的具体执行流程。
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 确认工作模式后加载对应 steering
|
||||
|
||||
| 我要做什么 | 加载的 steering |
|
||||
|-----------|----------------|
|
||||
| 批量执行 89 单元视觉还原 | `batch-auto.md` |
|
||||
| 用户指定页面/区域做开发或修正 | `user-guided.md` |
|
||||
| 从零开发新页面 | `user-guided.md`(场景二) |
|
||||
| 执行截图+审计+修正+验证流程 | `action-manual.md` |
|
||||
| 查 Power/MCP 调用方式 | `power-integration.md` |
|
||||
|
||||
### 实时数据(仓库内,不在 Power 中)
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| 迁移进度 | `docs/miniprogram-dev/04-audit/PROGRESS.md` |
|
||||
| 踩坑速查 | `docs/miniprogram-dev/05-lessons/pitfalls.md` |
|
||||
| 收敛模式 | `docs/miniprogram-dev/05-lessons/convergence-patterns.md` |
|
||||
| 页面结构速查 | `docs/miniprogram-dev/03-reference/page-structure-map.md` |
|
||||
| 基准测试历史 | `docs/miniprogram-dev/03-reference/benchmark-history.md` |
|
||||
|
||||
### 关联资源
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| H5 原型 | `docs/h5_ui/pages/*.html` |
|
||||
| 设计 Token | `docs/h5_ui/design-tokens.json` |
|
||||
| 图标映射 | `docs/h5_ui/icon-mapping.md` |
|
||||
| 截图产物 | `docs/h5_ui/compare/<page>/` |
|
||||
| MP 源码 | `apps/miniprogram/miniprogram/pages/` |
|
||||
|
||||
## Steering 文件清单
|
||||
|
||||
本 Power 包含 4 个 steering 文件:
|
||||
|
||||
### 调度层(做什么、什么顺序)
|
||||
- `batch-auto.md` — 89 单元批量编排 + 4 代理流水线
|
||||
- `user-guided.md` — 半自动/新页面开发模式
|
||||
|
||||
### 行动层(怎么做)
|
||||
- `action-manual.md` — 截图+审计+修正+验证的完整执行手册(合并原 screenshot/audit/fix/verify 四个文件)
|
||||
|
||||
### 集成层
|
||||
- `power-integration.md` — 4 个外部 Power 的调用方式 + MCP 连接规范
|
||||
|
||||
## 与 Steering 的分工
|
||||
|
||||
| 内容 | 位置 | 加载方式 |
|
||||
|------|------|---------|
|
||||
| 硬性规则(标签映射、rpx、颜色、截图禁令) | `mp-h5-rules.md` | always-on |
|
||||
| 开发参考值(灰阶全表、圆角、阴影、CSS 风险) | `mp-h5-dev.md` | fileMatch 自动 |
|
||||
| 操作手册(截图流程、审计方法、修正策略) | 本 Power steering | 按需 readSteering |
|
||||
210
_DEL/miniprogram-h5-conversion/steering/action-manual.md
Normal file
210
_DEL/miniprogram-h5-conversion/steering/action-manual.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 截图+审计+修正+验证 执行手册
|
||||
|
||||
> 合并原 screenshot.md / audit.md / fix.md / verify.md 四个文件。
|
||||
> 硬性规则(rpx、标签映射、颜色)已在 always-on steering `mp-h5-rules.md` 中,此处不重复。
|
||||
|
||||
---
|
||||
|
||||
## 一、截图
|
||||
|
||||
### H5 截图(Playwright Power → `browser_run_code`)
|
||||
|
||||
```javascript
|
||||
async (page) => {
|
||||
const browser = page.context().browser();
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 430, height: 752 },
|
||||
deviceScaleFactor: 1.5
|
||||
});
|
||||
const p = await ctx.newPage();
|
||||
await p.goto('file:///C:/NeoZQYY/docs/h5_ui/pages/<page>.html',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await p.waitForTimeout(3000); // Tailwind CDN JIT
|
||||
|
||||
await p.evaluate(() => {
|
||||
const nav = document.getElementById('bottomNav');
|
||||
if (nav) nav.style.display = 'none';
|
||||
const safeArea = document.querySelector('.safe-area-top');
|
||||
if (safeArea) safeArea.style.paddingTop = '0px';
|
||||
const aiFab = document.querySelector('.ai-float-btn-container');
|
||||
if (aiFab) aiFab.style.display = 'none';
|
||||
const s = document.createElement('style');
|
||||
s.textContent = '::-webkit-scrollbar{display:none!important}*{scrollbar-width:none!important}';
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
|
||||
await p.evaluate((scrollTop) => window.scrollTo(0, scrollTop), <scrollTop>);
|
||||
await p.waitForTimeout(300);
|
||||
await p.screenshot({
|
||||
path: 'C:/NeoZQYY/docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png',
|
||||
type: 'png', scale: 'device'
|
||||
});
|
||||
await ctx.close();
|
||||
return { saved: true };
|
||||
}
|
||||
```
|
||||
|
||||
步数 ≥3 的页面可在同一 context 中循环截图。
|
||||
|
||||
### MP 截图(weixin-devtools Power)
|
||||
|
||||
```
|
||||
1. relaunch → url: "/pages/<page>/<page>"(禁止 switch_tab/navigate_to)
|
||||
2. waitFor → delay: 2000
|
||||
3. 前置:WXML 中 dev-fab/ai-float-button 加 wx:if="{{false}}"
|
||||
4. 滚动到目标 scrollTop
|
||||
5. screenshot
|
||||
6. 模式 B 页面裁剪头部 96px(PIL: img.crop((0, 96, 645, 1224)))
|
||||
```
|
||||
|
||||
### 滚动策略
|
||||
|
||||
标准:直接使用 H5 相同 scrollTop。
|
||||
|
||||
窗口错位判断:上下大块红绿(>200px)=整体偏移→锚点对齐法 | 散布多区域=样式差异→继续修正 | 底部全黑/白>100px=内容更短→锚点对齐法
|
||||
|
||||
锚点对齐法:确定锚点元素 → `wx.createSelectorQuery` 获取绝对 top → `MP_target = absoluteTop - sticky高度` → 滚动到该位置
|
||||
|
||||
### 多维度切换
|
||||
|
||||
board-coach/customer:获取快照 → 点击筛选下拉 → 选择维度 → 等待刷新
|
||||
board-finance:H5 `toggleCompare()` / MP 点击环比开关
|
||||
|
||||
### 截图后行为(严格遵守)
|
||||
|
||||
- 直接进入审计流程,禁止用 PIL/pixelmatch/image-compare 检查尺寸或做像素 diff
|
||||
- 截图参数已固定,尺寸必然正确
|
||||
|
||||
---
|
||||
|
||||
## 二、审计
|
||||
|
||||
### 三阶段流程
|
||||
|
||||
```
|
||||
阶段 0:结构拆解(自顶向下)
|
||||
H5 源码 → 首层容器 → 递归到叶子 → 建立 H5↔MP 映射表
|
||||
结构完整性校验(缺失/多余/顺序错误 → P0)
|
||||
|
||||
阶段 1:逐级测量(自底向上)
|
||||
叶子:自身尺寸 + 样式属性
|
||||
容器:内边距 + 子元素间距 + 总尺寸
|
||||
页面级:全局内边距 + 区块间距
|
||||
工具:pixel-audit Power → readSteering("measure.md")
|
||||
|
||||
阶段 2:偏差审计
|
||||
逐属性对比 H5 rpx 理论值 vs MP 实测值 → 按偏差量分级
|
||||
```
|
||||
|
||||
### 迁移前置准备(step-0 必做)
|
||||
|
||||
1. H5 页面结构预扫描:全局结构、CSS 风险特性、字体确认
|
||||
2. MP 骨架检查:根容器、navigationBar、safe-area 移除、背景色、浮动按钮隐藏
|
||||
3. mock 数据一致性预核对(不一致 = P0)
|
||||
4. 多屏页面:测量 MP scrollHeight,与编排值差异 >50px 则重算序列
|
||||
|
||||
### 审计报告格式
|
||||
|
||||
产出 `docs/h5_ui/compare/<page>/audit.md`:
|
||||
|
||||
| 章节 | 内容 |
|
||||
|------|------|
|
||||
| A. 结构对照 | 区域完整性、顺序 |
|
||||
| B. CSS 风险点 | 不支持的 CSS |
|
||||
| C. 关键样式映射 | Tailwind→computed→WXSS 现值→是否一致 |
|
||||
| D. 图标处理 | 每个图标迁移状态 |
|
||||
| E. 偏差清单 | P0-P7 排序,含 H5 值→rpx→MP 现值→差异 |
|
||||
| F. 复杂结构专项 | Banner/AI 图标/盖戳/inline SVG/渐变文字 |
|
||||
|
||||
关键原则:不能只看 diff 图猜测,必须回溯 H5 源码。Tailwind 类名是唯一可信样式来源。
|
||||
|
||||
---
|
||||
|
||||
## 三、修正
|
||||
|
||||
### 优先级定义
|
||||
|
||||
| 级别 | 类型 | 判定标准 | 阶段 |
|
||||
|------|------|---------|------|
|
||||
| P0 | 区域缺失/顺序错误 | 整块缺失 | 阶段一 |
|
||||
| P1 | 背景色/渐变色不匹配 | 大面积色差 | 阶段一 |
|
||||
| P2 | 字号明显偏差 | font-size 差 ≥4rpx | 阶段一 |
|
||||
| P3 | 间距明显偏差 | padding/margin/gap 差 ≥4rpx | 阶段一 |
|
||||
| P4 | 圆角偏差 | border-radius 差 ±2rpx | 阶段二 |
|
||||
| P5 | 颜色微调 | 灰阶偏差、透明度差 0.1 | 阶段二 |
|
||||
| P6 | 行高/字重微调 | line-height 缺失或偏差 | 阶段二 |
|
||||
| P7 | 阴影微调 | box-shadow 偏差 | 阶段二 |
|
||||
|
||||
### 两阶段收敛
|
||||
|
||||
```
|
||||
P0-P3 >10 → 结构级重写(每轮 3-8 处)→ 循环
|
||||
P0-P3 >0 → 阶段一:结构级修正(每轮 2-5 处)→ 循环直到 P0-P3=0
|
||||
P4-P7 >0 → 阶段二:像素级精调(每轮 1-3 处)→ 循环直到可接受
|
||||
全部 ≤2rpx → ✅ 通过
|
||||
```
|
||||
|
||||
### 回滚规则(强制)
|
||||
|
||||
每轮修正后重新截图对比,差异率上升(哪怕 0.01%)→ 立即回滚 → 逐项排查 → 每次只改一处
|
||||
|
||||
### 最大重试限制
|
||||
|
||||
差异率下降 >0.5% → 重试计数归零 | 下降 ≤0.5% → 计数+1 | 计数=5 且仍 ≥5% → 跳过(记录到 report.md)
|
||||
|
||||
### 首屏 >20% 触发结构重写
|
||||
|
||||
| 页面类型 | 重写范围 | 禁止修改 |
|
||||
|---|---|---|
|
||||
| 单屏 | 当前维度完整 WXML/WXSS | 其他维度 |
|
||||
| 多屏(step-0) | 页面顶部结构 | step-600 以下 |
|
||||
| 多屏(step-N>0) | 当前视口内节点 | 其他屏和全局样式 |
|
||||
| 变体 | 与主页面差异部分 | 共享布局 |
|
||||
|
||||
### 修改范围约束
|
||||
|
||||
- 只改当前屏可见元素
|
||||
- 偏差根因在前序屏 → 标注"需回退到 step-X"返回主代理
|
||||
- 偏差根因是全局样式 → 标注"全局样式变更"返回主代理
|
||||
- 修改后 `getDiagnostics` 确认零 TS 错误,有错误立即回退
|
||||
|
||||
### 收敛停滞
|
||||
|
||||
连续 3 轮差异率未下降 >0.5%:分析是否为不可消除结构性差异 → 是则标注可接受 → 否则缩小粒度
|
||||
|
||||
---
|
||||
|
||||
## 四、验证
|
||||
|
||||
### 修正后验证
|
||||
|
||||
```
|
||||
1. 重新截图 → 重新审计
|
||||
2. P0-P3=0 → ✅ 通过
|
||||
3. P0-P3 减少 → 继续修正
|
||||
4. P0-P3 未减少或增加 → 回滚 + 重试计数+1
|
||||
5. 重试=5 → 跳过
|
||||
```
|
||||
|
||||
### 全量回归校验(多屏页面所有屏通过后强制执行)
|
||||
|
||||
```
|
||||
1. 逐屏截取双端截图
|
||||
2. 每屏结构化审计
|
||||
3. 禁止修改任何源码
|
||||
4. P0-P3 增加 → 标记"回归"
|
||||
5. 返回主代理决定处理方式
|
||||
```
|
||||
|
||||
### Sticky 元素专项
|
||||
|
||||
step-0 完成后先检测 sticky 差异 → 优先修复(一次修复所有屏受益)→ 再继续后续步骤
|
||||
|
||||
### 异常处理
|
||||
|
||||
| 故障 | 处理 | 升级 |
|
||||
|------|------|------|
|
||||
| MCP 断开 | reconnect_devtools;3次失败→mcp_recompile+5s | 重编译后仍失败→暂停 |
|
||||
| Playwright 不可用 | 用已有截图继续审计 | 无截图→报告主代理 |
|
||||
| 截图异常(白屏) | 暂停检查页面状态 | 禁止使用异常截图 |
|
||||
| MCP 超时 | 重试最多 2 次 | 3次均超时→跳过 |
|
||||
122
_DEL/miniprogram-h5-conversion/steering/batch-auto.md
Normal file
122
_DEL/miniprogram-h5-conversion/steering/batch-auto.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 批量自动模式:89 单元视觉还原编排
|
||||
|
||||
> 每次会话开始:读 `docs/miniprogram-dev/04-audit/PROGRESS.md` 确认进度。
|
||||
> 执行手册:`readSteering("action-manual.md")`
|
||||
> Power 调用:`readSteering("power-integration.md")`
|
||||
|
||||
---
|
||||
|
||||
## 执行模型:4 种专职子代理
|
||||
|
||||
```
|
||||
主代理(调度)
|
||||
→ 截图代理 → 审计代理 → 修正代理 ⇆ 验证代理 → 主代理汇总
|
||||
```
|
||||
|
||||
全程严格串行——同一时刻只有一个单元在执行。禁止预先批量截图。
|
||||
|
||||
---
|
||||
|
||||
## 主代理调度职责
|
||||
|
||||
1. 读 PROGRESS.md 确认进度
|
||||
2. 检查 MCP 就绪
|
||||
3. 新页面:隐藏 dev-fab/ai-float-button
|
||||
4. 逐屏下发:截图→审计→修正/验证循环
|
||||
5. 通过→更新 PROGRESS.md→下一屏
|
||||
6. 需回退→回退到指定 step
|
||||
7. 跳过→备注原因,继续
|
||||
8. 页面所有屏通过→全量回归校验
|
||||
|
||||
---
|
||||
|
||||
## 单元内流程
|
||||
|
||||
```
|
||||
Step 1:截图代理 → H5/MP 截图
|
||||
Step 2:审计代理 → audit.md + 偏差清单
|
||||
Step 3:修正代理 → 修正源码
|
||||
├─ P0-P3 >0 且未触发跳过 → 验证代理 → 循环
|
||||
├─ P0-P3=0 → Step 4
|
||||
├─ 连续 5 轮无改善 → 跳过
|
||||
└─ P0-P3 >10 且连续 3 轮无法突破 → 结构重写
|
||||
Step 4:主代理汇总 → 更新 PROGRESS.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前置任务
|
||||
|
||||
P0. TS 零诊断基线:17 页面 `.ts` 全部 `getDiagnostics` 零诊断
|
||||
P1. 跨页面共性偏差批量修复
|
||||
|
||||
---
|
||||
|
||||
## 批次编排
|
||||
|
||||
### A 批次:看板(32 单元)
|
||||
|
||||
board-finance(20 单元):default 10 屏(step-0~4848) + compare 10 屏(step-0~4827)
|
||||
board-coach(4 单元):perf/salary/sv/task 各 step-0
|
||||
board-customer(8 单元):recall/potential/balance/recharge/recent/spend60/freq60/loyal 各 step-0
|
||||
|
||||
### B 批次:核心(4 单元)
|
||||
|
||||
task-list:3 屏(#33-35) | my-profile:1 屏(#36)
|
||||
|
||||
### C 批次:任务详情(17 单元)
|
||||
|
||||
task-detail:5 屏(#37-41)
|
||||
task-detail-callback:4 屏(#42-45,teal 主题色)
|
||||
task-detail-priority:4 屏(#46-49,orange 主题色)
|
||||
task-detail-relationship:4 屏(#50-53,pink 主题色)
|
||||
|
||||
### D 批次:详情(12 单元)
|
||||
|
||||
coach-detail:5 屏(#54-58) | customer-detail:5 屏(#59-63) | customer-service-records:2 屏(#64-65)
|
||||
|
||||
### E 批次:绩效(18 单元)
|
||||
|
||||
performance:13 屏(#66-78) | performance-records:5 屏(#79-83)
|
||||
|
||||
### F 批次:对话(3 单元)
|
||||
|
||||
chat:2 屏(#84-85) | chat-history:1 屏(#86)
|
||||
|
||||
### G 批次:其他(3 单元)
|
||||
|
||||
notes:3 屏(#87-89)
|
||||
|
||||
---
|
||||
|
||||
## 子代理下发模板
|
||||
|
||||
### 标准单元
|
||||
|
||||
```
|
||||
执行视觉还原对照单元:<单元 ID>
|
||||
源码:H5 docs/h5_ui/pages/<page>.html | MP apps/miniprogram/.../pages/<page>/
|
||||
输出:docs/h5_ui/compare/<page>/
|
||||
当前步骤:step-<N>(第 M / 共 T 步)
|
||||
前序屏状态:<首屏"无前序" / 后续"step-X 已通过">
|
||||
|
||||
执行:截图→审计→修正→验证(见 action-manual.md)
|
||||
约束:只改当前屏 | 偏差在前序屏→标注"需回退" | 全局样式→标注"全局变更"
|
||||
完成后:更新 PROGRESS.md
|
||||
```
|
||||
|
||||
### 变体单元(C 批次)
|
||||
|
||||
```
|
||||
执行视觉还原(变体简化):<单元 ID>
|
||||
与 task-detail 共享布局,仅 Banner 主题色不同。
|
||||
主题色:callback=teal / priority=orange / relationship=pink
|
||||
step-0 重点验证 Banner 渐变色和按钮配色,其余快速对比。
|
||||
```
|
||||
|
||||
### 回归校验
|
||||
|
||||
```
|
||||
执行全量回归校验:<page>
|
||||
逐屏截取+审计,禁止修改源码。P0-P3 增加→标记"回归"。
|
||||
```
|
||||
63
_DEL/miniprogram-h5-conversion/steering/power-integration.md
Normal file
63
_DEL/miniprogram-h5-conversion/steering/power-integration.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Power 集成指南
|
||||
|
||||
> 4 个外部 Power 的调用方式 + MCP 连接规范。
|
||||
|
||||
---
|
||||
|
||||
## Power 清单与激活
|
||||
|
||||
| Power | 用途 | 激活 |
|
||||
|-------|------|------|
|
||||
| `wechat-miniprogram` | 小程序开发规范 | `kiroPowers activate wechat-miniprogram` → `view-layer.md` / `tdesign.md` / `builtin-components.md` |
|
||||
| `pixel-audit` | 像素间距测量 | `kiroPowers activate pixel-audit` → `readSteering("measure.md")` |
|
||||
| `power-playwright` | H5 截图 | `kiroPowers activate power-playwright` → `browser_run_code` |
|
||||
| `weixin-devtools` | MP 截图+操作 | `kiroPowers activate weixin-devtools` → `mcp_weixin_devtools_mcp_*` |
|
||||
|
||||
---
|
||||
|
||||
## 各阶段调用
|
||||
|
||||
### 页面开发
|
||||
|
||||
wechat-miniprogram → `view-layer.md` + `tdesign.md` + `builtin-components.md`
|
||||
|
||||
### 截图
|
||||
|
||||
H5:power-playwright → `browser_run_code`(DPR=1.5 context,详见 action-manual.md §一)
|
||||
MP:weixin-devtools → `relaunch` → `waitFor` → `screenshot`(+ `evaluate_script` / `get_page_snapshot` / `click`)
|
||||
|
||||
### 审计
|
||||
|
||||
pixel-audit → `readSteering("measure.md")`(rpx 换算精确公式 + 五种间距类型 + Tailwind→WXSS 映射表)
|
||||
测量工具:`uv run python scripts/ops/measure_gaps.py`
|
||||
锚点对比:`uv run python scripts/ops/anchor_compare.py`
|
||||
|
||||
> `image-compare` MCP 已移除(2026-03-12),禁止调用。
|
||||
|
||||
---
|
||||
|
||||
## MCP 连接规范
|
||||
|
||||
### weixin-devtools
|
||||
|
||||
- 只能用 wsEndpoint:`ws://127.0.0.1:9420`
|
||||
- 禁止 auto/launch/connect/discover 策略
|
||||
- 断开:`reconnect_devtools` → 3 次失败 → `mcp_recompile` + 5s
|
||||
- 单次调用最大等待 10 分钟
|
||||
|
||||
### Playwright
|
||||
|
||||
- 必须 `browser_run_code` 创建 DPR=1.5 context
|
||||
- 禁止 `browser_take_screenshot`(DPR=1)
|
||||
- 禁止 `browser_navigate` + `browser_evaluate` 分步流程
|
||||
|
||||
---
|
||||
|
||||
## 会话开始 MCP 就绪检查
|
||||
|
||||
```
|
||||
[ ] weixin-devtools → get_connection_status → 已连接
|
||||
[ ] Playwright → browser_run_code 可用
|
||||
[ ] 微信开发者工具已开启
|
||||
[ ] pixel-audit → 已激活
|
||||
```
|
||||
122
_DEL/miniprogram-h5-conversion/steering/user-guided.md
Normal file
122
_DEL/miniprogram-h5-conversion/steering/user-guided.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 半自动模式 & 新页面开发
|
||||
|
||||
> 合并原 user-guided.md / new-page.md / page-dev.md。
|
||||
> 硬性规则和开发参考值已在 steering `mp-h5-rules.md` + `mp-h5-dev.md` 中。
|
||||
|
||||
---
|
||||
|
||||
## 一、半自动模式
|
||||
|
||||
用户直接指定页面/区域/任务,AI 按需执行。
|
||||
|
||||
### 意图解析
|
||||
|
||||
解析用户输入,确定:目标页面 → 目标范围(整页/step/区域/组件)→ 任务类型(开发/修正/审计/验证)
|
||||
|
||||
意图不明确时追问一轮即可(不进入完整审问模式)。
|
||||
|
||||
### 按需加载
|
||||
|
||||
| 任务类型 | 加载 |
|
||||
|---------|------|
|
||||
| 视觉还原(截图+审计+修正) | `readSteering("action-manual.md")` |
|
||||
| 仅审计 | `readSteering("action-manual.md")` §二 |
|
||||
| 仅修正(已有审计报告) | `readSteering("action-manual.md")` §三+§四 |
|
||||
| 仅截图 | `readSteering("action-manual.md")` §一 |
|
||||
| 新页面开发 | 本文件 §二/§三 |
|
||||
|
||||
始终加载:`readSteering("power-integration.md")`
|
||||
|
||||
### 与批量模式的区别
|
||||
|
||||
- 不需要按批次顺序
|
||||
- 可跳过不相关步骤
|
||||
- 修正范围由用户指定
|
||||
- 完成后提示是否更新 PROGRESS.md
|
||||
|
||||
### 经验沉淀
|
||||
|
||||
发现新踩坑点 → 向用户提出建议 → 确认后录入 `docs/miniprogram-dev/05-lessons/pitfalls.md`
|
||||
|
||||
---
|
||||
|
||||
## 二、新页面开发(有 H5 原型)
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
1. 前置加载
|
||||
H5 源码 + design-tokens.json + icon-mapping.md + 交互说明
|
||||
激活 wechat-miniprogram Power(view-layer.md + tdesign.md)
|
||||
|
||||
2. H5 结构分析
|
||||
全局结构识别(safe-area/bottomNav/ai-float 等)
|
||||
CSS 风险特性扫描
|
||||
组件清单(TDesign 可覆盖 vs 自定义)
|
||||
|
||||
3. MP 页面骨架搭建(见 §四)
|
||||
|
||||
4. 视觉比对审计(推荐)
|
||||
→ readSteering("action-manual.md")
|
||||
```
|
||||
|
||||
### 必须遵守
|
||||
|
||||
- mock 数据必须与 H5 原型完全一致
|
||||
- TDesign 优先原则
|
||||
- 所有规则见 always-on steering `mp-h5-rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 三、新页面开发(无 H5 原型)
|
||||
|
||||
从 spec/PRD 直接开发,无视觉比对。
|
||||
|
||||
```
|
||||
1. 需求确认(用途、数据来源、导航关系、是否 tabBar)
|
||||
2. 设计参考(design-tokens.json + 现有页面风格 + TDesign 选型)
|
||||
3. 页面开发(见 §四)
|
||||
4. 功能验证(微信开发者工具预览 + 交互流程 + 空/加载/错误态)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、页面骨架搭建
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
apps/miniprogram/miniprogram/pages/<page>/
|
||||
├── <page>.wxml # 结构
|
||||
├── <page>.wxss # 样式
|
||||
├── <page>.ts # 逻辑 + mock
|
||||
└── <page>.json # 配置(navigationBarTitleText + usingComponents)
|
||||
```
|
||||
|
||||
### .json 配置模板
|
||||
|
||||
```json
|
||||
{
|
||||
"navigationBarTitleText": "页面标题",
|
||||
"usingComponents": {
|
||||
"t-button": "tdesign-miniprogram/button/button",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TS mock 数据
|
||||
|
||||
```typescript
|
||||
Page({
|
||||
data: { /* mock 数据,结构贴近真实 API */ },
|
||||
onLoad() {
|
||||
// TODO: 替换为真实 API 调用
|
||||
this.setData({ /* ... */ });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 完成检查
|
||||
|
||||
见 fileMatch steering `mp-h5-dev.md` 中的「新页面 Checklist」。
|
||||
130
_DEL/mp-h5-dev.md
Normal file
130
_DEL/mp-h5-dev.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
inclusion: fileMatch
|
||||
fileMatchPattern: "**/*.wxml,**/*.wxss,**/h5_ui/**,**/miniprogram/miniprogram/pages/**,**/miniprogram-dev/**"
|
||||
name: mp-h5-dev
|
||||
description: 小程序页面开发详细规范。读取 wxml/wxss/h5_ui/miniprogram 页面文件时自动加载。
|
||||
---
|
||||
|
||||
# 小程序页面开发详细规范
|
||||
|
||||
硬性规则见 always-on steering `mp-h5-rules.md`。本文件补充开发流程和详细参考值。
|
||||
|
||||
## 完整颜色灰阶(13 级)
|
||||
|
||||
| Token | Hex | 用途 |
|
||||
|-------|-----|------|
|
||||
| gray-1 | #f3f3f3 | 页面背景 |
|
||||
| gray-2 | #eeeeee | 分隔线 |
|
||||
| gray-3 | #e7e7e7 | 边框 |
|
||||
| gray-4 | #dcdcdc | 禁用态 |
|
||||
| gray-5 | #c5c5c5 | — |
|
||||
| gray-6 | #a6a6a6 | 辅助文字 |
|
||||
| gray-7 | #8b8b8b | TabBar 默认色 |
|
||||
| gray-8 | #777777 | — |
|
||||
| gray-9 | #5e5e5e | 次要文字 |
|
||||
| gray-10 | #4b4b4b | — |
|
||||
| gray-11 | #393939 | — |
|
||||
| gray-12 | #2c2c2c | — |
|
||||
| gray-13 | #242424 | 主文字 |
|
||||
|
||||
## border-radius 标准值
|
||||
|
||||
sm 8rpx(rounded) | md 16rpx(rounded-lg) | lg 24rpx(rounded-xl) | xl 32rpx(rounded-2xl) | 3xl 48rpx(rounded-3xl)
|
||||
|
||||
## 阴影标准值
|
||||
|
||||
lg: `0 8rpx 32rpx rgba(0,0,0,0.06)` | xl: `0 16rpx 48rpx rgba(0,0,0,0.08)`
|
||||
|
||||
## 固定高度元素(切断 rpx 取整累积漂移)
|
||||
|
||||
Tab 栏 84rpx | 筛选栏 120rpx | 列表项 96rpx(标准)/112rpx(含副标题) | 按钮 80rpx | 底部操作区 112rpx
|
||||
|
||||
## 页面模式(截图裁剪)
|
||||
|
||||
模式 A(系统 navBar,无需裁剪):board-finance, board-coach, board-customer, task-list, my-profile
|
||||
模式 B(自定义 navBar,裁剪头部 96px):task-detail 系列, coach-detail, customer-detail, performance, notes, chat, chat-history, customer-service-records, performance-records
|
||||
|
||||
## Sticky 高度
|
||||
|
||||
board-finance 116px | board-coach 110px | board-customer 110px | 其他 0
|
||||
|
||||
## 全局结构处理
|
||||
|
||||
| H5 元素 | MP 处理 |
|
||||
|---------|---------|
|
||||
| `.safe-area-top` | 去除 padding-top,MP 由 navigationBar 处理 |
|
||||
| `#bottomNav` | 不迁移,MP 用原生 tabBar |
|
||||
| `.ai-float-btn-container` | `wx:if="{{false}}"` |
|
||||
| `<dev-fab />` | `wx:if="{{false}}"` |
|
||||
| `::before/::after` 视觉元素 | 额外 `<view>` 绝对定位模拟 |
|
||||
| Tab 指示线 `::after` | 额外 `<view>` 模拟 |
|
||||
| 气泡尖角 `::after` | 绝对定位 `<view>` + `transform: rotate(45deg)` |
|
||||
|
||||
## 复杂视觉元素分层策略
|
||||
|
||||
| 复杂度 | 示例 | 方案 |
|
||||
|--------|------|------|
|
||||
| L1 简单 | 纯色标签、单色图标 | WXSS 原生 |
|
||||
| L2 中等 | 渐变标签、多层 gradient | WXSS 多层渐变 |
|
||||
| L3 复杂 | 伪元素纹理、SVG data URI | 导出为图片/SVG 文件 |
|
||||
| L4 不可迁移 | `background-clip:text`、`conic-gradient` | 降级为近似纯色 |
|
||||
|
||||
## Banner 迁移模板
|
||||
|
||||
```xml
|
||||
<view class="banner-bg">
|
||||
<view class="banner-texture"></view> <!-- 替代 ::before -->
|
||||
<view class="banner-glow"></view> <!-- 替代 ::after -->
|
||||
<view class="banner-content"><!-- 内容 --></view>
|
||||
</view>
|
||||
```
|
||||
|
||||
## SVG 处理决策树
|
||||
|
||||
1. TDesign 有等价图标 → `<t-icon name="xxx">`
|
||||
2. 品牌/自定义图标 → 导出到 `assets/icons/<name>.svg`,`<image>` 引用
|
||||
3. CSS 背景 `data:image/svg+xml` → 导出独立文件
|
||||
4. 简单几何图形 → WXSS border/transform 替代
|
||||
|
||||
## 不可消除差异白名单
|
||||
|
||||
字体渲染差异(Chromium vs WebView) | 行内元素高度偏小~7% | 抗锯齿差异 | 环比箭头(SVG vs 文字↑↓) | CSS 渐变文字→纯色
|
||||
|
||||
## CSS 风险特性速查
|
||||
|
||||
| 特性 | 出现页面 | 替代方案 |
|
||||
|------|---------|---------|
|
||||
| `backdrop-filter: blur()` | board-finance | `rgba(255,255,255,0.95)` |
|
||||
| `-webkit-background-clip: text` | board-customer | 纯色文字 |
|
||||
| `data:image/svg+xml` | reviewing, no-permission | 导出 PNG/SVG |
|
||||
| `position: fixed` | board-finance/coach/customer, task-list | `position: sticky` 或页面级 fixed |
|
||||
| `@font-face` 远程 | 所有页面 | 系统字体(推荐) |
|
||||
|
||||
## 新页面 Checklist
|
||||
|
||||
- [ ] 无 HTML 标签残留(div/span/p/a/img)
|
||||
- [ ] 内联 SVG 已提取为文件
|
||||
- [ ] 样式全部 WXSS(rpx 单位)
|
||||
- [ ] 不支持的 CSS 已替代
|
||||
- [ ] 事件 `bindtap`,传参 `data-*`
|
||||
- [ ] TDesign 组件在 .json 注册
|
||||
- [ ] 安全区适配(JS statusBarHeight)
|
||||
- [ ] `getDiagnostics` 零错误
|
||||
- [ ] `wx:key` 已添加
|
||||
- [ ] 布尔属性用 `{{}}` 包裹
|
||||
- [ ] mock 数据与 H5 一致
|
||||
|
||||
## 关联资源路径
|
||||
|
||||
| 资源 | 路径 |
|
||||
|------|------|
|
||||
| H5 原型 | `docs/h5_ui/pages/*.html` |
|
||||
| 设计 Token | `docs/h5_ui/design-tokens.json` |
|
||||
| 图标映射 | `docs/h5_ui/icon-mapping.md` |
|
||||
| 交互说明 | `docs/h5_ui/interactions/<page>.md` |
|
||||
| 截图产物 | `docs/h5_ui/compare/<page>/` |
|
||||
| MP 源码 | `apps/miniprogram/miniprogram/pages/` |
|
||||
| 迁移进度 | `docs/miniprogram-dev/04-audit/PROGRESS.md` |
|
||||
| 踩坑速查 | `docs/miniprogram-dev/05-lessons/pitfalls.md` |
|
||||
| 收敛模式 | `docs/miniprogram-dev/05-lessons/convergence-patterns.md` |
|
||||
| 页面结构速查 | `docs/miniprogram-dev/03-reference/page-structure-map.md` |
|
||||
57
_DEL/mp-h5-rules.md
Normal file
57
_DEL/mp-h5-rules.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: mp-h5-rules
|
||||
description: H5→微信小程序转换硬性规则(核心约束,始终生效)
|
||||
---
|
||||
|
||||
# H5 → 微信小程序转换硬性规则
|
||||
|
||||
本规则在所有涉及小程序前端开发的 session 中始终生效。详细操作手册见 Power `miniprogram-h5-conversion`。
|
||||
|
||||
## 视觉真相
|
||||
|
||||
- `docs/h5_ui/pages/*.html` 是唯一视觉参考,任何偏离需用户确认
|
||||
- 颜色/间距/字号标准值见 `docs/h5_ui/design-tokens.json`,禁止凭记忆硬编码
|
||||
|
||||
## 标签映射(严禁在 WXML 中使用 HTML 标签)
|
||||
|
||||
`<div>`→`<view>` | `<span>/<p>`→`<text>` | `<img>`→`<image mode="">` | `<svg>`内联→`<image src="xx.svg">` | `<button>`→`<t-button>` | `<input>`→`<t-input>`
|
||||
|
||||
## rpx 换算
|
||||
|
||||
- 精确公式:`rpx = px × 2 × 0.875`(取偶数)
|
||||
- 圆角例外:`border-radius = px × 2`
|
||||
- Tailwind 字号必须同步 line-height:text-xs→22/28rpx | text-sm→24/34rpx | text-base→28/42rpx | text-lg→32/48rpx | text-xl→36/48rpx | text-2xl→42/56rpx
|
||||
|
||||
## 颜色灰阶(禁止使用 #333/#666/#999 等非标准值)
|
||||
|
||||
gray-1 #f3f3f3(页面背景) | gray-6 #a6a6a6(辅助文字) | gray-9 #5e5e5e(次要文字) | gray-13 #242424(主文字) | primary #0052d9 | success #00a870 | warning #ed7b2f | error #e34d59
|
||||
|
||||
## TDesign 优先
|
||||
|
||||
凡 TDesign 组件能覆盖的 UI 元素必须使用 TDesign。`t-button icon="xxx"` 只支持内置图标,品牌图标用 `<view>+<image>` 组合。
|
||||
|
||||
## 不支持的 CSS
|
||||
|
||||
`backdrop-filter`→`rgba()` | `::before/::after`→额外`<view>`绝对定位 | `background-clip:text`→纯色 | `data:image/svg+xml`→导出独立文件 | `*`通配符→逐个设置
|
||||
|
||||
## 截图规则
|
||||
|
||||
- H5:必须 `browser_run_code` 创建 DPR=1.5 context(禁止 `browser_take_screenshot`/分步流程)
|
||||
- MP:导航一律 `relaunch`(禁止 `switch_tab`/`navigate_to`);模式 B(自定义 navBar)裁剪头部 96px
|
||||
- 双端对比基准:645×1128
|
||||
- 截图后禁止用 PIL/pixelmatch/image-compare 做像素 diff,改用结构化拆解+逐级测量+偏差审计
|
||||
- `image-compare` MCP 已移除(2026-03-12),禁止调用
|
||||
|
||||
## 偏差判定
|
||||
|
||||
≤2rpx 通过 | >2rpx且≤4rpx 警告(P4-P7) | >4rpx 不通过(P0-P3)
|
||||
|
||||
## 事件与数据
|
||||
|
||||
- 传参用 `data-*`,取值 `e.detail.value`,阻止冒泡用 `catchtap`
|
||||
- `this.setData()` 驱动视图,布尔属性用 `{{}}`,列表加 `wx:key`
|
||||
- mock 数据必须与 H5 硬编码值完全一致,标记 `// TODO: 替换为真实 API 调用`
|
||||
|
||||
## 安全区
|
||||
|
||||
JS `wx.getSystemInfoSync().statusBarHeight` 动态 padding-top,禁止 `env(safe-area-inset-top)`
|
||||
148
_DEL/pitfalls.md
Normal file
148
_DEL/pitfalls.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 踩坑速查与迁移经验
|
||||
|
||||
> 经 User 确认后录入的实践经验。AI 发现新踩坑点时先提出建议,User 确认后再录入。
|
||||
> 按类别组织,每条记录包含:场景、问题、解决方案、影响页面。
|
||||
|
||||
---
|
||||
|
||||
## WXML 模板
|
||||
|
||||
### 禁止在 `{{}}` 中调用 JS 方法
|
||||
- 场景:WXML 模板中使用 `.toFixed()`、`.map()` 等
|
||||
- 问题:小程序模板不支持 JS 方法调用
|
||||
- 方案:用 WXS 模块 `utils/format.wxs`
|
||||
- 影响:所有页面
|
||||
|
||||
### TabBar 页面跳转必须用 `wx.switchTab`
|
||||
- 场景:从非 tabBar 页面跳转到 task-list、board-finance、my-profile
|
||||
- 问题:`navigateTo` 会静默失败
|
||||
- 方案:tabBar 页面一律用 `wx.switchTab`
|
||||
- 影响:所有涉及 tabBar 跳转的页面
|
||||
|
||||
---
|
||||
|
||||
## 样式与布局
|
||||
|
||||
### 一屏页面用 `height: 100vh` 而非 `min-height`
|
||||
- 场景:login、reviewing、no-permission 等单屏页面
|
||||
- 问题:`min-height: 100vh` 在某些情况下不生效
|
||||
- 方案:`height: 100vh` + `box-sizing: border-box`
|
||||
- 影响:login, reviewing, no-permission
|
||||
|
||||
### 状态栏适配禁止用 `env(safe-area-inset-top)`
|
||||
- 场景:自定义 navBar 页面的顶部 padding
|
||||
- 问题:小程序不支持 `env()` CSS 函数
|
||||
- 方案:JS `wx.getSystemInfoSync().statusBarHeight` 动态设置
|
||||
- 影响:所有自定义 navBar 页面
|
||||
|
||||
### `padding-top + 100vh` 必须配合 `box-sizing: border-box`
|
||||
- 场景:有顶部 padding 的全屏页面
|
||||
- 问题:不加 box-sizing 会导致底部内容溢出
|
||||
- 方案:始终添加 `box-sizing: border-box`
|
||||
- 影响:所有全屏页面
|
||||
|
||||
---
|
||||
|
||||
## TDesign 组件
|
||||
|
||||
### `t-button icon` 只支持内置图标
|
||||
- 场景:按钮中使用品牌图标(微信 logo 等)
|
||||
- 问题:`icon` 属性只接受 TDesign 内置图标名
|
||||
- 方案:用 `<view>` + `<image>` 组合替代
|
||||
- 影响:login 页面微信登录按钮
|
||||
|
||||
### TDesign 默认样式优先级高
|
||||
- 场景:需要大幅覆盖 TDesign 组件样式
|
||||
- 问题:CSS 变量和外部样式类不够用时,`!important` 也可能不生效
|
||||
- 方案:直接用原生 `<view>` 实现,绕开样式干扰
|
||||
- 影响:视具体组件
|
||||
|
||||
---
|
||||
|
||||
## 资源引用
|
||||
|
||||
### 所有 H5 内联 SVG 必须导出为独立文件
|
||||
- 场景:H5 中直接写在 HTML 内的 `<svg>` 元素
|
||||
- 问题:小程序不支持内联 SVG
|
||||
- 方案:导出为 `.svg` 文件存放 `assets/icons/`,用 `<image>` 引用
|
||||
- 影响:所有含内联 SVG 的页面
|
||||
|
||||
### 引用不存在的图片会 500 错误
|
||||
- 场景:WXML 中 `<image src>` 指向不存在的文件
|
||||
- 问题:不是 404 而是 500 错误,难以排查
|
||||
- 方案:用 CSS 渐变、emoji、`<t-icon>` 替代;或确保文件存在
|
||||
- 影响:所有页面
|
||||
|
||||
---
|
||||
|
||||
## 截图与对比
|
||||
|
||||
### rpx 取整导致累积高度偏移
|
||||
- 场景:多屏长页面的中间屏截图对比
|
||||
- 问题:MP 页面总高度比 H5 短 ~10%,中间屏内容窗口错位
|
||||
- 方案:使用锚点对齐法(按元素位置对齐而非 scrollTop 数值)
|
||||
- 影响:board-finance, performance 等长页面
|
||||
- 发现日期:2026-03-10
|
||||
|
||||
### AI 浮动按钮动画造成随机差异
|
||||
- 场景:截图时 ai-float-button 的 gradientShift 动画处于不同帧
|
||||
- 问题:每次截图差异 ~1%
|
||||
- 方案:双端截图前隐藏(H5 用 JS,MP 用 `wx:if="{{false}}"`)
|
||||
- 影响:所有含 ai-float-button 的页面
|
||||
- 发现日期:2026-03-10
|
||||
|
||||
### 禁止跨维度套用布局假设,H5 原型是唯一视觉真相
|
||||
- 场景:board-customer "最专一"卡片的 `.loyal-table` 布局
|
||||
- 问题:其他维度(最应召回等)的 `card-mid-row` / `card-assistant-row` 有 `ml-11`(80rpx 对齐头像右侧),开发时"合理推断"表格也应该加 `margin-left: 80rpx`。但 H5 原型中表格容器没有 `ml-11`,是从卡片 padding 开始的(left=16px)。Playwright 实测才发现偏差
|
||||
- 方案:每个维度的每个组件都必须回到 H5 HTML 逐一确认 Tailwind 类,禁止从其他维度"类推"。不确定时用 Playwright `getBoundingClientRect()` 实测 H5 元素位置
|
||||
- 影响:所有多维度页面(board-customer 8 维度、board-coach 4 维度)
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### H5 与 MP mock 数据不同,像素级 diff 需结合结构比对
|
||||
- 场景:用 PIL/numpy 或 image-compare 对比 H5 和 MP 截图
|
||||
- 问题:H5 原型使用硬编码 mock 数据(如孙先生/王先生/李女士),MP 使用后端返回的测试数据(王先生/李女士/张先生),文字内容不同导致像素 diff 高达 11%,18 个分段覆盖全页面,无法区分"数据差异"和"样式差异"
|
||||
- 方案:像素级 diff 仅用于发现大面积结构偏移(窗口错位、整块缺失)。样式还原度验证的主要方法是 Tailwind 类→rpx 值逐项比对表,逐一确认每个 CSS 属性的换算结果
|
||||
- 影响:所有页面的截图对比流程
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### Tailwind→WXSS 映射必须逐项建表验证
|
||||
- 场景:将 H5 Tailwind 类翻译为小程序 WXSS
|
||||
- 问题:凭记忆或经验直接写 WXSS 值容易出错——`font-medium` 是 500 不是 600,`gap-1` 是 4px 不是 2px,`space-y-2` 是 8px 不是 4px。错误在视觉上不明显但会累积
|
||||
- 方案:对每个组件建立完整的对照表(H5 Tailwind 类 → 计算值 px → rpx 换算 → MP WXSS 值),逐行核对。参考速查表:`2→4, 4→8, 6→10, 8→14, 10→18, 12→22, 14→24, 16→28, 20→36, 24→42`
|
||||
- 影响:所有页面的 WXSS 编写
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### 相邻元素间距叠加导致高度翻倍
|
||||
- 场景:经营一览的 `.overview-grid-2` 底部 padding 32rpx + `.ai-insight-section` margin-top 32rpx,间距翻倍为 64rpx
|
||||
- 问题:H5 中 grid 无 margin-bottom,间距完全由下方元素的 `mt-4`(16px) 提供。MP 中两端都加了间距,导致块间距翻倍、整体高度偏高
|
||||
- 方案:校对间距时必须确认「这段间距是谁提供的」——上方元素的 padding-bottom 还是下方元素的 margin-top。用 Playwright `getComputedStyle` 实测 H5 两个相邻元素的 margin/padding,确认间距归属后只在一端设置
|
||||
- 影响:所有包含多个子区块的板块(经营一览、预收资产等)
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### 用 Playwright 实测 H5 比手动解析 Tailwind 更可靠
|
||||
- 场景:H5 原型文件是单行 minified HTML,手动读取 Tailwind 类名容易遗漏或误判
|
||||
- 问题:Tailwind 类名组合后的实际渲染值不直观(如 `p-4` 在嵌套容器中可能被覆盖,`space-y-2` 的实际 gap 取决于子元素数量),手动推算容易出错
|
||||
- 方案:通过 `browser_run_code` 创建 375×812 viewport + DPR 1.5 的 context,用 `getComputedStyle` + `getBoundingClientRect` 直接拿到渲染后的实际 px 值,再按换算规则转 rpx。这个方法应作为所有页面校对的标准流程
|
||||
- 影响:所有页面的 WXSS 校对流程
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
### Tailwind 字号类自带 line-height,WXSS 必须同步补齐
|
||||
- 场景:校对经营一览的现金流水概览 4 个块,H5 明显比 MP 高
|
||||
- 问题:Tailwind 的 `text-xs`/`text-sm`/`text-lg`/`text-xl` 等字号类捆绑了特定的 `line-height`(如 `text-lg` = `font-size: 18px; line-height: 28px`),但 WXSS 只写了 `font-size` 没写 `line-height`,小程序默认行高约 1.2,远小于 Tailwind 的行高,导致每行文字占据的垂直空间不足,块整体矮了一截
|
||||
- 方案:每个 Tailwind 字号类转 WXSS 时,必须同时写 `font-size` 和 `line-height`。完整对照表(87.5% 缩放):
|
||||
|
||||
| Tailwind | font-size (px) | line-height (px) | WXSS font-size | WXSS line-height |
|
||||
|----------|---------------|------------------|----------------|-----------------|
|
||||
| `text-xs` | 12 | 16 | 22rpx | 28rpx |
|
||||
| `text-sm` | 14 | 20 | 24rpx | 34rpx |
|
||||
| `text-base` | 16 | 24 | 28rpx | 42rpx |
|
||||
| `text-lg` | 18 | 28 | 32rpx | 48rpx |
|
||||
| `text-xl` | 20 | 28 | 36rpx | 48rpx |
|
||||
| `text-2xl` | 24 | 32 | 42rpx | 56rpx |
|
||||
|
||||
- 影响:所有页面的所有文字元素
|
||||
- 发现日期:2026-03-12
|
||||
|
||||
---
|
||||
|
||||
> 新增经验请按以上格式录入,包含:场景、问题、解决方案、影响页面、发现日期(可选)。
|
||||
43
_DEL/pixel-audit/POWER.md
Normal file
43
_DEL/pixel-audit/POWER.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: "pixel-audit"
|
||||
displayName: "H5→MP 像素测量与 rpx 换算"
|
||||
description: "H5 页面结构化拆解、逐级元素测量、px→rpx 换算、偏差审计方法论。用于迁移前 H5 审计和迁移后双端对比。"
|
||||
keywords: ["rpx", "间距测量", "measure", "像素", "pixel", "px", "换算", "Tailwind", "wxss", "间距", "gap", "spacing", "审计", "拆解", "映射表", "偏差"]
|
||||
author: "NeoZQYY"
|
||||
---
|
||||
|
||||
# H5→MP 像素测量与 rpx 换算
|
||||
|
||||
精确测量 H5 页面元素尺寸与间距,换算为小程序 rpx 值。
|
||||
提供结构化拆解→逐级测量→偏差审计的完整方法论,指导 H5→MP 迁移和双端对比。
|
||||
|
||||
核心工具:
|
||||
- `scripts/ops/measure_gaps.py`(Playwright headless 测量)
|
||||
- Playwright MCP `browser_evaluate`(等效 JS 测量)
|
||||
|
||||
## rpx 速查
|
||||
|
||||
```
|
||||
精确公式:rpx = ceil(px × 1.7442 / 2) × 2
|
||||
简化心算:rpx ≈ px × 2 × 0.875 取偶(6px/14px 处有 2rpx 偏差)
|
||||
圆角:border-radius = px × 2
|
||||
|
||||
2→4 4→8 6→12 8→14 10→18 12→22
|
||||
14→26 16→28 20→36 24→42 28→50 32→56
|
||||
```
|
||||
|
||||
## 偏差判定标准
|
||||
|
||||
| rpx 偏差 | 级别 | 处理 |
|
||||
|----------|------|------|
|
||||
| ≤ 2rpx | 通过 | 无需修正 |
|
||||
| > 2rpx 且 ≤ 4rpx | 警告(P4-P7) | 像素级精调 |
|
||||
| > 4rpx | 不通过(P0-P3) | 必须修正 |
|
||||
|
||||
## Steering
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `measure.md` | 结构化拆解方法论、H5 审计映射表、逐级测量流程、偏差审计规则、rpx 换算公式与速查表、measure_gaps.py 用法、MP 反向验证 |
|
||||
|
||||
加载:激活 Power → `readSteering("measure.md")`
|
||||
345
_DEL/pixel-audit/steering/measure.md
Normal file
345
_DEL/pixel-audit/steering/measure.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# 间距测量与 rpx 换算
|
||||
|
||||
## 核心换算公式
|
||||
|
||||
```
|
||||
小程序 viewport 宽 = 750rpx = 430px
|
||||
RPX_FACTOR = 750 / 430 = 1.7442
|
||||
|
||||
精确公式:rpx = ceil(H5_px × 1.7442 / 2) × 2
|
||||
简化心算:rpx ≈ H5_px × 2 × 0.875 取偶(6px、14px 处有 2rpx 偏差,以精确公式为准)
|
||||
|
||||
圆角例外:border-radius = px × 2(不乘系数),数值更整洁
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结构化审计方法论(三阶段)
|
||||
|
||||
本方法论适用于两个场景:
|
||||
- **迁移前**:对 H5 页面做详细审计,产出映射表,指导 MP 开发
|
||||
- **迁移后**:基于映射表做双端对比,逐元素验证偏差
|
||||
|
||||
### 阶段 0:结构拆解(自顶向下)
|
||||
|
||||
目标:将页面拆解为树状元素清单,建立 H5↔MP 映射表。
|
||||
|
||||
#### 0.1 拆解流程
|
||||
|
||||
1. 读取 H5 源码(`docs/h5_ui/pages/<page>.html`),识别页面首层容器
|
||||
2. 对每个首层容器,递归拆解子元素,直到到达叶子节点
|
||||
3. 叶子节点定义:**单一样式属性可描述**的最小视觉单元
|
||||
- 文字 span(font-size + color + weight 即可描述)
|
||||
- 图标 image / SVG
|
||||
- 纯色/渐变 view(background 即可描述)
|
||||
- 分隔线(border 即可描述)
|
||||
4. 为每个节点记录:层级深度、CSS 选择器、Tailwind 类名、角色描述
|
||||
|
||||
#### 0.2 H5 审计映射表(迁移前必做)
|
||||
|
||||
拆解完成后,产出结构化映射表。此表是后续所有工作的基准:
|
||||
|
||||
```markdown
|
||||
## <page> H5 审计映射表
|
||||
|
||||
| # | 层级 | 元素描述 | H5 选择器 | Tailwind 类名 | 理论 rpx 值 | MP 选择器 | 备注 |
|
||||
|---|------|---------|-----------|--------------|------------|-----------|------|
|
||||
| 1 | L0 | 页面根容器 | .page | px-4 bg-gray-1 | padding: 0 14rpx | .page | — |
|
||||
| 2 | L1 | Banner 区域 | .banner-bg | h-40 rounded-2xl | height: 140rpx, radius: 32rpx | .banner-bg | 渐变需简化 |
|
||||
| 3 | L1 | 统计卡片 | .summary-card | p-4 rounded-xl | padding: 28rpx, radius: 24rpx | .summary-card | — |
|
||||
| 4 | L2 | 卡片标题 | .summary-card h3 | text-base font-semibold | font-size: 28rpx, weight: 600 | .card-title | — |
|
||||
| 5 | L2 | 金额数值 | .amount | text-2xl font-semibold | font-size: 42rpx, weight: 600 | .amount | — |
|
||||
| ... | ... | ... | ... | ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
列说明:
|
||||
- **层级**:L0=页面根, L1=首层区块, L2=区块内组件, L3=组件内元素...
|
||||
- **理论 rpx 值**:从 Tailwind 类名查速查表换算,不是实测值
|
||||
- **MP 选择器**:迁移前留空,迁移后填入对应的 WXML 选择器
|
||||
- **备注**:CSS 风险特性、不可消除差异、特殊处理方案
|
||||
|
||||
#### 0.3 结构完整性校验
|
||||
|
||||
映射表同时用于校验 MP 端结构完整性:
|
||||
|
||||
| 校验项 | 方法 | 判定 |
|
||||
|--------|------|------|
|
||||
| 元素缺失 | H5 映射表中有、MP 选择器为空或不存在 | P0 |
|
||||
| 元素多余 | MP 中存在映射表未列出的视觉元素 | 检查是否为调试元素,否则标注 |
|
||||
| 顺序错误 | MP 元素 DOM 顺序与映射表不一致 | P0 |
|
||||
| 嵌套层级不一致 | MP 的父子关系与 H5 不同 | P1(可能影响间距计算) |
|
||||
|
||||
校验时机:
|
||||
- **迁移前**:映射表产出后,MP 选择器列全部留空,无需校验
|
||||
- **迁移后**:填入 MP 选择器,逐行校验存在性和顺序
|
||||
- **对比审计时**:先跑结构完整性校验,通过后再进入测量阶段
|
||||
|
||||
|
||||
### 阶段 1:逐级测量(自底向上)
|
||||
|
||||
目标:从叶子节点开始,逐级向上测量每个元素的尺寸和间距。
|
||||
|
||||
#### 1.1 测量顺序
|
||||
|
||||
```
|
||||
叶子节点(L3/L4)→ 组件容器(L2)→ 区块容器(L1)→ 页面根(L0)
|
||||
```
|
||||
|
||||
自底向上的原因:内层元素的实测尺寸决定了外层容器的实际高度。先测内层,才能判断外层的 padding/gap 是否正确。
|
||||
|
||||
#### 1.2 每个元素的标准测量维度
|
||||
|
||||
| 维度 | 测量方法 | 适用节点 |
|
||||
|------|---------|---------|
|
||||
| width / height | `getBoundingClientRect()` | 所有 |
|
||||
| padding(四方向) | `getComputedStyle().paddingTop/Right/Bottom/Left` | 容器节点 |
|
||||
| margin(四方向) | `getComputedStyle().marginTop/Right/Bottom/Left` | 所有 |
|
||||
| font-size | `getComputedStyle().fontSize` | 文字节点 |
|
||||
| line-height | `getComputedStyle().lineHeight` | 文字节点 |
|
||||
| border-radius | `getComputedStyle().borderRadius` | 有圆角的节点 |
|
||||
| 与父容器的 offset | `child.rect.top - parent.rect.top - parent.paddingTop` | 所有子节点 |
|
||||
| 与相邻兄弟的 gap | `next.rect.top - current.rect.bottom` | 同级相邻节点 |
|
||||
|
||||
#### 1.3 H5 端测量(Playwright MCP / measure_gaps.py)
|
||||
|
||||
方式 A:Playwright MCP `browser_evaluate`
|
||||
|
||||
```javascript
|
||||
// 测量单个元素的完整属性
|
||||
async (selector) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return { error: 'not found' };
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
selector,
|
||||
width: rect.width, height: rect.height,
|
||||
top: rect.top, left: rect.left,
|
||||
paddingTop: parseFloat(cs.paddingTop),
|
||||
paddingRight: parseFloat(cs.paddingRight),
|
||||
paddingBottom: parseFloat(cs.paddingBottom),
|
||||
paddingLeft: parseFloat(cs.paddingLeft),
|
||||
marginTop: parseFloat(cs.marginTop),
|
||||
marginBottom: parseFloat(cs.marginBottom),
|
||||
fontSize: parseFloat(cs.fontSize),
|
||||
lineHeight: cs.lineHeight === 'normal' ? 'normal' : parseFloat(cs.lineHeight),
|
||||
fontWeight: cs.fontWeight,
|
||||
borderRadius: cs.borderRadius,
|
||||
color: cs.color,
|
||||
backgroundColor: cs.backgroundColor
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
> Playwright MCP 不支持 `file://` 协议。需先起本地 HTTP 服务:
|
||||
> `python -m http.server 8765 --directory docs/h5_ui/pages`
|
||||
> 然后用 `browser_navigate("http://localhost:8765/<page>.html")` 访问。
|
||||
|
||||
方式 B:`scripts/ops/measure_gaps.py`(批量测量)
|
||||
|
||||
```bash
|
||||
uv run python scripts/ops/measure_gaps.py <page> --selectors "<sel1>" "<sel2>" ...
|
||||
```
|
||||
|
||||
#### 1.4 MP 端测量(SelectorQuery)
|
||||
|
||||
```javascript
|
||||
// 微信开发者工具 MCP evaluate_script
|
||||
async (selector) => {
|
||||
return new Promise(resolve => {
|
||||
const query = wx.createSelectorQuery();
|
||||
query.select(selector).boundingClientRect(rect => {
|
||||
query.select(selector).fields({
|
||||
computedStyle: ['paddingTop','paddingRight','paddingBottom','paddingLeft',
|
||||
'marginTop','marginBottom','fontSize','lineHeight',
|
||||
'fontWeight','borderRadius','color','backgroundColor']
|
||||
}, style => {
|
||||
resolve({ rect, style });
|
||||
}).exec();
|
||||
}).exec();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 测量结果记录格式
|
||||
|
||||
每个元素的测量结果追加到映射表:
|
||||
|
||||
```markdown
|
||||
| # | 元素 | 属性 | H5 实测(px) | 理论 rpx | MP 实测(rpx) | 偏差 | 级别 |
|
||||
|---|------|------|------------|---------|-------------|------|------|
|
||||
| 4 | 卡片标题 | font-size | 16px | 28rpx | 28rpx | 0 | ✅ |
|
||||
| 4 | 卡片标题 | line-height | 24px | 42rpx | 36rpx | -6rpx | P3 |
|
||||
| 4 | 卡片标题 | font-weight | 600 | 600 | 400 | — | P6 |
|
||||
| 3 | 统计卡片 | padding-top | 16px | 28rpx | 30rpx | +2rpx | ✅ |
|
||||
| 3→4 | 卡片→标题 gap | margin-top | 0px | 0rpx | 0rpx | 0 | ✅ |
|
||||
```
|
||||
|
||||
|
||||
### 阶段 2:偏差审计
|
||||
|
||||
目标:基于测量结果,逐属性判定偏差级别,输出结构化审计报告。
|
||||
|
||||
#### 2.1 偏差判定标准
|
||||
|
||||
| rpx 偏差 | 级别 | 说明 |
|
||||
|----------|------|------|
|
||||
| 0 | ✅ 通过 | 完全匹配 |
|
||||
| ≤ 2rpx | ✅ 通过 | rpx 取偶导致的不可避免误差 |
|
||||
| > 2rpx 且 ≤ 4rpx | ⚠️ 警告(P4-P7) | 像素级精调可修复 |
|
||||
| > 4rpx | ❌ 不通过(P0-P3) | 必须修正 |
|
||||
|
||||
特殊属性判定:
|
||||
- **font-weight**:不用 rpx 偏差,直接比对数值(400/500/600/700),不一致即 P6
|
||||
- **color**:不用 rpx 偏差,比对 hex 值,不在标准色表中即 P5
|
||||
- **border-radius**:使用 `px × 2` 公式(不乘 1.7442),偏差 > 2rpx 即 P4
|
||||
- **结构缺失/顺序错误**:直接 P0,不进入测量
|
||||
|
||||
#### 2.2 审计报告模板
|
||||
|
||||
```markdown
|
||||
# <page> 结构化审计报告
|
||||
|
||||
## 基本信息
|
||||
- 页面:<page>
|
||||
- 审计日期:YYYY-MM-DD
|
||||
- H5 元素总数:N
|
||||
- MP 元素总数:M
|
||||
- 结构完整性:✅ 通过 / ❌ 缺失 K 个元素
|
||||
|
||||
## 结构完整性校验
|
||||
| # | H5 元素 | MP 状态 | 问题 |
|
||||
|---|---------|---------|------|
|
||||
| 7 | .env-arrow svg | ❌ 缺失 | MP 不支持 inline SVG,需导出 |
|
||||
|
||||
## 偏差汇总
|
||||
| 级别 | 数量 | 占比 |
|
||||
|------|------|------|
|
||||
| ✅ 通过 | XX | XX% |
|
||||
| ⚠️ P4-P7 | XX | XX% |
|
||||
| ❌ P0-P3 | XX | XX% |
|
||||
|
||||
## 逐元素偏差明细
|
||||
(按映射表 # 排序,仅列出有偏差的属性)
|
||||
|
||||
| # | 元素 | 属性 | H5 值 | 理论 rpx | MP 值 | 偏差 | 级别 | 修正建议 |
|
||||
|---|------|------|-------|---------|-------|------|------|---------|
|
||||
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
|
||||
|
||||
## 不可消除差异(不计入偏差)
|
||||
- 字体渲染差异(Noto Sans SC vs 系统字体)
|
||||
- 行内元素高度系统性偏小 ~7%
|
||||
- 抗锯齿差异
|
||||
```
|
||||
|
||||
#### 2.3 迁移前审计 vs 迁移后对比
|
||||
|
||||
| 维度 | 迁移前审计 | 迁移后对比 |
|
||||
|------|-----------|-----------|
|
||||
| 输入 | H5 源码 | H5 源码 + MP 源码 |
|
||||
| 映射表 MP 列 | 留空 | 填入实际选择器 |
|
||||
| 测量端 | 仅 H5 | H5 + MP 双端 |
|
||||
| 产出 | 映射表 + 理论 rpx 值 | 偏差审计报告 |
|
||||
| 用途 | 指导 WXSS 编写 | 验证迁移质量 |
|
||||
|
||||
迁移前审计产出的映射表直接作为迁移后对比的输入——MP 开发完成后,填入 MP 选择器列,跑结构完整性校验 + 逐级测量,即可得到偏差报告。
|
||||
|
||||
---
|
||||
|
||||
## Tailwind → WXSS 完整速查表
|
||||
|
||||
### 字号与行高
|
||||
|
||||
| Tailwind | H5 px | rpx | 说明 |
|
||||
|----------|-------|-----|------|
|
||||
| text-xs | 12px / 16px | 22rpx / 28rpx | 字号 / 行高 |
|
||||
| text-sm | 14px / 20px | 26rpx / 36rpx | 字号 / 行高 |
|
||||
| text-base | 16px / 24px | 28rpx / 42rpx | 字号 / 行高 |
|
||||
| text-lg | 18px / 28px | 32rpx / 50rpx | 字号 / 行高 |
|
||||
| text-xl | 20px / 28px | 36rpx / 50rpx | 字号 / 行高 |
|
||||
| text-2xl | 24px / 32px | 42rpx / 56rpx | 字号 / 行高 |
|
||||
|
||||
⚠️ Tailwind `text-*` 同时设置 font-size 和 line-height。MP 迁移时必须同时迁移两个属性,遗漏 line-height 会导致行高塌陷。
|
||||
|
||||
### 间距
|
||||
|
||||
| Tailwind | H5 px | rpx |
|
||||
|----------|-------|-----|
|
||||
| p-1 / gap-1 | 4px | 8rpx |
|
||||
| p-1.5 / gap-1.5 | 6px | 12rpx |
|
||||
| p-2 / gap-2 | 8px | 14rpx |
|
||||
| p-3 / gap-3 / space-y-3 | 12px | 22rpx |
|
||||
| p-4 / gap-4 | 16px | 28rpx |
|
||||
| p-5 / gap-5 | 20px | 36rpx |
|
||||
| p-6 / gap-6 | 24px | 42rpx |
|
||||
| p-8 / gap-8 | 32px | 56rpx |
|
||||
|
||||
⚠️ `space-y-N` 换算陷阱:严格按精确公式 `ceil(px × 1.7442 / 2) × 2`,不要心算跳步。
|
||||
例:`space-y-3` = 12px → 12×1.7442=20.93 → ceil(20.93/2)×2 = ceil(10.47)×2 = 22rpx(不是 20rpx)。
|
||||
|
||||
### 常用 px → rpx 对照
|
||||
|
||||
```
|
||||
2px → 4rpx 3px → 6rpx 4px → 8rpx
|
||||
5px → 10rpx 6px → 12rpx 8px → 14rpx
|
||||
10px → 18rpx 11px → 20rpx 12px → 22rpx
|
||||
14px → 26rpx 16px → 28rpx 18px → 32rpx
|
||||
20px → 36rpx 24px → 42rpx 28px → 50rpx
|
||||
32px → 56rpx 36px → 64rpx 40px → 70rpx
|
||||
44px → 78rpx 48px → 84rpx
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:measure_gaps.py 详细用法
|
||||
|
||||
路径:`scripts/ops/measure_gaps.py`
|
||||
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
# 测量页面内所有 .task-card 元素的尺寸和间距
|
||||
uv run python scripts/ops/measure_gaps.py task-list --selectors ".task-card"
|
||||
|
||||
# 测量多个选择器(按 DOM 顺序,计算相邻间距)
|
||||
uv run python scripts/ops/measure_gaps.py board-finance --selectors ".summary-header" ".summary-content" ".grid-cols-3"
|
||||
|
||||
# 指定两个元素的直接间距
|
||||
uv run python scripts/ops/measure_gaps.py task-list --pairs ".sticky-header" ".task-card:first-child"
|
||||
|
||||
# 页面中下方元素(需 scrollTop)
|
||||
uv run python scripts/ops/measure_gaps.py performance --selectors ".perf-section" --scroll 1200
|
||||
```
|
||||
|
||||
### 输出内容
|
||||
|
||||
- 元素尺寸表:top_px, h_px, paddingT/B, marginT/B, gap, fontSize, lineHeight, h_rpx
|
||||
- 相邻元素垂直间距表:gap_px 和 gap_rpx
|
||||
- audit.md 可直接粘贴的 Markdown 表格
|
||||
|
||||
### 常用 CSS 选择器快查
|
||||
|
||||
| 元素类型 | 选择器示例 |
|
||||
|---|---|
|
||||
| 页面内边距容器 | `.px-4`, `.px-6`, `[class*="px-"]` |
|
||||
| 卡片 | `.task-card`, `[class*="card"]` |
|
||||
| 列表项间距 | `.list-item`, `li`, `[class*="item"]` |
|
||||
| Sticky 头部 | `.sticky`, `.filter-bar`, `[class*="sticky"]` |
|
||||
| Banner | `.banner-bg`, `[class*="banner"]` |
|
||||
| 标签/徽章 | `.tag`, `.badge`, `[class*="tag"]` |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:图像反推验证(截图偏差定位)
|
||||
|
||||
当 diff 图显示某元素位置偏移时,用截图像素反推实际间距:
|
||||
|
||||
```
|
||||
实际间距(px) = diff 图中偏移像素数 ÷ DPR
|
||||
DPR = 1.5
|
||||
示例:diff 图中元素 A 比 H5 下移 9 像素
|
||||
实际偏差 = 9 / 1.5 = 6px → 查速查表 → 12rpx
|
||||
应调整 WXSS 中对应 margin/padding
|
||||
```
|
||||
145
_DEL/wechat-miniprogram/POWER.md
Normal file
145
_DEL/wechat-miniprogram/POWER.md
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: "wechat-miniprogram"
|
||||
displayName: "微信小程序官方文档"
|
||||
description: "微信小程序开发官方文档查询工具,覆盖框架、组件、API、服务端、自定义组件等全部开发知识。当需要查阅小程序开发细节时,按需加载对应领域的 steering 文件获取权威参考。"
|
||||
keywords: ["微信小程序", "miniprogram", "wxml", "wxss", "wx.request", "小程序组件", "小程序API", "Page", "Component", "getApp", "小程序生命周期", "小程序登录", "小程序支付", "weixin", "wechat", "tdesign", "t-button", "t-cell", "t-dialog", "t-toast", "t-navbar", "t-tabs", "t-popup", "t-input", "t-picker", "t-tag", "t-search", "t-empty", "t-loading", "t-skeleton", "t-tab-bar", "t-avatar", "t-badge", "t-image"]
|
||||
author: "NeoZQYY"
|
||||
---
|
||||
|
||||
# 微信小程序官方文档
|
||||
|
||||
## Overview
|
||||
|
||||
本 Power 是微信小程序官方开发文档的结构化知识库,覆盖框架核心概念、视图层、逻辑层、自定义组件、内置组件、前端 API、服务端 API 等全部开发领域。
|
||||
|
||||
当 AI 需要回答小程序相关问题时,应根据问题领域加载对应的 steering 文件,获取官方文档的权威内容,而非依赖可能过时的训练数据。
|
||||
|
||||
官方文档入口:https://developers.weixin.qq.com/miniprogram/dev/framework/
|
||||
|
||||
## 文档结构与 Steering 文件索引
|
||||
|
||||
以下 steering 文件按领域组织,按需加载:
|
||||
|
||||
### framework-core.md — 框架核心
|
||||
- 小程序框架概述(MINA 架构:逻辑层 + 视图层)
|
||||
- 小程序配置(app.json / page.json / sitemap.json)
|
||||
- 目录结构与文件类型(.wxml / .wxss / .js / .json)
|
||||
- 场景值、兼容性、基础库版本
|
||||
|
||||
### app-service.md — 逻辑层(App Service)
|
||||
- App() 注册程序、生命周期
|
||||
- Page() 注册页面、页面生命周期(onLoad/onShow/onReady/onHide/onUnload)
|
||||
- 页面路由(navigateTo/redirectTo/switchTab/reLaunch/navigateBack)
|
||||
- 模块化(require/module.exports)、文件作用域
|
||||
- API 调用方式(回调/Promise)
|
||||
- 页面间通信、EventChannel
|
||||
|
||||
### view-layer.md — 视图层(WXML / WXSS / WXS)
|
||||
- WXML 语法:数据绑定、列表渲染(wx:for)、条件渲染(wx:if)、模板(template)、引用(import/include)
|
||||
- WXSS 样式:rpx 单位、样式导入(@import)、选择器支持范围、内联样式
|
||||
- WXS(WeiXin Script):语法、模块、与 WXML 配合使用
|
||||
- 事件系统:冒泡/非冒泡事件、bind/catch/mut-bind、事件对象、dataset
|
||||
|
||||
### custom-component.md — 自定义组件
|
||||
- 创建自定义组件(json/wxml/wxss/js 四文件)
|
||||
- Component() 构造器:properties / data / methods / lifetimes / pageLifetimes / observers
|
||||
- 组件模板和样式(slot、样式隔离 styleIsolation)
|
||||
- 组件间通信(properties 传值、triggerEvent 事件、selectComponent)
|
||||
- behaviors(代码复用,类似 mixins)
|
||||
- 数据监听器(observers)
|
||||
- 纯数据字段(pureDataPattern)
|
||||
- 组件间关系(relations)
|
||||
- 抽象节点(componentGenerics)
|
||||
- 用 Component 构造器构造页面
|
||||
|
||||
### builtin-components.md — 内置组件
|
||||
- 视图容器:view / scroll-view / swiper / movable-view / cover-view
|
||||
- 基础内容:text / rich-text / progress / icon
|
||||
- 表单组件:button / input / textarea / picker / slider / switch / checkbox / radio / form
|
||||
- 导航:navigator
|
||||
- 媒体:image / video / camera / live-player / live-pusher
|
||||
- 地图:map
|
||||
- 画布:canvas
|
||||
- 开放能力:open-data / web-view / ad
|
||||
- 无障碍访问
|
||||
|
||||
### frontend-api.md — 前端 API
|
||||
- 基础:wx.canIUse / wx.env / 系统信息
|
||||
- 路由:wx.navigateTo / wx.redirectTo / wx.switchTab / wx.reLaunch / wx.navigateBack
|
||||
- 界面:wx.showToast / wx.showModal / wx.showLoading / wx.showActionSheet / 导航栏 / TabBar / 下拉刷新
|
||||
- 网络:wx.request / wx.uploadFile / wx.downloadFile / WebSocket / TCP/UDP
|
||||
- 数据缓存:wx.setStorage / wx.getStorage / wx.removeStorage
|
||||
- 媒体:图片(wx.chooseImage/wx.previewImage) / 录音 / 音频 / 视频 / 相机
|
||||
- 位置:wx.getLocation / wx.openLocation / wx.chooseLocation
|
||||
- 文件:wx.saveFile / FileSystemManager
|
||||
- 开放接口:登录(wx.login) / 用户信息 / 支付(wx.requestPayment) / 授权 / 设置 / 收货地址 / 发票 / 生物认证 / 微信运动 / 订阅消息
|
||||
- 设备:蓝牙 / NFC / Wi-Fi / 电话 / 加速计 / 罗盘 / 陀螺仪 / 剪贴板 / 屏幕亮度 / 振动
|
||||
- Worker / 第三方平台 / WXML 节点查询(SelectorQuery / IntersectionObserver)
|
||||
- 画布 Canvas API
|
||||
|
||||
### server-api.md — 服务端 API
|
||||
- 登录:code2Session(code 换 session_key + openid)
|
||||
- access_token 获取与管理
|
||||
- 用户信息解密(手机号、用户信息)
|
||||
- 消息推送(订阅消息、客服消息、模板消息)
|
||||
- 小程序码与二维码生成
|
||||
- 内容安全(文本/图片审核)
|
||||
- 数据分析
|
||||
- 物流助手
|
||||
- OCR / 直播 / 安全风控
|
||||
|
||||
### login-auth.md — 登录与鉴权(重点)
|
||||
- wx.login() 获取 code 的完整流程
|
||||
- 服务端 code2Session 换取 openid / session_key / unionid
|
||||
- 自定义登录态设计(token 方案)
|
||||
- wx.checkSession() 检查 session_key 有效性
|
||||
- 手机号快速验证(getPhoneNumber)
|
||||
- 用户信息获取(getUserProfile 已废弃 → 头像昵称填写能力)
|
||||
- 授权流程(wx.authorize / wx.getSetting / wx.openSetting)
|
||||
- 常见登录架构与安全注意事项
|
||||
|
||||
### best-practices.md — 开发最佳实践与常见坑
|
||||
- setData 性能优化(减少数据量、避免频繁调用)
|
||||
- 分包加载(subpackages / 独立分包 / 分包预下载)
|
||||
- 图片优化与懒加载
|
||||
- 页面栈管理(最多 10 层)
|
||||
- 小程序与 H5 差异(无 DOM/BOM、不支持 window/document)
|
||||
- TypeScript 支持
|
||||
- npm 支持与构建
|
||||
- 自定义 tabBar
|
||||
- 骨架屏
|
||||
- 常见审核被拒原因与规避
|
||||
|
||||
### tdesign.md — TDesign 小程序组件库
|
||||
- TDesign 安装与配置(npm 构建、TS 配置、基础库要求)
|
||||
- 完整组件列表(60+ 组件,按基础/导航/输入/数据展示/反馈分类)
|
||||
- 常用组件用法示例(Button / Input / Cell / Dialog / Toast / Popup / Tabs / Navbar / TabBar / Search / Empty / Loading / Skeleton)
|
||||
- 样式覆盖 4 种方式(style 属性 / 解除隔离 / 外部样式类 / CSS 变量)
|
||||
- 自定义主题(全局 Design Token / CSS Variables)
|
||||
- 深色模式适配
|
||||
|
||||
## 使用方式
|
||||
|
||||
当遇到小程序相关问题时:
|
||||
|
||||
1. 根据问题领域,使用 `readSteering` 加载对应的 steering 文件
|
||||
2. 如果不确定属于哪个领域,先加载 `framework-core.md` 了解整体架构
|
||||
3. 登录/鉴权问题优先加载 `login-auth.md`
|
||||
4. 组件用法问题加载 `builtin-components.md` 或 `custom-component.md`
|
||||
5. API 调用问题加载 `frontend-api.md` 或 `server-api.md`
|
||||
6. TDesign 组件用法/样式定制加载 `tdesign.md`
|
||||
|
||||
## 在线查询
|
||||
|
||||
如果 steering 文件中的信息不够详细,可以直接访问官方文档页面获取最新内容:
|
||||
|
||||
- 框架:https://developers.weixin.qq.com/miniprogram/dev/framework/
|
||||
- 组件:https://developers.weixin.qq.com/miniprogram/dev/component/
|
||||
- API:https://developers.weixin.qq.com/miniprogram/dev/api/
|
||||
- 服务端 API:https://developers.weixin.qq.com/miniprogram/dev/api-backend/
|
||||
- 配置参考:https://developers.weixin.qq.com/miniprogram/dev/reference/
|
||||
- 开发者工具:https://developers.weixin.qq.com/miniprogram/dev/devtools/devtools.html
|
||||
- TDesign 组件总览:https://tdesign.tencent.com/miniprogram/overview
|
||||
- TDesign 具体组件:https://tdesign.tencent.com/miniprogram/components/{组件名}
|
||||
|
||||
使用 `webFetch` 工具抓取对应页面即可获取最新文档内容。
|
||||
255
_DEL/wechat-miniprogram/steering/app-service.md
Normal file
255
_DEL/wechat-miniprogram/steering/app-service.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# 逻辑层(App Service)
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/
|
||||
|
||||
逻辑层使用 JavaScript 引擎运行,不在浏览器中,没有 `window`、`document` 等 Web API。
|
||||
|
||||
## App() — 注册小程序
|
||||
|
||||
```javascript
|
||||
App({
|
||||
onLaunch(options) {
|
||||
// 小程序初始化时触发,全局只触发一次
|
||||
// options.scene — 场景值
|
||||
// options.query — 启动参数
|
||||
// options.path — 启动页面路径
|
||||
},
|
||||
onShow(options) {
|
||||
// 小程序启动或从后台进入前台时触发
|
||||
},
|
||||
onHide() {
|
||||
// 小程序从前台进入后台时触发
|
||||
},
|
||||
onError(msg) {
|
||||
// 小程序发生脚本错误或 API 调用失败时触发
|
||||
console.error(msg)
|
||||
},
|
||||
onUnhandledRejection(res) {
|
||||
// 未处理的 Promise 拒绝事件
|
||||
console.warn(res.reason, res.promise)
|
||||
},
|
||||
onPageNotFound(res) {
|
||||
// 页面不存在时触发
|
||||
wx.redirectTo({ url: 'pages/...' })
|
||||
},
|
||||
onThemeChange({ theme }) {
|
||||
// 系统主题变更(dark / light)
|
||||
},
|
||||
globalData: {
|
||||
userInfo: null
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
获取 App 实例:
|
||||
```javascript
|
||||
const app = getApp()
|
||||
console.log(app.globalData)
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 不要在 `App()` 内调用 `getApp()`,使用 `this` 即可
|
||||
- 不要在 `onLaunch` 时调用 `getCurrentPages()`,此时 page 还没有生成
|
||||
|
||||
## Page() — 注册页面
|
||||
|
||||
```javascript
|
||||
Page({
|
||||
data: {
|
||||
text: 'Hello',
|
||||
array: [{ msg: '1' }, { msg: '2' }]
|
||||
},
|
||||
|
||||
// ===== 生命周期 =====
|
||||
onLoad(options) {
|
||||
// 页面加载时触发,options 为页面路由参数
|
||||
// 一个页面只会调用一次
|
||||
},
|
||||
onShow() {
|
||||
// 页面显示/切入前台时触发
|
||||
},
|
||||
onReady() {
|
||||
// 页面初次渲染完成时触发
|
||||
// 一个页面只会调用一次,代表页面已可以和视图层交互
|
||||
},
|
||||
onHide() {
|
||||
// 页面隐藏/切入后台时触发(如 navigateTo 或底部 tab 切换)
|
||||
},
|
||||
onUnload() {
|
||||
// 页面卸载时触发(如 redirectTo 或 navigateBack)
|
||||
},
|
||||
|
||||
// ===== 页面事件处理 =====
|
||||
onPullDownRefresh() {
|
||||
// 下拉刷新(需在 json 中开启 enablePullDownRefresh)
|
||||
// 处理完后调用 wx.stopPullDownRefresh()
|
||||
},
|
||||
onReachBottom() {
|
||||
// 上拉触底(可在 json 中设置 onReachBottomDistance)
|
||||
},
|
||||
onShareAppMessage(res) {
|
||||
// 用户点击右上角转发
|
||||
// res.from: 'button' 或 'menu'
|
||||
return {
|
||||
title: '自定义转发标题',
|
||||
path: '/pages/index/index',
|
||||
imageUrl: '' // 自定义图片路径
|
||||
}
|
||||
},
|
||||
onShareTimeline() {
|
||||
// 分享到朋友圈(基础库 2.11.3+)
|
||||
return { title: '', query: '', imageUrl: '' }
|
||||
},
|
||||
onPageScroll(res) {
|
||||
// 页面滚动时触发,res.scrollTop 为垂直滚动距离(px)
|
||||
// 注意:频繁触发,避免在此做复杂操作
|
||||
},
|
||||
onResize(res) {
|
||||
// 页面尺寸变化时触发(如屏幕旋转)
|
||||
},
|
||||
onTabItemTap(item) {
|
||||
// 当前是 tab 页时,点击 tab 时触发
|
||||
// item.index / item.pagePath / item.text
|
||||
},
|
||||
|
||||
// ===== 自定义方法 =====
|
||||
viewTap() {
|
||||
this.setData({
|
||||
text: 'Set some data for updating view.'
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 页面生命周期顺序
|
||||
|
||||
```
|
||||
onLoad → onShow → onReady → [onHide → onShow] → onUnload
|
||||
```
|
||||
|
||||
### setData 详解
|
||||
|
||||
```javascript
|
||||
// 基本用法
|
||||
this.setData({
|
||||
text: 'changed data'
|
||||
})
|
||||
|
||||
// 修改数组某一项
|
||||
this.setData({
|
||||
'array[0].msg': 'changed'
|
||||
})
|
||||
|
||||
// 修改对象某个属性
|
||||
this.setData({
|
||||
'object.key': 'value'
|
||||
})
|
||||
|
||||
// 带回调
|
||||
this.setData({ text: 'new' }, function() {
|
||||
// setData 引起的界面更新渲染完毕后的回调
|
||||
})
|
||||
```
|
||||
|
||||
**setData 性能注意事项**:
|
||||
- 数据量不宜过大(单次 setData 不超过 1MB,建议不超过 256KB)
|
||||
- 不要频繁调用(如 onPageScroll 中不要每次都 setData)
|
||||
- 只传需要更新的数据,不要整个 data 都传
|
||||
- 后台页面不要 setData(页面 onHide 后避免 setData)
|
||||
|
||||
## 页面路由
|
||||
|
||||
框架以栈的形式维护当前所有页面,最多 10 层。
|
||||
|
||||
| 路由方式 | 触发时机 | 路由前页面 | 路由后页面 |
|
||||
|----------|----------|-----------|-----------|
|
||||
| 初始化 | 小程序打开第一个页面 | | onLoad, onShow |
|
||||
| 打开新页面 | wx.navigateTo / `<navigator open-type="navigate">` | onHide | onLoad, onShow |
|
||||
| 页面重定向 | wx.redirectTo / `<navigator open-type="redirect">` | onUnload | onLoad, onShow |
|
||||
| 页面返回 | wx.navigateBack / 用户左上角返回 | onUnload | onShow |
|
||||
| Tab 切换 | wx.switchTab / `<navigator open-type="switchTab">` / 用户切换 Tab | | 各种情况 |
|
||||
| 重启动 | wx.reLaunch / `<navigator open-type="reLaunch">` | onUnload | onLoad, onShow |
|
||||
|
||||
```javascript
|
||||
// 保留当前页面,跳转到新页面(页面栈 +1)
|
||||
wx.navigateTo({ url: '/pages/detail/detail?id=1' })
|
||||
|
||||
// 关闭当前页面,跳转到新页面(页面栈不变)
|
||||
wx.redirectTo({ url: '/pages/detail/detail?id=1' })
|
||||
|
||||
// 关闭所有页面,打开某个页面
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
|
||||
// 跳转到 tabBar 页面,关闭其他所有非 tabBar 页面
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
|
||||
// 返回上一页(delta 为返回的页面数)
|
||||
wx.navigateBack({ delta: 1 })
|
||||
```
|
||||
|
||||
### 页面间通信(EventChannel)
|
||||
|
||||
```javascript
|
||||
// 页面 A
|
||||
wx.navigateTo({
|
||||
url: '/pages/B/B',
|
||||
events: {
|
||||
// 监听来自 B 页面的事件
|
||||
acceptDataFromOpenedPage(data) {
|
||||
console.log(data)
|
||||
}
|
||||
},
|
||||
success(res) {
|
||||
// 向 B 页面发送数据
|
||||
res.eventChannel.emit('acceptDataFromOpenerPage', { data: 'test' })
|
||||
}
|
||||
})
|
||||
|
||||
// 页面 B
|
||||
Page({
|
||||
onLoad() {
|
||||
const eventChannel = this.getOpenerEventChannel()
|
||||
// 向 A 页面发送数据
|
||||
eventChannel.emit('acceptDataFromOpenedPage', { data: 'from B' })
|
||||
// 监听来自 A 页面的数据
|
||||
eventChannel.on('acceptDataFromOpenerPage', (data) => {
|
||||
console.log(data)
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 模块化
|
||||
|
||||
```javascript
|
||||
// common.js
|
||||
function sayHello(name) {
|
||||
console.log(`Hello ${name}!`)
|
||||
}
|
||||
module.exports.sayHello = sayHello
|
||||
|
||||
// 使用
|
||||
const common = require('common.js')
|
||||
common.sayHello('MINA')
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 小程序不支持直接引入 `node_modules`,需使用 npm 构建或手动拷贝
|
||||
- 每个文件有独立作用域,不同文件中可声明同名变量
|
||||
- 通过 `getApp()` 获取全局数据
|
||||
|
||||
## getCurrentPages()
|
||||
|
||||
```javascript
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1] // 当前页面
|
||||
const prevPage = pages[pages.length - 2] // 上一个页面
|
||||
// 可以通过 prevPage.setData() 修改上一页数据(返回时生效)
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需更详细信息,可抓取:
|
||||
- App 参考:https://developers.weixin.qq.com/miniprogram/dev/reference/api/App.html
|
||||
- Page 参考:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Page.html
|
||||
- 路由:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/route.html
|
||||
304
_DEL/wechat-miniprogram/steering/best-practices.md
Normal file
304
_DEL/wechat-miniprogram/steering/best-practices.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 开发最佳实践与常见坑
|
||||
|
||||
> 综合官方文档与社区经验
|
||||
|
||||
## setData 性能优化
|
||||
|
||||
setData 是小程序性能的关键瓶颈,因为数据需要从逻辑层(JS 线程)序列化后传输到视图层(渲染线程)。
|
||||
|
||||
### 原则
|
||||
|
||||
1. **减少数据量**:只传需要更新的字段
|
||||
```javascript
|
||||
// ❌ 错误:传整个列表
|
||||
this.setData({ list: this.data.list })
|
||||
|
||||
// ✅ 正确:只更新变化的项
|
||||
this.setData({ 'list[2].name': 'new name' })
|
||||
```
|
||||
|
||||
2. **减少调用频率**:合并多次 setData
|
||||
```javascript
|
||||
// ❌ 错误:多次调用
|
||||
this.setData({ a: 1 })
|
||||
this.setData({ b: 2 })
|
||||
this.setData({ c: 3 })
|
||||
|
||||
// ✅ 正确:合并为一次
|
||||
this.setData({ a: 1, b: 2, c: 3 })
|
||||
```
|
||||
|
||||
3. **后台页面不要 setData**
|
||||
```javascript
|
||||
// ❌ 错误:页面隐藏后仍在 setData(如定时器)
|
||||
onShow() {
|
||||
this._timer = setInterval(() => {
|
||||
this.setData({ time: Date.now() })
|
||||
}, 1000)
|
||||
},
|
||||
// ✅ 正确:页面隐藏时停止
|
||||
onHide() {
|
||||
clearInterval(this._timer)
|
||||
}
|
||||
```
|
||||
|
||||
4. **避免在 onPageScroll 中 setData**
|
||||
```javascript
|
||||
// ❌ 错误
|
||||
onPageScroll(e) {
|
||||
this.setData({ scrollTop: e.scrollTop })
|
||||
}
|
||||
|
||||
// ✅ 正确:用 WXS 响应事件或节流
|
||||
onPageScroll: throttle(function(e) {
|
||||
if (this._needUpdate) {
|
||||
this.setData({ isTop: e.scrollTop < 100 })
|
||||
}
|
||||
}, 100)
|
||||
```
|
||||
|
||||
5. **大列表用纯数据字段**
|
||||
```javascript
|
||||
Component({
|
||||
options: { pureDataPattern: /^_/ },
|
||||
data: {
|
||||
displayList: [], // 用于渲染
|
||||
_rawList: [] // 纯数据,不传输到视图层
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 分包加载
|
||||
|
||||
### 基本分包
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"pages": [
|
||||
"pages/index/index",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"subpackages": [
|
||||
{
|
||||
"root": "packageA",
|
||||
"name": "pack-a",
|
||||
"pages": [
|
||||
"pages/cat/cat",
|
||||
"pages/dog/dog"
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "packageB",
|
||||
"name": "pack-b",
|
||||
"pages": [
|
||||
"pages/apple/apple"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 整个小程序所有分包大小不超过 20MB(使用分包时)
|
||||
- 单个分包/主包大小不超过 2MB
|
||||
- tabBar 页面必须在主包
|
||||
|
||||
### 独立分包
|
||||
|
||||
不依赖主包即可运行,适合独立功能页面(如活动页)。
|
||||
|
||||
```json
|
||||
{
|
||||
"subpackages": [
|
||||
{
|
||||
"root": "packageIndependent",
|
||||
"pages": ["pages/activity/activity"],
|
||||
"independent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:独立分包中不能使用主包的公共资源(js/组件/样式)。
|
||||
|
||||
### 分包预下载
|
||||
|
||||
```json
|
||||
{
|
||||
"preloadRule": {
|
||||
"pages/index/index": {
|
||||
"network": "all",
|
||||
"packages": ["packageA"]
|
||||
},
|
||||
"pages/logs/logs": {
|
||||
"network": "wifi",
|
||||
"packages": ["packageB"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 小程序与 H5 的关键差异
|
||||
|
||||
| 特性 | H5 | 小程序 |
|
||||
|------|-----|--------|
|
||||
| DOM 操作 | 支持 document/window | ❌ 不支持 |
|
||||
| BOM | 支持 | ❌ 不支持 |
|
||||
| 路由 | URL hash/history | 页面栈(最多 10 层) |
|
||||
| 样式 | 完整 CSS | WXSS(部分 CSS 不支持) |
|
||||
| 脚本 | 完整 JS + Web API | JS(无 DOM API) |
|
||||
| 渲染 | 单线程 | 双线程(逻辑层 + 视图层) |
|
||||
| 网络请求 | fetch/XMLHttpRequest | wx.request(需配置域名) |
|
||||
| 本地存储 | localStorage | wx.setStorage(10MB) |
|
||||
| Cookie | 支持 | ❌ 不支持(需自行管理) |
|
||||
| 动态创建元素 | 支持 | ❌ 不支持 |
|
||||
| eval / new Function | 支持 | ❌ 不支持 |
|
||||
| SVG | 支持 | 部分支持(image src 可用) |
|
||||
|
||||
### 常见迁移坑
|
||||
|
||||
1. **没有 Cookie**:登录态需要自行通过 header 传递 token
|
||||
2. **没有 DOM**:不能用 jQuery、不能 `document.getElementById`
|
||||
3. **不支持动态执行代码**:`eval()`、`new Function()` 都不可用
|
||||
4. **样式差异**:
|
||||
- 不支持 `*` 通配符选择器
|
||||
- 不支持 `>` `+` `~` 等关系选择器(部分版本已支持)
|
||||
- 不支持 `@media` 的部分写法
|
||||
- 不支持 `position: fixed` 在某些场景下的表现
|
||||
5. **页面栈限制**:最多 10 层,超过后 navigateTo 会失败
|
||||
6. **包大小限制**:主包 2MB,总包 20MB
|
||||
7. **网络请求域名白名单**:必须在管理后台配置
|
||||
8. **不支持 npm 直接引入**:需要通过开发者工具构建 npm
|
||||
|
||||
## TypeScript 支持
|
||||
|
||||
小程序原生支持 TypeScript:
|
||||
|
||||
```json
|
||||
// tsconfig.json(项目根目录)
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2017",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2017"],
|
||||
"typeRoots": ["./typings"]
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 页面 .ts 文件
|
||||
Page({
|
||||
data: {
|
||||
msg: 'Hello' as string,
|
||||
list: [] as Array<{ id: number; name: string }>
|
||||
},
|
||||
onLoad(options: Record<string, string | undefined>) {
|
||||
const id = options.id
|
||||
}
|
||||
})
|
||||
|
||||
// 组件 .ts 文件
|
||||
Component({
|
||||
properties: {
|
||||
title: { type: String, value: '' }
|
||||
},
|
||||
data: {
|
||||
count: 0 as number
|
||||
},
|
||||
methods: {
|
||||
increment() {
|
||||
this.setData({ count: this.data.count + 1 })
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## npm 支持
|
||||
|
||||
1. 在小程序根目录执行 `npm install`
|
||||
2. 在开发者工具中:工具 → 构建 npm
|
||||
3. 构建后会生成 `miniprogram_npm` 目录
|
||||
4. 使用:`const dayjs = require('dayjs')`
|
||||
|
||||
**注意**:
|
||||
- 不是所有 npm 包都能在小程序中使用(不能依赖 Node.js 内置模块或浏览器 API)
|
||||
- 每次 `npm install` 后都需要重新构建 npm
|
||||
|
||||
## 自定义 tabBar
|
||||
|
||||
```json
|
||||
// app.json
|
||||
{
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "首页" },
|
||||
{ "pagePath": "pages/mine/mine", "text": "我的" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在根目录创建 `custom-tab-bar/` 组件:
|
||||
```
|
||||
custom-tab-bar/
|
||||
├── index.js
|
||||
├── index.json
|
||||
├── index.wxml
|
||||
└── index.wxss
|
||||
```
|
||||
|
||||
## 骨架屏
|
||||
|
||||
开发者工具支持自动生成骨架屏:
|
||||
1. 在模拟器中预览页面
|
||||
2. 点击模拟器右下角「...」→「生成骨架屏」
|
||||
3. 会自动生成 `页面名.skeleton.wxml` 和 `页面名.skeleton.wxss`
|
||||
|
||||
```json
|
||||
// 页面 json 中引用
|
||||
{
|
||||
"initialRenderingCache": "static"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见审核被拒原因
|
||||
|
||||
1. **功能不完整**:提交审核时确保所有功能可用
|
||||
2. **测试账号未提供**:需要登录的小程序必须提供测试账号
|
||||
3. **类目不符**:选择的服务类目与实际功能不匹配
|
||||
4. **诱导分享/关注**:不能强制用户分享或关注公众号才能使用
|
||||
5. **虚拟支付**:iOS 不允许虚拟商品使用微信支付(需走 IAP)
|
||||
6. **内容违规**:UGC 内容需要内容安全检测
|
||||
7. **隐私协议**:需要配置隐私保护指引
|
||||
8. **授权滥用**:不能在首页就弹出授权请求,需要在使用时才请求
|
||||
|
||||
## 调试技巧
|
||||
|
||||
```javascript
|
||||
// 真机调试日志
|
||||
const log = wx.getRealtimeLogManager()
|
||||
log.info('info message')
|
||||
log.warn('warn message')
|
||||
log.error('error message')
|
||||
|
||||
// 性能监控
|
||||
const performance = wx.getPerformance()
|
||||
const observer = performance.createObserver((entryList) => {
|
||||
console.log(entryList.getEntries())
|
||||
})
|
||||
observer.observe({ entryTypes: ['render', 'script', 'navigation'] })
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
- 性能优化:https://developers.weixin.qq.com/miniprogram/dev/framework/performance/tips.html
|
||||
- 分包加载:https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages.html
|
||||
- npm 支持:https://developers.weixin.qq.com/miniprogram/dev/devtools/npm.html
|
||||
- 自定义 tabBar:https://developers.weixin.qq.com/miniprogram/dev/framework/ability/custom-tabbar.html
|
||||
364
_DEL/wechat-miniprogram/steering/builtin-components.md
Normal file
364
_DEL/wechat-miniprogram/steering/builtin-components.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 内置组件
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/component/
|
||||
|
||||
## 视图容器
|
||||
|
||||
### view
|
||||
基础视图容器,类似 HTML 的 `<div>`。
|
||||
```xml
|
||||
<view class="container" hover-class="hover" hover-stay-time="400">
|
||||
内容
|
||||
</view>
|
||||
```
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hover-class | string | 按下去的样式类,none 为不设置 |
|
||||
| hover-stop-propagation | boolean | 阻止祖先节点出现点击态 |
|
||||
| hover-start-time | number | 按住后多久出现点击态(ms),默认 50 |
|
||||
| hover-stay-time | number | 松开后点击态保留时间(ms),默认 400 |
|
||||
|
||||
### scroll-view
|
||||
可滚动视图区域。
|
||||
```xml
|
||||
<!-- 纵向滚动需设置固定高度 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
style="height: 300px;"
|
||||
bindscrolltolower="loadMore"
|
||||
bindscroll="onScroll"
|
||||
scroll-into-view="{{toView}}"
|
||||
scroll-top="{{scrollTop}}"
|
||||
refresher-enabled="{{true}}"
|
||||
bindrefresherrefresh="onRefresh"
|
||||
>
|
||||
<view id="item1">A</view>
|
||||
<view id="item2">B</view>
|
||||
<view id="item3">C</view>
|
||||
</scroll-view>
|
||||
```
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| scroll-x | boolean | 允许横向滚动 |
|
||||
| scroll-y | boolean | 允许纵向滚动 |
|
||||
| upper-threshold | number | 距顶部/左边多远时触发 scrolltoupper(px),默认 50 |
|
||||
| lower-threshold | number | 距底部/右边多远时触发 scrolltolower(px),默认 50 |
|
||||
| scroll-top | number | 设置竖向滚动条位置 |
|
||||
| scroll-into-view | string | 滚动到某子元素(id) |
|
||||
| scroll-with-animation | boolean | 滚动动画过渡 |
|
||||
| enable-back-to-top | boolean | iOS 点击状态栏回到顶部 |
|
||||
| refresher-enabled | boolean | 开启自定义下拉刷新(2.10.1+) |
|
||||
| refresher-triggered | boolean | 设置刷新状态 |
|
||||
| enhanced | boolean | 增强模式(2.12.0+,支持 scroll-into-view 动画等) |
|
||||
|
||||
### swiper / swiper-item
|
||||
滑块视图容器(轮播图)。
|
||||
```xml
|
||||
<swiper
|
||||
indicator-dots="{{true}}"
|
||||
autoplay="{{true}}"
|
||||
interval="{{3000}}"
|
||||
duration="{{500}}"
|
||||
circular="{{true}}"
|
||||
bindchange="swiperChange"
|
||||
>
|
||||
<swiper-item wx:for="{{imgUrls}}" wx:key="*this">
|
||||
<image src="{{item}}" mode="aspectFill"/>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
```
|
||||
|
||||
### movable-area / movable-view
|
||||
可拖拽区域。
|
||||
```xml
|
||||
<movable-area style="height: 200px; width: 200px; background: red;">
|
||||
<movable-view direction="all" style="height: 50px; width: 50px; background: blue;">
|
||||
text
|
||||
</movable-view>
|
||||
</movable-area>
|
||||
```
|
||||
|
||||
### cover-view / cover-image
|
||||
覆盖在原生组件(map、video、canvas、camera、live-player、live-pusher)之上的视图。
|
||||
|
||||
## 基础内容
|
||||
|
||||
### text
|
||||
```xml
|
||||
<text selectable="{{true}}" space="ensp" decode="{{true}}">
|
||||
Hello World
|
||||
</text>
|
||||
```
|
||||
- `selectable`:文本是否可选
|
||||
- `space`:显示连续空格(ensp/emsp/nbsp)
|
||||
- `decode`:是否解码(` ` `<` 等)
|
||||
- **注意**:`<text>` 组件内只支持嵌套 `<text>`
|
||||
|
||||
### rich-text
|
||||
```xml
|
||||
<rich-text nodes="{{htmlNodes}}"></rich-text>
|
||||
```
|
||||
```javascript
|
||||
data: {
|
||||
htmlNodes: '<div class="div_class"><h5>标题</h5><p>段落</p></div>'
|
||||
// 也支持 nodes 数组格式
|
||||
}
|
||||
```
|
||||
|
||||
### icon
|
||||
```xml
|
||||
<icon type="success" size="23" color="green"/>
|
||||
```
|
||||
type 可选:success, success_no_circle, info, warn, waiting, cancel, download, search, clear
|
||||
|
||||
### progress
|
||||
```xml
|
||||
<progress percent="80" show-info stroke-width="3" activeColor="#00CC00"/>
|
||||
```
|
||||
|
||||
## 表单组件
|
||||
|
||||
### button
|
||||
```xml
|
||||
<button
|
||||
type="primary"
|
||||
size="default"
|
||||
loading="{{isLoading}}"
|
||||
disabled="{{isDisabled}}"
|
||||
open-type="getPhoneNumber"
|
||||
bindgetphonenumber="getPhoneNumber"
|
||||
>
|
||||
获取手机号
|
||||
</button>
|
||||
```
|
||||
| open-type | 说明 |
|
||||
|-----------|------|
|
||||
| contact | 打开客服会话 |
|
||||
| share | 触发转发 |
|
||||
| getPhoneNumber | 获取手机号(需配合 bindgetphonenumber) |
|
||||
| getUserInfo | 已废弃(2.27.1+) |
|
||||
| launchApp | 打开 APP |
|
||||
| openSetting | 打开授权设置页 |
|
||||
| chooseAvatar | 获取用户头像(基础库 2.21.2+) |
|
||||
| agreePrivacyAuthorization | 同意隐私协议(2.33.2+) |
|
||||
|
||||
### input
|
||||
```xml
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入"
|
||||
value="{{inputValue}}"
|
||||
bindinput="onInput"
|
||||
bindfocus="onFocus"
|
||||
bindblur="onBlur"
|
||||
bindconfirm="onConfirm"
|
||||
maxlength="140"
|
||||
confirm-type="send"
|
||||
adjust-position="{{true}}"
|
||||
/>
|
||||
```
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| text | 文本 |
|
||||
| number | 数字 |
|
||||
| idcard | 身份证 |
|
||||
| digit | 带小数点数字 |
|
||||
| nickname | 昵称输入(基础库 2.21.2+) |
|
||||
| safe-password | 密码安全输入 |
|
||||
|
||||
### textarea
|
||||
```xml
|
||||
<textarea
|
||||
value="{{content}}"
|
||||
placeholder="请输入内容"
|
||||
maxlength="-1"
|
||||
auto-height
|
||||
bindinput="onInput"
|
||||
bindblur="onBlur"
|
||||
show-confirm-bar="{{false}}"
|
||||
/>
|
||||
```
|
||||
|
||||
### picker
|
||||
```xml
|
||||
<!-- 普通选择器 -->
|
||||
<picker mode="selector" range="{{array}}" bindchange="pickerChange">
|
||||
<view>当前选择:{{array[index]}}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 多列选择器 -->
|
||||
<picker mode="multiSelector" range="{{multiArray}}" bindchange="multiChange" bindcolumnchange="columnChange">
|
||||
<view>{{multiArray[0][multiIndex[0]]}} - {{multiArray[1][multiIndex[1]]}}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 时间选择器 -->
|
||||
<picker mode="time" value="{{time}}" start="09:00" end="21:00" bindchange="timeChange">
|
||||
<view>{{time}}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 日期选择器 -->
|
||||
<picker mode="date" value="{{date}}" start="2020-01-01" end="2030-12-31" bindchange="dateChange">
|
||||
<view>{{date}}</view>
|
||||
</picker>
|
||||
|
||||
<!-- 省市区选择器 -->
|
||||
<picker mode="region" value="{{region}}" bindchange="regionChange">
|
||||
<view>{{region[0]}} - {{region[1]}} - {{region[2]}}</view>
|
||||
</picker>
|
||||
```
|
||||
|
||||
### form
|
||||
```xml
|
||||
<form bindsubmit="formSubmit" bindreset="formReset">
|
||||
<input name="username" placeholder="用户名"/>
|
||||
<switch name="agree" checked/>
|
||||
<slider name="age" show-value/>
|
||||
<button form-type="submit">提交</button>
|
||||
<button form-type="reset">重置</button>
|
||||
</form>
|
||||
```
|
||||
```javascript
|
||||
formSubmit(e) {
|
||||
e.detail.value // { username: '...', agree: true, age: 50 }
|
||||
}
|
||||
```
|
||||
|
||||
### 其他表单组件
|
||||
- `checkbox-group` + `checkbox`
|
||||
- `radio-group` + `radio`
|
||||
- `slider`
|
||||
- `switch`
|
||||
- `label`(绑定表单控件)
|
||||
|
||||
## 导航
|
||||
|
||||
### navigator
|
||||
```xml
|
||||
<navigator url="/pages/detail/detail?id=1" open-type="navigate">
|
||||
跳转到详情
|
||||
</navigator>
|
||||
|
||||
<navigator url="/pages/index/index" open-type="switchTab">
|
||||
切换到首页 Tab
|
||||
</navigator>
|
||||
|
||||
<navigator open-type="navigateBack" delta="1">
|
||||
返回上一页
|
||||
</navigator>
|
||||
```
|
||||
|
||||
## 媒体组件
|
||||
|
||||
### image
|
||||
```xml
|
||||
<image
|
||||
src="{{imgUrl}}"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
show-menu-by-longpress
|
||||
binderror="imgError"
|
||||
bindload="imgLoad"
|
||||
/>
|
||||
```
|
||||
| mode | 说明 |
|
||||
|------|------|
|
||||
| scaleToFill | 不保持比例缩放,填满 |
|
||||
| aspectFit | 保持比例,完整显示(可能留白) |
|
||||
| aspectFill | 保持比例,填满(可能裁剪) |
|
||||
| widthFix | 宽度不变,高度自适应 |
|
||||
| heightFix | 高度不变,宽度自适应(2.10.3+) |
|
||||
| top/bottom/center/left/right | 不缩放,显示对应区域 |
|
||||
|
||||
**注意**:image 默认宽 320px、高 240px。
|
||||
|
||||
### video
|
||||
```xml
|
||||
<video
|
||||
src="{{videoUrl}}"
|
||||
controls
|
||||
autoplay="{{false}}"
|
||||
loop="{{false}}"
|
||||
muted="{{false}}"
|
||||
initial-time="0"
|
||||
show-fullscreen-btn
|
||||
show-play-btn
|
||||
enable-progress-gesture
|
||||
bindplay="onPlay"
|
||||
bindpause="onPause"
|
||||
bindended="onEnded"
|
||||
binderror="onError"
|
||||
/>
|
||||
```
|
||||
|
||||
### camera
|
||||
```xml
|
||||
<camera
|
||||
device-position="back"
|
||||
flash="auto"
|
||||
bindscancode="onScanCode"
|
||||
style="width: 100%; height: 300px;"
|
||||
/>
|
||||
```
|
||||
|
||||
## 地图
|
||||
|
||||
### map
|
||||
```xml
|
||||
<map
|
||||
longitude="{{longitude}}"
|
||||
latitude="{{latitude}}"
|
||||
scale="16"
|
||||
markers="{{markers}}"
|
||||
polyline="{{polyline}}"
|
||||
show-location
|
||||
style="width: 100%; height: 300px;"
|
||||
bindmarkertap="onMarkerTap"
|
||||
bindregionchange="onRegionChange"
|
||||
/>
|
||||
```
|
||||
|
||||
## 画布
|
||||
|
||||
### canvas
|
||||
```xml
|
||||
<!-- 新版 Canvas 2D(推荐,基础库 2.9.0+) -->
|
||||
<canvas type="2d" id="myCanvas" style="width: 300px; height: 200px;"/>
|
||||
```
|
||||
```javascript
|
||||
const query = wx.createSelectorQuery()
|
||||
query.select('#myCanvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const canvas = res[0].node
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = wx.getWindowInfo().pixelRatio
|
||||
canvas.width = res[0].width * dpr
|
||||
canvas.height = res[0].height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillRect(0, 0, 100, 100)
|
||||
})
|
||||
```
|
||||
|
||||
## 开放能力组件
|
||||
|
||||
### web-view
|
||||
```xml
|
||||
<!-- 承载网页的容器,会自动铺满整个小程序页面 -->
|
||||
<web-view src="https://mp.weixin.qq.com/"></web-view>
|
||||
```
|
||||
- 需要在小程序管理后台配置业务域名
|
||||
- 个人类型小程序暂不支持
|
||||
|
||||
### open-data(已限制)
|
||||
```xml
|
||||
<!-- 基础库 2.0.1+ 起,大部分 type 已不再返回真实数据 -->
|
||||
<open-data type="userAvatarUrl"></open-data>
|
||||
<open-data type="userNickName"></open-data>
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
组件文档非常详细,如需查看某个具体组件的完整属性和事件,可抓取:
|
||||
- 组件总览:https://developers.weixin.qq.com/miniprogram/dev/component/
|
||||
- 具体组件:`https://developers.weixin.qq.com/miniprogram/dev/component/{组件名}.html`
|
||||
例如:https://developers.weixin.qq.com/miniprogram/dev/component/scroll-view.html
|
||||
443
_DEL/wechat-miniprogram/steering/custom-component.md
Normal file
443
_DEL/wechat-miniprogram/steering/custom-component.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# 自定义组件
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/
|
||||
|
||||
基础库 1.6.3+ 支持。
|
||||
|
||||
## 创建自定义组件
|
||||
|
||||
一个自定义组件由 `json` `wxml` `wxss` `js` 四个文件组成。
|
||||
|
||||
```json
|
||||
// my-component.json — 声明为组件
|
||||
{ "component": true }
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- my-component.wxml -->
|
||||
<view class="inner">
|
||||
{{innerText}}
|
||||
<slot></slot>
|
||||
</view>
|
||||
```
|
||||
|
||||
```css
|
||||
/* my-component.wxss — 样式只作用于本组件 */
|
||||
.inner { color: red; }
|
||||
```
|
||||
|
||||
```javascript
|
||||
// my-component.js
|
||||
Component({
|
||||
properties: {
|
||||
innerText: {
|
||||
type: String,
|
||||
value: 'default value'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
someData: {}
|
||||
},
|
||||
methods: {
|
||||
customMethod() {}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 使用自定义组件
|
||||
|
||||
```json
|
||||
// 页面或组件的 .json
|
||||
{
|
||||
"usingComponents": {
|
||||
"my-component": "/components/my-component/my-component"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 页面 wxml -->
|
||||
<my-component inner-text="Some text">
|
||||
<view>这里是插入到 slot 中的内容</view>
|
||||
</my-component>
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- 标签名只能是小写字母、中划线、下划线的组合
|
||||
- 组件和页面所在项目根目录名不能以 "wx-" 为前缀
|
||||
- 使用 `usingComponents` 会使页面的 `this` 原型稍有差异(多了 `selectComponent` 等方法)
|
||||
- 使用 `usingComponents` 时,`setData` 内容不会被深复制(性能优化)
|
||||
|
||||
## Component() 构造器
|
||||
|
||||
```javascript
|
||||
Component({
|
||||
// ===== 组件属性(外部传入) =====
|
||||
properties: {
|
||||
myProperty: {
|
||||
type: String, // 类型:String, Number, Boolean, Object, Array, null(任意)
|
||||
value: '', // 默认值
|
||||
observer(newVal, oldVal) {
|
||||
// 属性变化时触发(已不推荐,建议用 observers)
|
||||
}
|
||||
},
|
||||
myProperty2: String // 简化定义
|
||||
},
|
||||
|
||||
// ===== 组件内部数据 =====
|
||||
data: {
|
||||
someData: 'initial'
|
||||
},
|
||||
|
||||
// ===== 生命周期(推荐写在 lifetimes 中) =====
|
||||
lifetimes: {
|
||||
created() {
|
||||
// 组件实例刚被创建时
|
||||
// 此时不能调用 setData,通常用于给 this 添加自定义属性
|
||||
},
|
||||
attached() {
|
||||
// 组件实例进入页面节点树时
|
||||
// 大多数初始化工作在此进行
|
||||
},
|
||||
ready() {
|
||||
// 组件在视图层布局完成后
|
||||
},
|
||||
moved() {
|
||||
// 组件实例被移动到节点树另一个位置时
|
||||
},
|
||||
detached() {
|
||||
// 组件实例被从页面节点树移除时
|
||||
// 清理工作(如清除定时器)
|
||||
},
|
||||
error(err) {
|
||||
// 组件方法抛出错误时(基础库 2.4.1+)
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 组件所在页面的生命周期 =====
|
||||
pageLifetimes: {
|
||||
show() {
|
||||
// 页面被展示时
|
||||
},
|
||||
hide() {
|
||||
// 页面被隐藏时
|
||||
},
|
||||
resize(size) {
|
||||
// 页面尺寸变化时
|
||||
},
|
||||
routeDone() {
|
||||
// 页面路由动画完成时(基础库 2.31.2+)
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 数据监听器(基础库 2.6.1+) =====
|
||||
observers: {
|
||||
'numberA, numberB'(numberA, numberB) {
|
||||
// numberA 或 numberB 变化时触发
|
||||
this.setData({ sum: numberA + numberB })
|
||||
},
|
||||
'some.subfield'(subfield) {
|
||||
// 监听子数据字段
|
||||
},
|
||||
'arr[12]'(val) {
|
||||
// 监听数组某一项
|
||||
},
|
||||
'some.field.**'(field) {
|
||||
// 使用通配符监听所有子数据字段
|
||||
},
|
||||
'**'() {
|
||||
// 监听所有 setData(每次 setData 都触发,慎用)
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 方法 =====
|
||||
methods: {
|
||||
onMyButtonTap() {
|
||||
this.setData({ someData: 'new value' })
|
||||
},
|
||||
// 内部方法建议以下划线开头
|
||||
_myPrivateMethod() {
|
||||
this.setData({ 'A.B': 'myPrivateData' })
|
||||
}
|
||||
},
|
||||
|
||||
// ===== behaviors =====
|
||||
behaviors: [],
|
||||
|
||||
// ===== 其他选项 =====
|
||||
options: {
|
||||
multipleSlots: true, // 启用多 slot(默认只能一个)
|
||||
styleIsolation: 'isolated', // 样式隔离模式
|
||||
pureDataPattern: /^_/, // 纯数据字段正则
|
||||
virtualHost: true // 虚拟化组件节点(基础库 2.11.2+)
|
||||
},
|
||||
|
||||
// ===== 外部样式类 =====
|
||||
externalClasses: ['my-class'],
|
||||
|
||||
// ===== 组件间关系 =====
|
||||
relations: {},
|
||||
|
||||
// ===== 导出(配合 wx://component-export) =====
|
||||
export() {
|
||||
return { myField: 'myValue' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 组件模板和样式
|
||||
|
||||
### 多 slot
|
||||
|
||||
```javascript
|
||||
// 组件 js
|
||||
Component({
|
||||
options: { multipleSlots: true }
|
||||
})
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 组件 wxml -->
|
||||
<view>
|
||||
<slot name="before"></slot>
|
||||
<view>组件内部内容</view>
|
||||
<slot name="after"></slot>
|
||||
</view>
|
||||
|
||||
<!-- 使用 -->
|
||||
<my-component>
|
||||
<view slot="before">before 内容</view>
|
||||
<view slot="after">after 内容</view>
|
||||
</my-component>
|
||||
```
|
||||
|
||||
### 样式隔离 styleIsolation
|
||||
|
||||
```javascript
|
||||
Component({
|
||||
options: {
|
||||
styleIsolation: 'isolated'
|
||||
// 'isolated'(默认):组件样式完全隔离
|
||||
// 'apply-shared':页面 wxss 样式会影响组件,但组件不影响页面
|
||||
// 'shared':页面和组件样式互相影响
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
也可在 json 中配置:
|
||||
```json
|
||||
{ "styleIsolation": "isolated" }
|
||||
```
|
||||
|
||||
### 外部样式类
|
||||
|
||||
```javascript
|
||||
// 组件
|
||||
Component({
|
||||
externalClasses: ['my-class']
|
||||
})
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 组件 wxml -->
|
||||
<view class="my-class">这段文本的颜色由外部决定</view>
|
||||
|
||||
<!-- 使用时 -->
|
||||
<my-component my-class="red-text"/>
|
||||
```
|
||||
|
||||
## 组件间通信
|
||||
|
||||
### 父 → 子:properties
|
||||
|
||||
```xml
|
||||
<my-component prop-a="{{dataA}}" prop-b="staticValue"/>
|
||||
```
|
||||
|
||||
### 子 → 父:triggerEvent
|
||||
|
||||
```javascript
|
||||
// 子组件
|
||||
Component({
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('myevent', { value: 'data' }, {
|
||||
bubbles: false, // 是否冒泡
|
||||
composed: false, // 是否穿越组件边界
|
||||
capturePhase: false // 是否有捕获阶段
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 父组件/页面 -->
|
||||
<my-component bind:myevent="onMyEvent"/>
|
||||
<!-- 或 bindmyevent="onMyEvent" -->
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 父组件/页面
|
||||
Page({
|
||||
onMyEvent(e) {
|
||||
e.detail // { value: 'data' }
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 父获取子实例:selectComponent
|
||||
|
||||
```xml
|
||||
<my-component id="the-id" class="the-class"/>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const child = this.selectComponent('#the-id')
|
||||
// 或 this.selectComponent('.the-class')
|
||||
child.setData({ ... })
|
||||
child.someMethod()
|
||||
```
|
||||
|
||||
## behaviors(代码复用)
|
||||
|
||||
类似 mixins / traits。
|
||||
|
||||
```javascript
|
||||
// my-behavior.js
|
||||
module.exports = Behavior({
|
||||
behaviors: [], // 可以引用其他 behavior
|
||||
properties: {
|
||||
myBehaviorProperty: { type: String }
|
||||
},
|
||||
data: {
|
||||
myBehaviorData: {}
|
||||
},
|
||||
attached() {},
|
||||
methods: {
|
||||
myBehaviorMethod() {}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 组件中使用
|
||||
const myBehavior = require('my-behavior')
|
||||
Component({
|
||||
behaviors: [myBehavior],
|
||||
// 组件自身的 properties/data/methods 会与 behavior 合并
|
||||
// 同名字段:组件 > behavior > 更早的 behavior
|
||||
// 同名生命周期:都会执行(behavior 先于组件)
|
||||
})
|
||||
```
|
||||
|
||||
### 内置 behaviors
|
||||
|
||||
| behavior | 说明 |
|
||||
|----------|------|
|
||||
| `wx://form-field` | 使组件像表单控件,form 可识别 |
|
||||
| `wx://form-field-group` | form 识别组件内部所有表单控件(2.10.2+) |
|
||||
| `wx://form-field-button` | form 识别组件内部 button(2.10.3+) |
|
||||
| `wx://component-export` | 自定义 selectComponent 返回值(2.2.3+) |
|
||||
|
||||
## 纯数据字段
|
||||
|
||||
不用于渲染的数据,不会参与 setData 传输,提升性能。
|
||||
|
||||
```javascript
|
||||
Component({
|
||||
options: {
|
||||
pureDataPattern: /^_/ // 以 _ 开头的字段为纯数据
|
||||
},
|
||||
data: {
|
||||
a: true, // 普通数据,参与渲染
|
||||
_b: true // 纯数据,不参与渲染
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 组件间关系 relations
|
||||
|
||||
```javascript
|
||||
// custom-ul
|
||||
Component({
|
||||
relations: {
|
||||
'./custom-li': {
|
||||
type: 'child',
|
||||
linked(target) {}, // 子组件 attached 时
|
||||
linkChanged(target) {},
|
||||
unlinked(target) {} // 子组件 detached 时
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// custom-li
|
||||
Component({
|
||||
relations: {
|
||||
'./custom-ul': {
|
||||
type: 'parent',
|
||||
linked(target) {},
|
||||
linkChanged(target) {},
|
||||
unlinked(target) {}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**type 可选值**:`parent` / `child` / `ancestor` / `descendant`
|
||||
|
||||
## 抽象节点 componentGenerics
|
||||
|
||||
```json
|
||||
// selectable-group.json
|
||||
{
|
||||
"componentGenerics": {
|
||||
"selectable": {
|
||||
"default": "path/to/default"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- selectable-group.wxml -->
|
||||
<view wx:for="{{labels}}">
|
||||
<selectable disabled="{{false}}"></selectable>
|
||||
</view>
|
||||
|
||||
<!-- 使用时指定具体组件 -->
|
||||
<selectable-group generic:selectable="custom-radio"/>
|
||||
```
|
||||
|
||||
## 用 Component 构造器构造页面
|
||||
|
||||
```javascript
|
||||
Component({
|
||||
properties: {
|
||||
paramA: Number, // 接收页面参数 ?paramA=123
|
||||
paramB: String
|
||||
},
|
||||
methods: {
|
||||
onLoad() {
|
||||
this.data.paramA // 123
|
||||
},
|
||||
onShow() {},
|
||||
onPullDownRefresh() {}
|
||||
// 页面生命周期写在 methods 中
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
对应 json 需包含 `usingComponents`:
|
||||
```json
|
||||
{ "usingComponents": {} }
|
||||
```
|
||||
|
||||
好处:可以使用 behaviors 提取所有页面公用代码。
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需更详细信息,可抓取:
|
||||
- Component 参考:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Component.html
|
||||
- Behavior 参考:https://developers.weixin.qq.com/miniprogram/dev/reference/api/Behavior.html
|
||||
- 组件生命周期:https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/lifetimes.html
|
||||
188
_DEL/wechat-miniprogram/steering/framework-core.md
Normal file
188
_DEL/wechat-miniprogram/steering/framework-core.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 框架核心(Framework Core)
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/
|
||||
|
||||
## 架构概述
|
||||
|
||||
小程序框架(MINA)分为两部分:
|
||||
- **逻辑层(App Service)**:运行 JavaScript,处理数据和业务逻辑
|
||||
- **视图层(View)**:渲染 WXML + WXSS,展示 UI
|
||||
|
||||
逻辑层和视图层分别运行在不同的线程中,通过 Native 层进行数据传输和事件通信。
|
||||
|
||||
**关键限制**:逻辑层不运行在浏览器中,没有 `window`、`document` 等 Web API。
|
||||
|
||||
## 目录结构
|
||||
|
||||
一个小程序主体部分由三个文件组成(必须放在项目根目录):
|
||||
|
||||
| 文件 | 必需 | 作用 |
|
||||
|------|------|------|
|
||||
| `app.js` | 是 | 小程序逻辑(App() 注册) |
|
||||
| `app.json` | 是 | 小程序公共配置 |
|
||||
| `app.wxss` | 否 | 小程序公共样式表 |
|
||||
|
||||
一个小程序页面由四个文件组成:
|
||||
|
||||
| 文件 | 必需 | 作用 |
|
||||
|------|------|------|
|
||||
| `页面.js` | 是 | 页面逻辑(Page() 注册) |
|
||||
| `页面.wxml` | 是 | 页面结构(模板) |
|
||||
| `页面.wxss` | 否 | 页面样式 |
|
||||
| `页面.json` | 否 | 页面配置 |
|
||||
|
||||
## app.json 全局配置
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
"pages/index/index",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTitleText": "小程序",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#eeeeee",
|
||||
"backgroundTextStyle": "light",
|
||||
"enablePullDownRefresh": false
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#999",
|
||||
"selectedColor": "#333",
|
||||
"backgroundColor": "#fff",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页",
|
||||
"iconPath": "images/tab/home.png",
|
||||
"selectedIconPath": "images/tab/home-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"networkTimeout": {
|
||||
"request": 10000,
|
||||
"downloadFile": 10000
|
||||
},
|
||||
"subpackages": [],
|
||||
"permission": {
|
||||
"scope.userLocation": {
|
||||
"desc": "你的位置信息将用于小程序位置接口的效果展示"
|
||||
}
|
||||
},
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
```
|
||||
|
||||
### pages 配置
|
||||
- 数组第一项为小程序初始页面(首页)
|
||||
- 不需要写文件后缀,框架会自动寻找 `.json` `.js` `.wxml` `.wxss` 四个文件
|
||||
- 新增/减少页面需要修改 pages 数组
|
||||
|
||||
### window 配置
|
||||
| 属性 | 类型 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| navigationBarBackgroundColor | HexColor | #000000 | 导航栏背景颜色 |
|
||||
| navigationBarTextStyle | string | white | 导航栏标题颜色,仅支持 black / white |
|
||||
| navigationBarTitleText | string | | 导航栏标题文字 |
|
||||
| navigationStyle | string | default | 导航栏样式,custom 为自定义导航栏(只保留右上角胶囊按钮) |
|
||||
| backgroundColor | HexColor | #ffffff | 窗口背景色 |
|
||||
| backgroundTextStyle | string | dark | 下拉 loading 样式,仅支持 dark / light |
|
||||
| enablePullDownRefresh | boolean | false | 是否开启全局下拉刷新 |
|
||||
| onReachBottomDistance | number | 50 | 页面上拉触底事件触发时距页面底部距离(px) |
|
||||
|
||||
### tabBar 配置
|
||||
- `list` 数组最少 2 个、最多 5 个 tab
|
||||
- tabBar 页面必须在 pages 数组中
|
||||
- `position` 可选 `bottom`(默认)或 `top`(顶部时不显示 icon)
|
||||
|
||||
## 页面配置(page.json)
|
||||
|
||||
每个页面可以有自己的 `.json` 文件,覆盖 `app.json` 中 `window` 的配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"navigationBarTitleText": "页面标题",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"my-component": "/components/my-component/my-component"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## sitemap.json
|
||||
|
||||
配置小程序及其页面是否允许被微信索引:
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 场景值
|
||||
|
||||
场景值用于描述用户进入小程序的路径,常见场景值:
|
||||
|
||||
| 场景值 | 说明 |
|
||||
|--------|------|
|
||||
| 1001 | 发现栏小程序主入口 |
|
||||
| 1007 | 单人聊天会话中的小程序消息卡片 |
|
||||
| 1008 | 群聊会话中的小程序消息卡片 |
|
||||
| 1011 | 扫描二维码 |
|
||||
| 1012 | 长按识别二维码 |
|
||||
| 1020 | 公众号 profile 页相关小程序列表 |
|
||||
| 1035 | 公众号自定义菜单 |
|
||||
| 1036 | App 分享消息卡片 |
|
||||
| 1037 | 小程序打开小程序 |
|
||||
| 1038 | 从另一个小程序返回 |
|
||||
| 1043 | 公众号模板消息 |
|
||||
| 1047 | 扫描小程序码 |
|
||||
| 1048 | 长按识别小程序码 |
|
||||
| 1089 | 微信聊天主界面下拉 |
|
||||
|
||||
可在 `App.onLaunch` / `App.onShow` 中通过 `options.scene` 获取。
|
||||
|
||||
## 基础库版本兼容
|
||||
|
||||
```javascript
|
||||
// 方式一:wx.canIUse
|
||||
if (wx.canIUse('openBluetoothAdapter')) {
|
||||
wx.openBluetoothAdapter()
|
||||
} else {
|
||||
wx.showModal({ title: '提示', content: '当前微信版本过低,无法使用该功能' })
|
||||
}
|
||||
|
||||
// 方式二:比较版本号
|
||||
function compareVersion(v1, v2) {
|
||||
v1 = v1.split('.')
|
||||
v2 = v2.split('.')
|
||||
const len = Math.max(v1.length, v2.length)
|
||||
while (v1.length < len) v1.push('0')
|
||||
while (v2.length < len) v2.push('0')
|
||||
for (let i = 0; i < len; i++) {
|
||||
const num1 = parseInt(v1[i])
|
||||
const num2 = parseInt(v2[i])
|
||||
if (num1 > num2) return 1
|
||||
else if (num1 < num2) return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const SDKVersion = wx.getSystemInfoSync().SDKVersion
|
||||
if (compareVersion(SDKVersion, '1.1.0') >= 0) {
|
||||
wx.openBluetoothAdapter()
|
||||
}
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需更详细的配置项说明,可直接抓取:
|
||||
- 全局配置:https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/app.html
|
||||
- 页面配置:https://developers.weixin.qq.com/miniprogram/dev/reference/configuration/page.html
|
||||
- 场景值列表:https://developers.weixin.qq.com/miniprogram/dev/reference/scene-list.html
|
||||
431
_DEL/wechat-miniprogram/steering/frontend-api.md
Normal file
431
_DEL/wechat-miniprogram/steering/frontend-api.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# 前端 API
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/api/
|
||||
|
||||
## API 调用约定
|
||||
|
||||
小程序 API 分三类:
|
||||
- **事件监听 API**:以 `on` 开头,如 `wx.onSocketOpen`
|
||||
- **同步 API**:以 `Sync` 结尾,如 `wx.setStorageSync`
|
||||
- **异步 API**:大多数 API,支持回调和 Promise
|
||||
|
||||
```javascript
|
||||
// 回调风格
|
||||
wx.request({
|
||||
url: 'https://example.com/api',
|
||||
success(res) { console.log(res.data) },
|
||||
fail(err) { console.error(err) },
|
||||
complete() { /* 无论成功失败都执行 */ }
|
||||
})
|
||||
|
||||
// Promise 风格(基础库 2.10.2+,除部分 API 外均支持)
|
||||
const res = await wx.request({ url: 'https://example.com/api' })
|
||||
// 注意:部分 API 不支持 Promise,如 wx.downloadFile、wx.connectSocket 等
|
||||
```
|
||||
|
||||
## 路由
|
||||
|
||||
```javascript
|
||||
// 保留当前页面,跳转(栈 +1,最多 10 层)
|
||||
wx.navigateTo({
|
||||
url: '/pages/detail/detail?id=1&name=test',
|
||||
events: { /* EventChannel 监听 */ },
|
||||
success(res) { res.eventChannel.emit('data', {}) }
|
||||
})
|
||||
|
||||
// 关闭当前页面,跳转
|
||||
wx.redirectTo({ url: '/pages/other/other' })
|
||||
|
||||
// 关闭所有页面,打开
|
||||
wx.reLaunch({ url: '/pages/index/index' })
|
||||
|
||||
// 跳转到 tabBar 页面(关闭其他非 tabBar 页面)
|
||||
wx.switchTab({ url: '/pages/index/index' })
|
||||
// ⚠️ switchTab 不支持带参数
|
||||
|
||||
// 返回
|
||||
wx.navigateBack({ delta: 1 })
|
||||
```
|
||||
|
||||
## 网络
|
||||
|
||||
### wx.request — HTTP 请求
|
||||
```javascript
|
||||
wx.request({
|
||||
url: 'https://example.com/api/data',
|
||||
method: 'POST', // GET, POST, PUT, DELETE, OPTIONS, HEAD, TRACE, CONNECT
|
||||
data: { key: 'value' },
|
||||
header: {
|
||||
'content-type': 'application/json',
|
||||
'Authorization': 'Bearer token'
|
||||
},
|
||||
timeout: 60000, // 超时时间(ms)
|
||||
dataType: 'json', // 返回数据自动 JSON.parse
|
||||
responseType: 'text', // text 或 arraybuffer
|
||||
enableHttp2: false,
|
||||
enableQuic: false,
|
||||
enableCache: false,
|
||||
success(res) {
|
||||
res.statusCode // HTTP 状态码
|
||||
res.data // 响应数据
|
||||
res.header // 响应头
|
||||
},
|
||||
fail(err) {
|
||||
err.errMsg // 错误信息
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- 需在小程序管理后台配置合法域名(request 合法域名)
|
||||
- 默认超时 60s,可在 `app.json` 的 `networkTimeout.request` 配置
|
||||
- 最大并发限制 10 个
|
||||
- HTTPS 只支持 TLS 1.2+
|
||||
- `data` 为 Object 时:GET 请求会序列化为 query string;POST 请求 header 为 `application/json` 时序列化为 JSON,为 `application/x-www-form-urlencoded` 时序列化为 query string
|
||||
|
||||
### wx.uploadFile — 上传文件
|
||||
```javascript
|
||||
wx.chooseImage({
|
||||
success(res) {
|
||||
wx.uploadFile({
|
||||
url: 'https://example.com/upload',
|
||||
filePath: res.tempFilePaths[0],
|
||||
name: 'file',
|
||||
formData: { user: 'test' },
|
||||
success(uploadRes) {
|
||||
console.log(uploadRes.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### wx.downloadFile — 下载文件
|
||||
```javascript
|
||||
wx.downloadFile({
|
||||
url: 'https://example.com/file.pdf',
|
||||
success(res) {
|
||||
if (res.statusCode === 200) {
|
||||
wx.openDocument({ filePath: res.tempFilePath })
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
```javascript
|
||||
const socketTask = wx.connectSocket({
|
||||
url: 'wss://example.com/ws',
|
||||
header: { 'Authorization': 'Bearer token' }
|
||||
})
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
socketTask.send({ data: 'hello' })
|
||||
})
|
||||
socketTask.onMessage((res) => {
|
||||
console.log(res.data)
|
||||
})
|
||||
socketTask.onClose(() => {})
|
||||
socketTask.onError((err) => {})
|
||||
socketTask.close()
|
||||
```
|
||||
|
||||
## 数据缓存
|
||||
|
||||
```javascript
|
||||
// 异步
|
||||
wx.setStorage({ key: 'key', data: 'value' })
|
||||
wx.getStorage({
|
||||
key: 'key',
|
||||
success(res) { console.log(res.data) }
|
||||
})
|
||||
wx.removeStorage({ key: 'key' })
|
||||
wx.clearStorage()
|
||||
wx.getStorageInfo({
|
||||
success(res) {
|
||||
res.keys // 所有 key
|
||||
res.currentSize // 当前占用(KB)
|
||||
res.limitSize // 限制大小(KB)
|
||||
}
|
||||
})
|
||||
|
||||
// 同步
|
||||
wx.setStorageSync('key', 'value')
|
||||
const value = wx.getStorageSync('key')
|
||||
wx.removeStorageSync('key')
|
||||
wx.clearStorageSync()
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个 key 上限 1MB
|
||||
- 总上限 10MB
|
||||
- 隔离策略:同一小程序不同用户数据隔离
|
||||
|
||||
## 界面
|
||||
|
||||
### 交互反馈
|
||||
```javascript
|
||||
// Toast
|
||||
wx.showToast({ title: '成功', icon: 'success', duration: 2000 })
|
||||
wx.showToast({ title: '加载中', icon: 'loading' })
|
||||
wx.showToast({ title: '自定义图标', icon: 'none' }) // 无图标,可显示两行文字
|
||||
wx.hideToast()
|
||||
|
||||
// Loading
|
||||
wx.showLoading({ title: '加载中', mask: true })
|
||||
wx.hideLoading()
|
||||
|
||||
// Modal
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除?',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
success(res) {
|
||||
if (res.confirm) { /* 确定 */ }
|
||||
else if (res.cancel) { /* 取消 */ }
|
||||
}
|
||||
})
|
||||
|
||||
// ActionSheet
|
||||
wx.showActionSheet({
|
||||
itemList: ['选项A', '选项B', '选项C'],
|
||||
success(res) {
|
||||
console.log(res.tapIndex) // 0, 1, 2
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 导航栏
|
||||
```javascript
|
||||
wx.setNavigationBarTitle({ title: '新标题' })
|
||||
wx.setNavigationBarColor({
|
||||
frontColor: '#ffffff', // 仅支持 #ffffff 和 #000000
|
||||
backgroundColor: '#ff0000',
|
||||
animation: { duration: 400, timingFunc: 'easeIn' }
|
||||
})
|
||||
wx.showNavigationBarLoading()
|
||||
wx.hideNavigationBarLoading()
|
||||
```
|
||||
|
||||
### 下拉刷新
|
||||
```javascript
|
||||
wx.startPullDownRefresh() // 触发下拉刷新
|
||||
wx.stopPullDownRefresh() // 停止下拉刷新
|
||||
```
|
||||
|
||||
### 滚动
|
||||
```javascript
|
||||
wx.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration: 300
|
||||
})
|
||||
```
|
||||
|
||||
### TabBar
|
||||
```javascript
|
||||
wx.showTabBar()
|
||||
wx.hideTabBar()
|
||||
wx.setTabBarBadge({ index: 0, text: '1' })
|
||||
wx.removeTabBarBadge({ index: 0 })
|
||||
wx.showTabBarRedDot({ index: 0 })
|
||||
wx.hideTabBarRedDot({ index: 0 })
|
||||
wx.setTabBarItem({
|
||||
index: 0,
|
||||
text: '新文字',
|
||||
iconPath: '/images/new-icon.png',
|
||||
selectedIconPath: '/images/new-icon-active.png'
|
||||
})
|
||||
wx.setTabBarStyle({
|
||||
color: '#000',
|
||||
selectedColor: '#ff0000',
|
||||
backgroundColor: '#ffffff'
|
||||
})
|
||||
```
|
||||
|
||||
## 媒体
|
||||
|
||||
### 图片
|
||||
```javascript
|
||||
// 选择图片(从相册或拍照)
|
||||
wx.chooseMedia({
|
||||
count: 9,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
success(res) {
|
||||
res.tempFiles // [{ tempFilePath, size, ... }]
|
||||
}
|
||||
})
|
||||
|
||||
// 预览图片
|
||||
wx.previewImage({
|
||||
current: 'url', // 当前显示图片的链接
|
||||
urls: ['url1', 'url2', 'url3']
|
||||
})
|
||||
|
||||
// 获取图片信息
|
||||
wx.getImageInfo({
|
||||
src: 'path/or/url',
|
||||
success(res) {
|
||||
res.width
|
||||
res.height
|
||||
res.path
|
||||
res.orientation
|
||||
res.type
|
||||
}
|
||||
})
|
||||
|
||||
// 保存图片到相册
|
||||
wx.saveImageToPhotosAlbum({
|
||||
filePath: 'tempFilePath',
|
||||
success() {}
|
||||
})
|
||||
```
|
||||
|
||||
## 位置
|
||||
|
||||
```javascript
|
||||
// 获取位置(需要用户授权 scope.userLocation)
|
||||
wx.getLocation({
|
||||
type: 'gcj02', // wgs84 或 gcj02
|
||||
success(res) {
|
||||
res.latitude
|
||||
res.longitude
|
||||
res.speed
|
||||
res.accuracy
|
||||
}
|
||||
})
|
||||
|
||||
// 打开地图选择位置
|
||||
wx.chooseLocation({
|
||||
success(res) {
|
||||
res.name
|
||||
res.address
|
||||
res.latitude
|
||||
res.longitude
|
||||
}
|
||||
})
|
||||
|
||||
// 打开内置地图查看位置
|
||||
wx.openLocation({
|
||||
latitude: 23.099994,
|
||||
longitude: 113.324520,
|
||||
name: '位置名',
|
||||
address: '详细地址',
|
||||
scale: 18
|
||||
})
|
||||
```
|
||||
|
||||
## 文件系统
|
||||
|
||||
```javascript
|
||||
const fs = wx.getFileSystemManager()
|
||||
|
||||
// 读文件
|
||||
fs.readFile({
|
||||
filePath: `${wx.env.USER_DATA_PATH}/hello.txt`,
|
||||
encoding: 'utf8',
|
||||
success(res) { console.log(res.data) }
|
||||
})
|
||||
|
||||
// 写文件
|
||||
fs.writeFile({
|
||||
filePath: `${wx.env.USER_DATA_PATH}/hello.txt`,
|
||||
data: 'some text',
|
||||
encoding: 'utf8',
|
||||
success() {}
|
||||
})
|
||||
|
||||
// 其他:appendFile, mkdir, rmdir, readdir, stat, unlink, rename, copyFile, access
|
||||
```
|
||||
|
||||
## WXML 节点查询
|
||||
|
||||
```javascript
|
||||
// SelectorQuery
|
||||
const query = wx.createSelectorQuery()
|
||||
query.select('#the-id').boundingClientRect((rect) => {
|
||||
rect.top // 节点上边界坐标
|
||||
rect.right
|
||||
rect.bottom
|
||||
rect.left
|
||||
rect.width
|
||||
rect.height
|
||||
})
|
||||
query.selectViewport().scrollOffset((res) => {
|
||||
res.scrollTop
|
||||
res.scrollLeft
|
||||
})
|
||||
query.exec()
|
||||
|
||||
// 在组件中使用
|
||||
const query = this.createSelectorQuery()
|
||||
query.select('#the-id').boundingClientRect().exec(callback)
|
||||
|
||||
// IntersectionObserver(监听元素与视口交叉)
|
||||
const observer = wx.createIntersectionObserver(this, { thresholds: [0, 0.5, 1] })
|
||||
observer.relativeToViewport({ bottom: 100 })
|
||||
observer.observe('.target-class', (res) => {
|
||||
res.intersectionRatio // 交叉比例
|
||||
res.intersectionRect // 交叉区域
|
||||
})
|
||||
// 停止监听
|
||||
observer.disconnect()
|
||||
```
|
||||
|
||||
## 系统信息
|
||||
|
||||
```javascript
|
||||
// 同步获取(推荐用新 API)
|
||||
const windowInfo = wx.getWindowInfo()
|
||||
// windowInfo.windowWidth / windowInfo.windowHeight / windowInfo.pixelRatio
|
||||
// windowInfo.statusBarHeight / windowInfo.safeArea
|
||||
|
||||
const appBaseInfo = wx.getAppBaseInfo()
|
||||
// appBaseInfo.SDKVersion / appBaseInfo.language / appBaseInfo.theme
|
||||
|
||||
const deviceInfo = wx.getDeviceInfo()
|
||||
// deviceInfo.platform / deviceInfo.brand / deviceInfo.model / deviceInfo.system
|
||||
|
||||
// 旧 API(仍可用但不推荐)
|
||||
const sysInfo = wx.getSystemInfoSync()
|
||||
```
|
||||
|
||||
## 转发分享
|
||||
|
||||
```javascript
|
||||
// 页面中定义 onShareAppMessage 即可开启转发
|
||||
Page({
|
||||
onShareAppMessage(res) {
|
||||
if (res.from === 'button') {
|
||||
// 来自页面内转发按钮
|
||||
}
|
||||
return {
|
||||
title: '自定义转发标题',
|
||||
path: '/pages/index/index?id=123',
|
||||
imageUrl: '/images/share.png'
|
||||
}
|
||||
},
|
||||
onShareTimeline() {
|
||||
// 分享到朋友圈(基础库 2.11.3+)
|
||||
return {
|
||||
title: '朋友圈标题',
|
||||
query: 'id=123',
|
||||
imageUrl: '/images/share.png'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 页面内转发按钮 -->
|
||||
<button open-type="share">转发</button>
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
API 文档非常庞大,如需查看某个具体 API 的完整参数,可抓取:
|
||||
- API 总览:https://developers.weixin.qq.com/miniprogram/dev/api/
|
||||
- 具体 API:`https://developers.weixin.qq.com/miniprogram/dev/api/{分类}/{api名}.html`
|
||||
例如:https://developers.weixin.qq.com/miniprogram/dev/api/network/request/wx.request.html
|
||||
362
_DEL/wechat-miniprogram/steering/login-auth.md
Normal file
362
_DEL/wechat-miniprogram/steering/login-auth.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# 登录与鉴权
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
|
||||
|
||||
## 登录流程概览
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ 小程序 │ │ 开发者服务器 │ │ 微信服务器 │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ 1. wx.login() │ │
|
||||
│ ──────────────>│ │
|
||||
│ 返回 code │ │
|
||||
│ │ │
|
||||
│ 2. wx.request │ │
|
||||
│ 发送 code │ │
|
||||
│ ──────────────>│ │
|
||||
│ │ 3. code2Session│
|
||||
│ │ ──────────────>│
|
||||
│ │ 返回 openid + │
|
||||
│ │ session_key │
|
||||
│ │ <──────────────│
|
||||
│ │ │
|
||||
│ │ 4. 生成自定义 │
|
||||
│ │ 登录态 token │
|
||||
│ │ │
|
||||
│ 5. 返回 token │ │
|
||||
│ <──────────────│ │
|
||||
│ │ │
|
||||
│ 6. 后续请求 │ │
|
||||
│ 携带 token │ │
|
||||
│ ──────────────>│ │
|
||||
│ │ 7. 校验 token │
|
||||
│ │ 查 openid │
|
||||
```
|
||||
|
||||
## 前端登录实现
|
||||
|
||||
### 基本登录
|
||||
|
||||
```javascript
|
||||
// app.js 或 utils/auth.js
|
||||
async function login() {
|
||||
// 1. 调用 wx.login 获取 code
|
||||
const { code } = await wx.login()
|
||||
|
||||
// 2. 发送 code 到后端换取 token
|
||||
const res = await wx.request({
|
||||
url: 'https://your-server.com/api/auth/login',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
})
|
||||
|
||||
if (res.data.token) {
|
||||
// 3. 存储 token
|
||||
wx.setStorageSync('token', res.data.token)
|
||||
return res.data
|
||||
} else {
|
||||
throw new Error('登录失败')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 检查登录态
|
||||
|
||||
```javascript
|
||||
// 检查 session_key 是否过期
|
||||
function checkSession() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.checkSession({
|
||||
success: () => resolve(true), // session_key 未过期
|
||||
fail: () => resolve(false) // session_key 已过期,需重新 login
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 启动时检查
|
||||
async function checkAndLogin() {
|
||||
const token = wx.getStorageSync('token')
|
||||
if (!token) {
|
||||
return await login()
|
||||
}
|
||||
|
||||
const isSessionValid = await checkSession()
|
||||
if (!isSessionValid) {
|
||||
// session_key 过期,重新登录
|
||||
return await login()
|
||||
}
|
||||
|
||||
return { token }
|
||||
}
|
||||
```
|
||||
|
||||
### 封装请求(自动携带 token)
|
||||
|
||||
```typescript
|
||||
// utils/request.ts
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
header?: Record<string, string>
|
||||
}
|
||||
|
||||
function request(options: RequestOptions): Promise<any> {
|
||||
const token = wx.getStorageSync('token')
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
...options,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...options.header
|
||||
},
|
||||
success(res) {
|
||||
if (res.statusCode === 401) {
|
||||
// token 过期,重新登录
|
||||
wx.removeStorageSync('token')
|
||||
login().then(() => {
|
||||
// 重试原请求
|
||||
request(options).then(resolve).catch(reject)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(res.data)
|
||||
} else {
|
||||
reject(res)
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 后端登录实现(Python / FastAPI 示例)
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
APPID = "your_appid"
|
||||
SECRET = "your_secret"
|
||||
JWT_SECRET = "your_jwt_secret"
|
||||
|
||||
@router.post("/auth/login")
|
||||
async def login(code: str):
|
||||
# 1. 用 code 换取 openid + session_key
|
||||
url = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
params = {
|
||||
"appid": APPID,
|
||||
"secret": SECRET,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code"
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params)
|
||||
data = resp.json()
|
||||
|
||||
if "errcode" in data and data["errcode"] != 0:
|
||||
raise HTTPException(400, f"微信登录失败: {data.get('errmsg')}")
|
||||
|
||||
openid = data["openid"]
|
||||
session_key = data["session_key"]
|
||||
unionid = data.get("unionid")
|
||||
|
||||
# 2. 查找或创建用户
|
||||
user = await find_or_create_user(openid, unionid)
|
||||
|
||||
# 3. 生成 JWT token
|
||||
token = jwt.encode({
|
||||
"sub": str(user.id),
|
||||
"openid": openid,
|
||||
"exp": datetime.utcnow() + timedelta(days=7)
|
||||
}, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
# 4. 缓存 session_key(用于后续解密)
|
||||
await cache_session_key(openid, session_key)
|
||||
|
||||
return {"token": token, "user": user.to_dict()}
|
||||
```
|
||||
|
||||
## 获取手机号
|
||||
|
||||
### 前端
|
||||
|
||||
```xml
|
||||
<!-- 基础库 2.21.2+ 推荐用法 -->
|
||||
<button open-type="getPhoneNumber" bindgetphonenumber="getPhoneNumber">
|
||||
授权手机号
|
||||
</button>
|
||||
```
|
||||
|
||||
```javascript
|
||||
Page({
|
||||
getPhoneNumber(e) {
|
||||
if (e.detail.errMsg === 'getPhoneNumber:ok') {
|
||||
const code = e.detail.code // 动态令牌(新版)
|
||||
// 发送 code 到后端
|
||||
wx.request({
|
||||
url: 'https://your-server.com/api/auth/phone',
|
||||
method: 'POST',
|
||||
data: { code },
|
||||
success(res) {
|
||||
console.log('手机号:', res.data.phoneNumber)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('用户拒绝授权')
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 后端
|
||||
|
||||
```python
|
||||
@router.post("/auth/phone")
|
||||
async def get_phone(code: str, token: str = Depends(get_current_token)):
|
||||
access_token = await get_access_token()
|
||||
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json={"code": code})
|
||||
data = resp.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
raise HTTPException(400, f"获取手机号失败: {data.get('errmsg')}")
|
||||
|
||||
phone_info = data["phone_info"]
|
||||
phone_number = phone_info["phoneNumber"]
|
||||
|
||||
# 更新用户手机号
|
||||
await update_user_phone(token.openid, phone_number)
|
||||
|
||||
return {"phoneNumber": phone_number}
|
||||
```
|
||||
|
||||
## 用户信息获取(当前方案)
|
||||
|
||||
`wx.getUserProfile` 已于基础库 2.27.1 废弃。当前获取用户头像和昵称的方式:
|
||||
|
||||
### 头像昵称填写能力
|
||||
|
||||
```xml
|
||||
<!-- 头像选择 -->
|
||||
<button open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<image src="{{avatarUrl}}" class="avatar"/>
|
||||
</button>
|
||||
|
||||
<!-- 昵称填写(type="nickname" 自动弹出微信昵称) -->
|
||||
<input type="nickname" placeholder="请输入昵称" bindchange="onNicknameChange"/>
|
||||
```
|
||||
|
||||
```javascript
|
||||
Page({
|
||||
data: {
|
||||
avatarUrl: '/images/default-avatar.png',
|
||||
nickname: ''
|
||||
},
|
||||
onChooseAvatar(e) {
|
||||
const { avatarUrl } = e.detail
|
||||
// avatarUrl 是临时路径,需上传到自己服务器
|
||||
this.setData({ avatarUrl })
|
||||
this.uploadAvatar(avatarUrl)
|
||||
},
|
||||
onNicknameChange(e) {
|
||||
this.setData({ nickname: e.detail.value })
|
||||
},
|
||||
async uploadAvatar(tempPath) {
|
||||
wx.uploadFile({
|
||||
url: 'https://your-server.com/api/upload/avatar',
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
success(res) {
|
||||
const data = JSON.parse(res.data)
|
||||
// 保存头像 URL
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 授权管理
|
||||
|
||||
```javascript
|
||||
// 查看当前授权状态
|
||||
wx.getSetting({
|
||||
success(res) {
|
||||
res.authSetting['scope.userLocation'] // true/false/undefined
|
||||
res.authSetting['scope.writePhotosAlbum']
|
||||
// undefined = 未请求过,true = 已授权,false = 已拒绝
|
||||
}
|
||||
})
|
||||
|
||||
// 提前请求授权
|
||||
wx.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success() { /* 授权成功 */ },
|
||||
fail() { /* 用户拒绝 */ }
|
||||
})
|
||||
|
||||
// 打开设置页(用户之前拒绝后,引导重新授权)
|
||||
wx.openSetting({
|
||||
success(res) {
|
||||
res.authSetting // 最新的授权状态
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 常用 scope
|
||||
|
||||
| scope | 说明 | 对应 API |
|
||||
|-------|------|----------|
|
||||
| scope.userLocation | 精确地理位置 | wx.getLocation |
|
||||
| scope.userFuzzyLocation | 模糊地理位置 | wx.getFuzzyLocation |
|
||||
| scope.record | 麦克风 | wx.startRecord |
|
||||
| scope.camera | 摄像头 | camera 组件 |
|
||||
| scope.writePhotosAlbum | 保存到相册 | wx.saveImageToPhotosAlbum |
|
||||
| scope.bluetooth | 蓝牙 | wx.openBluetoothAdapter |
|
||||
| scope.addPhoneContact | 添加到联系人 | wx.addPhoneContact |
|
||||
| scope.addPhoneCalendar | 添加到日历 | wx.addPhoneCalendar |
|
||||
| scope.werun | 微信运动步数 | wx.getWeRunData |
|
||||
| scope.userInfo | 已废弃 | - |
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **session_key 绝不能下发到前端**
|
||||
2. **code 只能使用一次**,且有效期很短(约 5 分钟)
|
||||
3. **不要在前端存储 openid**,通过 token 在后端关联
|
||||
4. **JWT token 设置合理过期时间**(建议 7 天,配合 refresh token)
|
||||
5. **HTTPS 是强制要求**,所有网络请求必须 HTTPS
|
||||
6. **敏感数据解密**要在服务端进行,不要在前端解密
|
||||
7. **access_token 要在服务端缓存**,不要每次请求都重新获取
|
||||
8. **unionid 需要绑定开放平台**才能获取,用于跨应用用户关联
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: wx.login 的 code 可以多次使用吗?
|
||||
A: 不可以,每个 code 只能使用一次,且有效期约 5 分钟。
|
||||
|
||||
### Q: session_key 什么时候会过期?
|
||||
A: 微信不会通知过期时间,需要通过 `wx.checkSession` 检查。用户越频繁使用小程序,session_key 有效期越长。
|
||||
|
||||
### Q: 如何获取 unionid?
|
||||
A: 需要在微信开放平台绑定小程序。绑定后,code2Session 会返回 unionid。
|
||||
|
||||
### Q: getUserProfile 废弃后怎么获取用户信息?
|
||||
A: 使用头像昵称填写能力(`<button open-type="chooseAvatar">` + `<input type="nickname">`),让用户主动填写。
|
||||
|
||||
## 在线查询
|
||||
|
||||
- 登录流程:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
|
||||
- 手机号快速验证:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
|
||||
- 用户信息:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html
|
||||
- 授权:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/authorize.html
|
||||
302
_DEL/wechat-miniprogram/steering/server-api.md
Normal file
302
_DEL/wechat-miniprogram/steering/server-api.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# 服务端 API
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/api-backend/
|
||||
|
||||
服务端 API 是小程序后端服务器调用微信服务器的 HTTP 接口。
|
||||
|
||||
## access_token
|
||||
|
||||
几乎所有服务端 API 都需要 access_token。
|
||||
|
||||
```
|
||||
GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"expires_in": 7200
|
||||
}
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- 有效期 2 小时,需要定时刷新并缓存
|
||||
- 每日调用上限有限制
|
||||
- 多服务器部署时需要中控服务器统一管理 access_token
|
||||
- 刷新 access_token 会使旧的立即失效
|
||||
|
||||
## 登录 — code2Session
|
||||
|
||||
```
|
||||
GET https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=SECRET&js_code=JSCODE&grant_type=authorization_code
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"openid": "OPENID",
|
||||
"session_key": "SESSION_KEY",
|
||||
"unionid": "UNIONID",
|
||||
"errcode": 0,
|
||||
"errmsg": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| openid | 用户唯一标识(同一小程序内唯一) |
|
||||
| session_key | 会话密钥(用于解密用户数据) |
|
||||
| unionid | 用户在开放平台的唯一标识(需绑定开放平台) |
|
||||
|
||||
**安全要求**:
|
||||
- `session_key` 不能下发到前端
|
||||
- `js_code` 只能使用一次
|
||||
- 该接口不需要 access_token
|
||||
|
||||
## 手机号
|
||||
|
||||
### 获取手机号(新版,推荐)
|
||||
|
||||
前端通过 `<button open-type="getPhoneNumber">` 获取 code,后端用 code 换取手机号:
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"code": "动态令牌code"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"errcode": 0,
|
||||
"errmsg": "ok",
|
||||
"phone_info": {
|
||||
"phoneNumber": "13800138000",
|
||||
"purePhoneNumber": "13800138000",
|
||||
"countryCode": "86",
|
||||
"watermark": {
|
||||
"timestamp": 1637744274,
|
||||
"appid": "APPID"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 旧版解密方式(不推荐)
|
||||
|
||||
使用 session_key + iv 解密 encryptedData,获取手机号。
|
||||
|
||||
## 小程序码
|
||||
|
||||
### 获取不限制的小程序码(推荐)
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"scene": "id=123",
|
||||
"page": "pages/index/index",
|
||||
"check_path": true,
|
||||
"env_version": "release",
|
||||
"width": 430,
|
||||
"auto_color": false,
|
||||
"line_color": {"r": 0, "g": 0, "b": 0},
|
||||
"is_hyaline": false
|
||||
}
|
||||
```
|
||||
|
||||
- `scene` 最大 32 个可见字符
|
||||
- 返回二进制图片数据(Content-Type: image/jpeg 或 image/png)
|
||||
- 数量不限制
|
||||
|
||||
### 获取小程序码(有限制)
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/getwxacode?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"path": "pages/index/index?id=123",
|
||||
"width": 430
|
||||
}
|
||||
```
|
||||
|
||||
- `path` 可带参数,最大 128 字节
|
||||
- 总数限制 10 万个
|
||||
|
||||
### 获取小程序二维码(有限制)
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/cgi-bin/wxaapp/createwxaqrcode?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"path": "pages/index/index?id=123",
|
||||
"width": 430
|
||||
}
|
||||
```
|
||||
|
||||
- 总数限制 10 万个
|
||||
|
||||
## 订阅消息
|
||||
|
||||
### 发送订阅消息
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"touser": "OPENID",
|
||||
"template_id": "TEMPLATE_ID",
|
||||
"page": "pages/index/index",
|
||||
"miniprogram_state": "formal",
|
||||
"lang": "zh_CN",
|
||||
"data": {
|
||||
"thing1": { "value": "订单已发货" },
|
||||
"time2": { "value": "2025-01-01 12:00" },
|
||||
"character_string3": { "value": "SF1234567890" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**前端需先请求用户授权**:
|
||||
```javascript
|
||||
wx.requestSubscribeMessage({
|
||||
tmplIds: ['TEMPLATE_ID'],
|
||||
success(res) {
|
||||
// res[TEMPLATE_ID] === 'accept' 表示用户同意
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 客服消息
|
||||
|
||||
### 发送客服消息
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN
|
||||
|
||||
// 文本消息
|
||||
{
|
||||
"touser": "OPENID",
|
||||
"msgtype": "text",
|
||||
"text": { "content": "Hello" }
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
{
|
||||
"touser": "OPENID",
|
||||
"msgtype": "image",
|
||||
"image": { "media_id": "MEDIA_ID" }
|
||||
}
|
||||
|
||||
// 小程序卡片
|
||||
{
|
||||
"touser": "OPENID",
|
||||
"msgtype": "miniprogrampage",
|
||||
"miniprogrampage": {
|
||||
"title": "标题",
|
||||
"pagepath": "pages/index/index",
|
||||
"thumb_media_id": "MEDIA_ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 内容安全
|
||||
|
||||
### 文本内容安全检测
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/msg_sec_check?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"content": "待检测文本",
|
||||
"version": 2,
|
||||
"scene": 1,
|
||||
"openid": "OPENID"
|
||||
}
|
||||
```
|
||||
|
||||
scene 值:1-社交日志 2-评论 3-论坛 4-社交日志
|
||||
|
||||
### 图片内容安全检测
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/img_sec_check?access_token=ACCESS_TOKEN
|
||||
|
||||
// multipart/form-data 上传图片
|
||||
```
|
||||
|
||||
## 数据分析
|
||||
|
||||
```
|
||||
// 获取日访问数据
|
||||
POST https://api.weixin.qq.com/datacube/getweanalysisappiddailyvisittrend?access_token=ACCESS_TOKEN
|
||||
{ "begin_date": "20250101", "end_date": "20250101" }
|
||||
|
||||
// 获取用户画像
|
||||
POST https://api.weixin.qq.com/datacube/getweanalysisappiduserportrait?access_token=ACCESS_TOKEN
|
||||
{ "begin_date": "20250101", "end_date": "20250107" }
|
||||
```
|
||||
|
||||
## URL Scheme / URL Link
|
||||
|
||||
### 生成 URL Scheme(用于短信/邮件等外部跳转)
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/generatescheme?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"jump_wxa": {
|
||||
"path": "pages/index/index",
|
||||
"query": "id=123",
|
||||
"env_version": "release"
|
||||
},
|
||||
"is_expire": true,
|
||||
"expire_type": 0,
|
||||
"expire_time": 1672502400
|
||||
}
|
||||
```
|
||||
|
||||
### 生成 URL Link(用于短信/邮件等外部跳转)
|
||||
|
||||
```
|
||||
POST https://api.weixin.qq.com/wxa/generate_urllink?access_token=ACCESS_TOKEN
|
||||
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"query": "id=123",
|
||||
"is_expire": true,
|
||||
"expire_type": 0,
|
||||
"expire_time": 1672502400,
|
||||
"env_version": "release"
|
||||
}
|
||||
```
|
||||
|
||||
## 常见错误码
|
||||
|
||||
| errcode | 说明 |
|
||||
|---------|------|
|
||||
| -1 | 系统繁忙 |
|
||||
| 0 | 请求成功 |
|
||||
| 40001 | access_token 无效或过期 |
|
||||
| 40013 | 不合法的 AppID |
|
||||
| 40029 | 不合法的 code(已使用或过期) |
|
||||
| 40125 | 不合法的 appsecret |
|
||||
| 41002 | 缺少 appid |
|
||||
| 41004 | 缺少 appsecret |
|
||||
| 42001 | access_token 过期 |
|
||||
| 45009 | 调用超过频率限制 |
|
||||
| 45011 | API 调用太频繁 |
|
||||
| 48001 | API 未授权 |
|
||||
| 61024 | 该 code 已被使用 |
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需查看某个具体服务端 API 的完整参数,可抓取:
|
||||
- 服务端 API 总览:https://developers.weixin.qq.com/miniprogram/dev/api-backend/
|
||||
- 登录:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html
|
||||
- 手机号:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html
|
||||
- 小程序码:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/qr-code/wxacode.getUnlimited.html
|
||||
- 订阅消息:https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/subscribe-message/subscribeMessage.send.html
|
||||
511
_DEL/wechat-miniprogram/steering/tdesign.md
Normal file
511
_DEL/wechat-miniprogram/steering/tdesign.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# TDesign 小程序组件库
|
||||
|
||||
> 官方文档:https://tdesign.tencent.com/miniprogram/overview
|
||||
> GitHub:https://github.com/Tencent/tdesign-miniprogram
|
||||
|
||||
TDesign 是腾讯出品的企业级设计体系,提供微信小程序组件库,包含 60+ 高质量组件。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm i tdesign-miniprogram -S --production
|
||||
```
|
||||
|
||||
安装后在微信开发者工具中构建 npm:`工具 → 构建 npm`。
|
||||
|
||||
构建时若出现 `NPM packages not found`,在 `project.config.json` 补充:
|
||||
```json
|
||||
{
|
||||
"setting": {
|
||||
"packNpmManually": true,
|
||||
"packNpmRelationList": [
|
||||
{
|
||||
"packageJsonPath": "./package.json",
|
||||
"miniprogramNpmDistDir": "./miniprogram/"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
构建成功后勾选 `将 JS 编译成 ES5`。
|
||||
|
||||
## 必要配置
|
||||
|
||||
### 移除 style: v2
|
||||
|
||||
将 `app.json` 中的 `"style": "v2"` 移除,否则会导致 TDesign 组件样式错乱。
|
||||
|
||||
### TypeScript 配置
|
||||
|
||||
如果使用 TypeScript 开发,修改 `tsconfig.json`:
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"tdesign-miniprogram/*": ["./miniprogram/miniprogram_npm/tdesign-miniprogram/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 最低基础库版本
|
||||
|
||||
`^2.12.0`
|
||||
|
||||
## 使用组件
|
||||
|
||||
### 引入
|
||||
|
||||
在页面或组件的 `.json` 中注册:
|
||||
```json
|
||||
{
|
||||
"usingComponents": {
|
||||
"t-button": "tdesign-miniprogram/button/button"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
全局引入则在 `app.json` 中配置 `usingComponents`。
|
||||
|
||||
### 使用
|
||||
|
||||
```xml
|
||||
<t-button theme="primary">按钮</t-button>
|
||||
```
|
||||
|
||||
### 引入路径规则
|
||||
|
||||
所有组件路径格式:`tdesign-miniprogram/{组件名}/{组件名}`
|
||||
|
||||
```json
|
||||
{
|
||||
"usingComponents": {
|
||||
"t-button": "tdesign-miniprogram/button/button",
|
||||
"t-input": "tdesign-miniprogram/input/input",
|
||||
"t-cell": "tdesign-miniprogram/cell/cell",
|
||||
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group",
|
||||
"t-icon": "tdesign-miniprogram/icon/icon",
|
||||
"t-image": "tdesign-miniprogram/image/image",
|
||||
"t-dialog": "tdesign-miniprogram/dialog/dialog",
|
||||
"t-toast": "tdesign-miniprogram/toast/toast",
|
||||
"t-navbar": "tdesign-miniprogram/navbar/navbar",
|
||||
"t-tabs": "tdesign-miniprogram/tabs/tabs",
|
||||
"t-tab-panel": "tdesign-miniprogram/tab-panel/tab-panel",
|
||||
"t-popup": "tdesign-miniprogram/popup/popup",
|
||||
"t-picker": "tdesign-miniprogram/picker/picker",
|
||||
"t-picker-item": "tdesign-miniprogram/picker-item/picker-item",
|
||||
"t-tag": "tdesign-miniprogram/tag/tag",
|
||||
"t-avatar": "tdesign-miniprogram/avatar/avatar",
|
||||
"t-badge": "tdesign-miniprogram/badge/badge",
|
||||
"t-search": "tdesign-miniprogram/search/search",
|
||||
"t-empty": "tdesign-miniprogram/empty/empty",
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"t-skeleton": "tdesign-miniprogram/skeleton/skeleton",
|
||||
"t-swipe-cell": "tdesign-miniprogram/swipe-cell/swipe-cell",
|
||||
"t-tab-bar": "tdesign-miniprogram/tab-bar/tab-bar",
|
||||
"t-tab-bar-item": "tdesign-miniprogram/tab-bar-item/tab-bar-item"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 完整组件列表
|
||||
|
||||
### 基础(6)
|
||||
| 组件 | 标签 | 说明 |
|
||||
|------|------|------|
|
||||
| Button 按钮 | `t-button` | 主按钮、次按钮、文字按钮、图标按钮 |
|
||||
| Divider 分割线 | `t-divider` | 内容分隔 |
|
||||
| Fab 悬浮按钮 | `t-fab` | 浮动操作按钮 |
|
||||
| Icon 图标 | `t-icon` | 内置图标库 |
|
||||
| Layout 布局 | `t-row` / `t-col` | 栅格布局 |
|
||||
| Link 链接 | `t-link` | 文字链接 |
|
||||
|
||||
### 导航(8)
|
||||
| 组件 | 标签 | 说明 |
|
||||
|------|------|------|
|
||||
| BackTop 返回顶部 | `t-back-top` | 长页面返回顶部 |
|
||||
| Drawer 抽屉 | `t-drawer` | 侧边滑出面板 |
|
||||
| Indexes 索引 | `t-indexes` | 字母索引列表 |
|
||||
| Navbar 导航条 | `t-navbar` | 自定义顶部导航栏 |
|
||||
| SideBar 侧边导航栏 | `t-side-bar` / `t-side-bar-item` | 侧边分类导航 |
|
||||
| Steps 步骤条 | `t-steps` / `t-step-item` | 流程步骤展示 |
|
||||
| TabBar 底部标签栏 | `t-tab-bar` / `t-tab-bar-item` | 底部导航 |
|
||||
| Tabs 选项卡 | `t-tabs` / `t-tab-panel` | 顶部选项卡切换 |
|
||||
|
||||
### 输入(16)
|
||||
| 组件 | 标签 | 说明 |
|
||||
|------|------|------|
|
||||
| Calendar 日历 | `t-calendar` | 日期选择 |
|
||||
| Cascader 级联选择器 | `t-cascader` | 多级联动选择 |
|
||||
| CheckBox 多选框 | `t-checkbox` / `t-checkbox-group` | 多选 |
|
||||
| ColorPicker 颜色选择器 | `t-color-picker` | 颜色选取 |
|
||||
| DateTimePicker 日期选择器 | `t-date-time-picker` | 日期时间选择 |
|
||||
| Input 输入框 | `t-input` | 文本输入 |
|
||||
| Picker 选择器 | `t-picker` / `t-picker-item` | 滚动选择 |
|
||||
| Radio 单选框 | `t-radio` / `t-radio-group` | 单选 |
|
||||
| Rate 评分 | `t-rate` | 星级评分 |
|
||||
| Search 搜索框 | `t-search` | 搜索输入 |
|
||||
| Slider 滑动选择器 | `t-slider` | 滑块选择 |
|
||||
| Stepper 步进器 | `t-stepper` | 数量加减 |
|
||||
| Switch 开关 | `t-switch` | 开关切换 |
|
||||
| Textarea 多行文本框 | `t-textarea` | 多行输入 |
|
||||
| TreeSelect 树形选择器 | `t-tree-select` | 树形多级选择 |
|
||||
| Upload 上传 | `t-upload` | 文件/图片上传 |
|
||||
|
||||
### 数据展示(18)
|
||||
| 组件 | 标签 | 说明 |
|
||||
|------|------|------|
|
||||
| Avatar 头像 | `t-avatar` / `t-avatar-group` | 用户头像 |
|
||||
| Badge 徽章 | `t-badge` | 消息提示红点/数字 |
|
||||
| Cell 单元格 | `t-cell` / `t-cell-group` | 列表项 |
|
||||
| Collapse 折叠面板 | `t-collapse` / `t-collapse-panel` | 可展开/收起内容 |
|
||||
| CountDown 倒计时 | `t-count-down` | 倒计时显示 |
|
||||
| Empty 空状态 | `t-empty` | 无数据提示 |
|
||||
| Footer 页脚 | `t-footer` | 页面底部信息 |
|
||||
| Grid 宫格 | `t-grid` / `t-grid-item` | 宫格布局 |
|
||||
| Image 图片 | `t-image` | 增强图片(懒加载、加载状态) |
|
||||
| ImageViewer 图片预览 | `t-image-viewer` | 图片放大预览 |
|
||||
| Progress 进度条 | `t-progress` | 进度展示 |
|
||||
| QRCode 二维码 | `t-qrcode` | 二维码生成 |
|
||||
| Result 结果 | `t-result` | 操作结果反馈 |
|
||||
| Skeleton 骨架屏 | `t-skeleton` | 加载占位 |
|
||||
| Sticky 吸顶容器 | `t-sticky` | 滚动吸顶 |
|
||||
| Swiper 轮播图 | `t-swiper` / `t-swiper-nav` | 轮播展示 |
|
||||
| Tag 标签 | `t-tag` / `t-check-tag` | 标签展示/可选标签 |
|
||||
| Watermark 水印 | `t-watermark` | 页面水印 |
|
||||
|
||||
### 反馈(13)
|
||||
| 组件 | 标签 | 说明 |
|
||||
|------|------|------|
|
||||
| ActionSheet 动作面板 | `t-action-sheet` | 底部弹出操作列表 |
|
||||
| Dialog 对话框 | `t-dialog` | 模态对话框 |
|
||||
| DropdownMenu 下拉菜单 | `t-dropdown-menu` / `t-dropdown-item` | 下拉筛选 |
|
||||
| Guide 引导 | `t-guide` | 新手引导 |
|
||||
| Loading 加载 | `t-loading` | 加载中状态 |
|
||||
| Message 全局提示 | `t-message` | 顶部消息提示 |
|
||||
| NoticeBar 消息提醒 | `t-notice-bar` | 通知栏 |
|
||||
| Overlay 遮罩层 | `t-overlay` | 背景遮罩 |
|
||||
| Popover 弹出气泡 | `t-popover` | 气泡提示/菜单 |
|
||||
| Popup 弹出层 | `t-popup` | 通用弹出层 |
|
||||
| PullDownRefresh 下拉刷新 | `t-pull-down-refresh` | 下拉刷新 |
|
||||
| SwipeCell 滑动操作 | `t-swipe-cell` | 左右滑动操作 |
|
||||
| Toast 轻提示 | `t-toast` | 轻量提示 |
|
||||
|
||||
## 常用组件用法示例
|
||||
|
||||
### Button 按钮
|
||||
|
||||
```xml
|
||||
<!-- 主题 -->
|
||||
<t-button theme="primary">主按钮</t-button>
|
||||
<t-button theme="default">次按钮</t-button>
|
||||
<t-button theme="danger">危险按钮</t-button>
|
||||
<t-button theme="light">浅色按钮</t-button>
|
||||
|
||||
<!-- 变体 -->
|
||||
<t-button variant="base">填充</t-button>
|
||||
<t-button variant="outline">描边</t-button>
|
||||
<t-button variant="dashed">虚框</t-button>
|
||||
<t-button variant="text">文字</t-button>
|
||||
|
||||
<!-- 尺寸 -->
|
||||
<t-button size="large">大按钮</t-button>
|
||||
<t-button size="medium">中按钮</t-button>
|
||||
<t-button size="small">小按钮</t-button>
|
||||
<t-button size="extra-small">超小按钮</t-button>
|
||||
|
||||
<!-- 块级 -->
|
||||
<t-button block theme="primary">块级按钮</t-button>
|
||||
|
||||
<!-- 图标按钮 -->
|
||||
<t-button theme="primary" icon="app">带图标</t-button>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<t-button theme="primary" loading>加载中</t-button>
|
||||
|
||||
<!-- 禁用 -->
|
||||
<t-button theme="primary" disabled>禁用</t-button>
|
||||
```
|
||||
|
||||
### Input 输入框
|
||||
|
||||
```xml
|
||||
<t-input
|
||||
label="标签"
|
||||
placeholder="请输入"
|
||||
value="{{value}}"
|
||||
bind:change="onChange"
|
||||
maxlength="20"
|
||||
type="text"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 带前缀图标 -->
|
||||
<t-input
|
||||
prefixIcon="search"
|
||||
placeholder="搜索"
|
||||
bind:change="onSearch"
|
||||
/>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<t-input
|
||||
label="密码"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
/>
|
||||
```
|
||||
|
||||
### Cell 单元格
|
||||
|
||||
```xml
|
||||
<t-cell-group>
|
||||
<t-cell title="单行标题" arrow />
|
||||
<t-cell title="单行标题" note="辅助信息" arrow />
|
||||
<t-cell title="单行标题" description="描述信息" arrow />
|
||||
<t-cell title="单行标题" left-icon="user" arrow />
|
||||
</t-cell-group>
|
||||
```
|
||||
|
||||
### Dialog 对话框
|
||||
|
||||
```xml
|
||||
<t-dialog
|
||||
visible="{{showDialog}}"
|
||||
title="对话框标题"
|
||||
content="对话框内容"
|
||||
confirm-btn="确认"
|
||||
cancel-btn="取消"
|
||||
bind:confirm="onConfirm"
|
||||
bind:cancel="onCancel"
|
||||
/>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// 命令式调用
|
||||
const dialog = this.selectComponent('#t-dialog')
|
||||
dialog.open()
|
||||
// 或
|
||||
dialog.close()
|
||||
```
|
||||
|
||||
### Toast 轻提示
|
||||
|
||||
```xml
|
||||
<t-toast id="t-toast" />
|
||||
```
|
||||
|
||||
```javascript
|
||||
import Toast from 'tdesign-miniprogram/toast/index'
|
||||
|
||||
Toast({
|
||||
context: this,
|
||||
selector: '#t-toast',
|
||||
message: '提示信息',
|
||||
theme: 'success', // success / warning / error / loading
|
||||
duration: 2000
|
||||
})
|
||||
```
|
||||
|
||||
### Popup 弹出层
|
||||
|
||||
```xml
|
||||
<t-popup visible="{{visible}}" placement="bottom" bind:visible-change="onVisibleChange">
|
||||
<view class="popup-content">弹出内容</view>
|
||||
</t-popup>
|
||||
```
|
||||
|
||||
### Tabs 选项卡
|
||||
|
||||
```xml
|
||||
<t-tabs defaultValue="{{0}}" bind:change="onTabChange">
|
||||
<t-tab-panel label="标签1" value="0">内容1</t-tab-panel>
|
||||
<t-tab-panel label="标签2" value="1">内容2</t-tab-panel>
|
||||
<t-tab-panel label="标签3" value="2">内容3</t-tab-panel>
|
||||
</t-tabs>
|
||||
```
|
||||
|
||||
### Navbar 导航条
|
||||
|
||||
```xml
|
||||
<t-navbar
|
||||
title="页面标题"
|
||||
left-arrow
|
||||
bind:go-back="onGoBack"
|
||||
/>
|
||||
|
||||
<!-- 自定义导航栏(需在 page.json 设置 navigationStyle: custom) -->
|
||||
<t-navbar title="自定义" left-arrow fixed>
|
||||
<view slot="capsule">胶囊区域</view>
|
||||
</t-navbar>
|
||||
```
|
||||
|
||||
### TabBar 底部标签栏
|
||||
|
||||
```xml
|
||||
<t-tab-bar value="{{activeTab}}" bind:change="onTabBarChange">
|
||||
<t-tab-bar-item value="home" icon="home">首页</t-tab-bar-item>
|
||||
<t-tab-bar-item value="user" icon="user">我的</t-tab-bar-item>
|
||||
</t-tab-bar>
|
||||
```
|
||||
|
||||
### Search 搜索框
|
||||
|
||||
```xml
|
||||
<t-search
|
||||
placeholder="搜索"
|
||||
value="{{searchValue}}"
|
||||
bind:change="onSearchChange"
|
||||
bind:submit="onSearchSubmit"
|
||||
bind:clear="onSearchClear"
|
||||
/>
|
||||
```
|
||||
|
||||
### Empty 空状态
|
||||
|
||||
```xml
|
||||
<t-empty icon="folder-open" description="暂无数据" />
|
||||
```
|
||||
|
||||
### Loading 加载
|
||||
|
||||
```xml
|
||||
<t-loading theme="circular" size="40rpx" text="加载中..." />
|
||||
```
|
||||
|
||||
### Skeleton 骨架屏
|
||||
|
||||
```xml
|
||||
<t-skeleton loading="{{loading}}" row-col="{{rowCol}}">
|
||||
<view>实际内容</view>
|
||||
</t-skeleton>
|
||||
```
|
||||
|
||||
```javascript
|
||||
data: {
|
||||
loading: true,
|
||||
rowCol: [
|
||||
{ width: '100%', height: '340rpx' },
|
||||
[{ width: '45%' }, { width: '45%' }],
|
||||
{ width: '100%' },
|
||||
{ width: '60%' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 样式覆盖(4 种方式)
|
||||
|
||||
### 1. style / custom-style 属性
|
||||
```xml
|
||||
<t-button style="color: red">按钮</t-button>
|
||||
<t-button custom-style="color: red">按钮</t-button>
|
||||
```
|
||||
开启 virtualHost 时两者效果一致;未开启时只能用 `custom-style`。
|
||||
|
||||
### 2. 解除样式隔离
|
||||
TDesign 全体组件开启了 `addGlobalClass`,页面样式可直接覆盖:
|
||||
```css
|
||||
/* 页面 wxss */
|
||||
.t-button--primary {
|
||||
background-color: navy;
|
||||
}
|
||||
```
|
||||
在自定义组件中使用需开启 `styleIsolation: 'shared'`。
|
||||
|
||||
### 3. 外部样式类
|
||||
```xml
|
||||
<t-button t-class="my-btn-class">按钮</t-button>
|
||||
```
|
||||
```css
|
||||
.my-btn-class {
|
||||
color: red !important;
|
||||
}
|
||||
```
|
||||
每个组件支持的外部样式类见组件文档(如 `t-class`、`t-class-icon`、`t-class-content` 等)。
|
||||
|
||||
### 4. CSS 变量
|
||||
```css
|
||||
page {
|
||||
--td-brand-color: navy; /* 主题色 */
|
||||
--td-success-color: #00a870; /* 成功色 */
|
||||
--td-warning-color: #ed7b2f; /* 警告色 */
|
||||
--td-error-color: #e34d59; /* 错误色 */
|
||||
}
|
||||
```
|
||||
每个组件都有独立的 CSS 变量,见组件文档的 CSS Variables 部分。
|
||||
|
||||
## 自定义主题
|
||||
|
||||
全局 Design Token 变量定义:[_variables.less](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/style/_variables.less)
|
||||
|
||||
```css
|
||||
/* app.wxss — 全局主题定制 */
|
||||
page {
|
||||
--td-brand-color: #0052d9;
|
||||
--td-brand-color-light: #d9e1ff;
|
||||
--td-success-color: #00a870;
|
||||
--td-warning-color: #ed7b2f;
|
||||
--td-error-color: #e34d59;
|
||||
|
||||
/* 文字颜色 */
|
||||
--td-text-color-primary: rgba(0, 0, 0, 0.9);
|
||||
--td-text-color-secondary: rgba(0, 0, 0, 0.6);
|
||||
--td-text-color-placeholder: rgba(0, 0, 0, 0.26);
|
||||
--td-text-color-disabled: rgba(0, 0, 0, 0.26);
|
||||
|
||||
/* 背景颜色 */
|
||||
--td-bg-color-container: #fff;
|
||||
--td-bg-color-page: #f3f3f3;
|
||||
|
||||
/* 圆角 */
|
||||
--td-radius-default: 12rpx;
|
||||
--td-radius-large: 18rpx;
|
||||
--td-radius-round: 999px;
|
||||
|
||||
/* 字体 */
|
||||
--td-font-size-s: 24rpx;
|
||||
--td-font-size-base: 28rpx;
|
||||
--td-font-size-m: 32rpx;
|
||||
--td-font-size-l: 36rpx;
|
||||
}
|
||||
```
|
||||
|
||||
## 深色模式
|
||||
|
||||
TDesign 1.3.0+ 支持深色模式。
|
||||
|
||||
### 开启步骤
|
||||
|
||||
1. `app.json` 添加 `"darkmode": true`
|
||||
2. `app.wxss` 引入主题变量:
|
||||
```css
|
||||
@import 'miniprogram_npm/tdesign-miniprogram/common/style/theme/_index.wxss';
|
||||
```
|
||||
3. 页面样式使用 CSS Variable:
|
||||
```css
|
||||
.text {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
```
|
||||
|
||||
### 特殊组件适配
|
||||
|
||||
自定义 TabBar 和 root-portal 内的组件需手动添加 `.page` 类名:
|
||||
```xml
|
||||
<view class="page">
|
||||
<t-tab-bar />
|
||||
</view>
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需查看某个具体组件的完整 API(Props / Events / Slots / CSS Variables),可抓取:
|
||||
- 组件总览:https://tdesign.tencent.com/miniprogram/overview
|
||||
- 具体组件:`https://tdesign.tencent.com/miniprogram/components/{组件名}`
|
||||
例如:https://tdesign.tencent.com/miniprogram/components/button
|
||||
- 快速开始:https://tdesign.tencent.com/miniprogram/getting-started
|
||||
- 样式覆盖:https://tdesign.tencent.com/miniprogram/custom-style
|
||||
- 自定义主题:https://tdesign.tencent.com/miniprogram/custom-theme
|
||||
- 深色模式:https://tdesign.tencent.com/miniprogram/dark-mode
|
||||
- CSS 变量定义:https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/style/_variables.less
|
||||
308
_DEL/wechat-miniprogram/steering/view-layer.md
Normal file
308
_DEL/wechat-miniprogram/steering/view-layer.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# 视图层(WXML / WXSS / WXS)
|
||||
|
||||
> 官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/view/
|
||||
|
||||
## WXML — 模板语法
|
||||
|
||||
### 数据绑定
|
||||
|
||||
```xml
|
||||
<!-- 简单绑定 -->
|
||||
<view>{{ message }}</view>
|
||||
|
||||
<!-- 组件属性绑定(需在双引号内) -->
|
||||
<view id="item-{{id}}"></view>
|
||||
|
||||
<!-- 控制属性绑定 -->
|
||||
<view wx:if="{{condition}}"></view>
|
||||
|
||||
<!-- 关键字绑定(注意 true/false 需要在 {{}} 内) -->
|
||||
<checkbox checked="{{false}}"></checkbox>
|
||||
<!-- ⚠️ 错误写法:checked="false" 会被当作字符串 "false",结果为 true -->
|
||||
|
||||
<!-- 运算 -->
|
||||
<view>{{ a + b }} + {{ c }} + d</view>
|
||||
<view>{{"hello " + name}}</view>
|
||||
<view>{{object.key}} {{array[0]}}</view>
|
||||
|
||||
<!-- 三元运算 -->
|
||||
<view hidden="{{flag ? true : false}}">Hidden</view>
|
||||
|
||||
<!-- 组合(数组) -->
|
||||
<view wx:for="{{[zero, 1, 2, 3, 4]}}">{{item}}</view>
|
||||
|
||||
<!-- 组合(对象) -->
|
||||
<template is="objectCombine" data="{{for: a, bar: b}}"></template>
|
||||
<!-- 展开运算符 -->
|
||||
<template is="objectCombine" data="{{...obj1, ...obj2, e: 5}}"></template>
|
||||
```
|
||||
|
||||
### 列表渲染 wx:for
|
||||
|
||||
```xml
|
||||
<!-- 基本用法:默认 item 和 index -->
|
||||
<view wx:for="{{array}}">
|
||||
{{index}}: {{item.message}}
|
||||
</view>
|
||||
|
||||
<!-- 自定义变量名 -->
|
||||
<view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName">
|
||||
{{idx}}: {{itemName.message}}
|
||||
</view>
|
||||
|
||||
<!-- 嵌套 -->
|
||||
<view wx:for="{{[1, 2, 3, 4, 5, 6, 7, 8, 9]}}" wx:for-item="i">
|
||||
<view wx:for="{{[1, 2, 3, 4, 5, 6, 7, 8, 9]}}" wx:for-item="j">
|
||||
<view wx:if="{{i <= j}}">
|
||||
{{i}} * {{j}} = {{i * j}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ⚠️ wx:key 非常重要,用于列表项的唯一标识 -->
|
||||
<!-- 值为字符串:item 的某个 property 名(该 property 值需唯一) -->
|
||||
<switch wx:for="{{objectArray}}" wx:key="unique">{{item.id}}</switch>
|
||||
|
||||
<!-- 值为 *this:item 本身是唯一字符串或数字 -->
|
||||
<switch wx:for="{{numberArray}}" wx:key="*this">{{item}}</switch>
|
||||
```
|
||||
|
||||
**wx:key 的作用**:当数据改变触发重新渲染时,带有 key 的组件会被重新排序而非重新创建,保持自身状态(如 `<input>` 的输入内容、`<switch>` 的选中状态)。
|
||||
|
||||
### 条件渲染
|
||||
|
||||
```xml
|
||||
<!-- wx:if / wx:elif / wx:else -->
|
||||
<view wx:if="{{length > 5}}">1</view>
|
||||
<view wx:elif="{{length > 2}}">2</view>
|
||||
<view wx:else>3</view>
|
||||
|
||||
<!-- block wx:if(不会渲染为真实 DOM) -->
|
||||
<block wx:if="{{true}}">
|
||||
<view>view1</view>
|
||||
<view>view2</view>
|
||||
</block>
|
||||
```
|
||||
|
||||
**wx:if vs hidden**:
|
||||
- `wx:if` 是惰性的,条件为 false 时不渲染,切换时销毁/重建
|
||||
- `hidden` 始终渲染,只是切换显示/隐藏(类似 CSS display:none)
|
||||
- 频繁切换用 `hidden`,运行时条件不大可能改变用 `wx:if`
|
||||
|
||||
### 模板 template
|
||||
|
||||
```xml
|
||||
<!-- 定义模板 -->
|
||||
<template name="msgItem">
|
||||
<view>
|
||||
<text>{{index}}: {{msg}}</text>
|
||||
<text>Time: {{time}}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 使用模板 -->
|
||||
<template is="msgItem" data="{{...item}}"/>
|
||||
|
||||
<!-- 动态模板名 -->
|
||||
<template is="{{item % 2 == 0 ? 'even' : 'odd'}}"/>
|
||||
```
|
||||
|
||||
### 引用
|
||||
|
||||
```xml
|
||||
<!-- import:引入目标文件中定义的 template -->
|
||||
<import src="item.wxml"/>
|
||||
<template is="item" data="{{text: 'forbar'}}"/>
|
||||
<!-- ⚠️ import 有作用域:只会引入目标文件中定义的 template,不会引入目标文件 import 的 template(不递归) -->
|
||||
|
||||
<!-- include:将目标文件除 <template/> <wxs/> 外的整个代码引入 -->
|
||||
<include src="header.wxml"/>
|
||||
<view>body</view>
|
||||
<include src="footer.wxml"/>
|
||||
```
|
||||
|
||||
## WXSS — 样式
|
||||
|
||||
### rpx 单位
|
||||
|
||||
rpx(responsive pixel):可以根据屏幕宽度进行自适应。规定屏幕宽为 750rpx。
|
||||
|
||||
| 设备 | rpx 换算 px | px 换算 rpx |
|
||||
|------|------------|------------|
|
||||
| iPhone5 | 1rpx = 0.42px | 1px = 2.34rpx |
|
||||
| iPhone6 | 1rpx = 0.5px | 1px = 2rpx |
|
||||
| iPhone6 Plus | 1rpx = 0.552px | 1px = 1.81rpx |
|
||||
|
||||
**建议**:开发时以 iPhone6 为视觉稿标准(750px 宽),1px = 1rpx。
|
||||
|
||||
### 样式导入
|
||||
|
||||
```css
|
||||
/* common.wxss */
|
||||
.small-p { padding: 5px; }
|
||||
|
||||
/* app.wxss */
|
||||
@import "common.wxss";
|
||||
.middle-p { padding: 15px; }
|
||||
```
|
||||
|
||||
### 选择器支持
|
||||
|
||||
| 选择器 | 示例 | 说明 |
|
||||
|--------|------|------|
|
||||
| .class | `.intro` | 类选择器 |
|
||||
| #id | `#firstname` | ID 选择器 |
|
||||
| element | `view` | 元素选择器 |
|
||||
| element, element | `view, checkbox` | 群组选择器 |
|
||||
| ::after | `view::after` | 伪元素 |
|
||||
| ::before | `view::before` | 伪元素 |
|
||||
|
||||
### 内联样式
|
||||
|
||||
```xml
|
||||
<!-- style:动态样式,运行时解析,尽量避免静态样式写在 style 中 -->
|
||||
<view style="color:{{color}};"/>
|
||||
|
||||
<!-- class:静态样式写在 class 中 -->
|
||||
<view class="normal_view"/>
|
||||
```
|
||||
|
||||
### 全局样式与局部样式
|
||||
- `app.wxss` 为全局样式,作用于每个页面
|
||||
- 页面的 `.wxss` 只对当前页面生效,会覆盖 `app.wxss` 中相同的选择器
|
||||
|
||||
## WXS(WeiXin Script)
|
||||
|
||||
WXS 是小程序的一套脚本语言,可以在 WXML 中使用。**WXS 运行在视图层**,比 JS 逻辑层快(不需要跨线程通信)。
|
||||
|
||||
```xml
|
||||
<!-- 内联 WXS -->
|
||||
<wxs module="m1">
|
||||
var msg = "hello world";
|
||||
module.exports.message = msg;
|
||||
</wxs>
|
||||
<view>{{m1.message}}</view>
|
||||
|
||||
<!-- 外部 WXS 文件 -->
|
||||
<wxs src="./tools.wxs" module="tools"/>
|
||||
<view>{{tools.msg}}</view>
|
||||
<view>{{tools.bar(tools.FOO)}}</view>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tools.wxs
|
||||
var foo = "'hello world' from tools.wxs"
|
||||
var bar = function(d) {
|
||||
return d
|
||||
}
|
||||
module.exports = {
|
||||
FOO: foo,
|
||||
bar: bar,
|
||||
}
|
||||
// ⚠️ WXS 不支持 ES6 语法(箭头函数、let/const、解构等)
|
||||
// ⚠️ WXS 中不能调用小程序 API(wx.xxx)
|
||||
```
|
||||
|
||||
**WXS 典型用途**:
|
||||
- 格式化数据(日期、金额、文本截断)
|
||||
- 在视图层做简单计算,避免 setData 开销
|
||||
- 响应事件(WXS 事件响应,iOS 上性能更好)
|
||||
|
||||
## 事件系统
|
||||
|
||||
### 事件分类
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| 冒泡事件 | 向父节点传递 | tap, longpress, touchstart, touchmove, touchend, touchcancel |
|
||||
| 非冒泡事件 | 不向父节点传递 | submit, input, scroll 等组件特有事件 |
|
||||
|
||||
### 事件绑定
|
||||
|
||||
```xml
|
||||
<!-- bind:不阻止冒泡 -->
|
||||
<view bindtap="handleTap">Click me</view>
|
||||
<view bind:tap="handleTap">Click me</view>
|
||||
|
||||
<!-- catch:阻止冒泡 -->
|
||||
<view catchtap="handleTap">Click me</view>
|
||||
|
||||
<!-- mut-bind:互斥事件绑定(基础库 2.8.2+) -->
|
||||
<view mut-bind:tap="handleTap">
|
||||
<button mut-bind:tap="handleButtonTap">按钮</button>
|
||||
</view>
|
||||
<!-- 同一冒泡路径上的 mut-bind 只会有一个被触发 -->
|
||||
|
||||
<!-- capture-bind:捕获阶段绑定 -->
|
||||
<view capture-bind:tap="handleCapture">
|
||||
<view bindtap="handleTap">inner</view>
|
||||
</view>
|
||||
|
||||
<!-- capture-catch:捕获阶段中断 -->
|
||||
<view capture-catch:tap="handleCapture">
|
||||
<view bindtap="handleTap">inner(不会触发)</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
### 事件对象
|
||||
|
||||
```javascript
|
||||
Page({
|
||||
handleTap(e) {
|
||||
e.type // 事件类型,如 "tap"
|
||||
e.timeStamp // 事件生成时的时间戳
|
||||
e.target // 触发事件的源组件(可能是子组件)
|
||||
e.currentTarget // 事件绑定的当前组件
|
||||
e.detail // 额外信息,如 tap 的 { x, y }
|
||||
e.touches // 触摸事件的触摸点信息数组
|
||||
e.changedTouches // 变化的触摸点信息数组
|
||||
e.mark // 事件标记(基础库 2.7.1+)
|
||||
|
||||
// target 和 currentTarget 的区别
|
||||
e.target.id // 触发事件的组件 id
|
||||
e.target.dataset // 触发事件的组件的 data-xxx 属性集合
|
||||
e.currentTarget.id // 绑定事件的组件 id
|
||||
e.currentTarget.dataset
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### dataset
|
||||
|
||||
```xml
|
||||
<!-- data-xxx 属性传递数据 -->
|
||||
<view data-alpha-beta="1" data-alphaBeta="2" bindtap="handleTap">
|
||||
Click
|
||||
</view>
|
||||
```
|
||||
|
||||
```javascript
|
||||
handleTap(e) {
|
||||
e.currentTarget.dataset.alphaBeta // "1"(连字符转驼峰)
|
||||
e.currentTarget.dataset.alphabeta // "2"(大写转小写)
|
||||
}
|
||||
```
|
||||
|
||||
### mark(基础库 2.7.1+)
|
||||
|
||||
```xml
|
||||
<!-- mark 可以在冒泡路径上所有节点收集 -->
|
||||
<view mark:myMark="last" bindtap="bindViewTap">
|
||||
<button mark:anotherMark="leaf" bindtap="bindButtonTap">按钮</button>
|
||||
</view>
|
||||
```
|
||||
|
||||
```javascript
|
||||
bindButtonTap(e) {
|
||||
e.mark // { myMark: "last", anotherMark: "leaf" }
|
||||
// mark 会合并冒泡路径上所有的 mark
|
||||
}
|
||||
```
|
||||
|
||||
## 在线查询
|
||||
|
||||
如需更详细信息,可抓取:
|
||||
- WXML 语法:https://developers.weixin.qq.com/miniprogram/dev/reference/wxml/
|
||||
- WXSS:https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxss.html
|
||||
- WXS:https://developers.weixin.qq.com/miniprogram/dev/reference/wxs/
|
||||
- 事件:https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html
|
||||
116
_DEL/weixin-devtools/POWER.md
Normal file
116
_DEL/weixin-devtools/POWER.md
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: "weixin-devtools"
|
||||
displayName: "微信开发者工具自动化"
|
||||
description: "通过 weixin-devtools-mcp 控制微信开发者工具,支持页面导航、元素快照、截图、表单操作、JS 执行、网络监控、Console 日志等小程序自动化操作"
|
||||
keywords: ["微信开发者工具", "小程序调试", "devtools", "wechat", "miniprogram", "截图", "screenshot", "页面快照", "snapshot", "元素点击", "click", "表单", "form", "navigate", "console", "网络请求", "network", "evaluate", "自动化测试", "UI验证"]
|
||||
author: "NeoZQYY"
|
||||
---
|
||||
|
||||
# 微信开发者工具自动化 Power
|
||||
|
||||
通过 weixin-devtools-mcp 控制微信开发者工具,用于小程序 UI 调试、页面验证、自动化测试。
|
||||
|
||||
## 可用 Server
|
||||
|
||||
### weixin-devtools
|
||||
连接本地微信开发者工具实例,提供完整的小程序自动化能力。
|
||||
|
||||
前置条件:
|
||||
- 微信开发者工具已打开并加载项目
|
||||
- 自动化端口 9420 已启用(设置 → 安全 → 服务端口)
|
||||
|
||||
## 工具分类速查
|
||||
|
||||
### 连接管理
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `connect_devtools` | 连接开发者工具(支持 auto/launch/connect 策略) |
|
||||
| `disconnect_devtools` | 断开连接并清理状态 |
|
||||
| `reconnect_devtools` | 重连(可复用上次参数) |
|
||||
| `get_connection_status` | 查看连接状态 |
|
||||
| `check_environment` | 检查自动化环境配置 |
|
||||
| `diagnose_connection` | 诊断连接问题 |
|
||||
| `debug_connection_flow` | 调试连接流程 |
|
||||
|
||||
### 页面导航
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `navigate_to` | 跳转到指定页面(支持参数) |
|
||||
| `navigate_back` | 返回上一页 |
|
||||
| `switch_tab` | 切换 TabBar 页面 |
|
||||
| `relaunch` | 重启小程序并跳转 |
|
||||
| `get_current_page` | 获取当前页面信息 |
|
||||
|
||||
### 元素操作
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `get_page_snapshot` | 获取页面元素快照(含 uid) |
|
||||
| `$` | CSS 选择器查找元素 |
|
||||
| `click` | 点击元素(支持双击) |
|
||||
| `input_text` | 输入文本 |
|
||||
| `set_form_control` | 设置表单控件值(picker/switch/slider) |
|
||||
| `get_value` | 获取元素值或文本 |
|
||||
| `debug_page_elements` | 调试元素获取问题 |
|
||||
|
||||
### 断言验证
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `assert_text` | 断言元素文本(精确/包含/正则) |
|
||||
| `assert_state` | 断言元素状态(可见/启用/选中/聚焦) |
|
||||
| `assert_attribute` | 断言元素属性值 |
|
||||
|
||||
### JS 执行
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `evaluate_script` | 在 AppService 上下文执行 JS |
|
||||
|
||||
### 监控与调试
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `screenshot` | 页面截图(base64 或保存文件) |
|
||||
| `list_console_messages` | 列表查询 console 消息 |
|
||||
| `get_console_message` | 获取单条 console 详情 |
|
||||
| `get_network_requests` | 获取网络请求记录 |
|
||||
| `clear_network_requests` | 清空网络请求记录 |
|
||||
| `stop_network_monitoring` | 停止网络监听 |
|
||||
|
||||
### 等待
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `waitFor` | 等待条件满足(元素出现/消失/文本匹配/延时) |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 1. 连接 → 导航 → 验证
|
||||
```
|
||||
connect_devtools → navigate_to → get_page_snapshot → assert_text
|
||||
```
|
||||
|
||||
### 2. 表单填写 → 提交 → 检查结果
|
||||
```
|
||||
navigate_to → get_page_snapshot → input_text → click → waitFor → assert_text
|
||||
```
|
||||
|
||||
### 3. 截图对比(配合 pixel-audit)
|
||||
```
|
||||
navigate_to → screenshot → 用 image-compare 对比 H5 截图
|
||||
```
|
||||
|
||||
### 4. 数据验证
|
||||
```
|
||||
evaluate_script(读取页面 data)→ 对比预期值
|
||||
```
|
||||
|
||||
## Steering
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `workflow.md` | 连接策略、页面导航模式、元素操作最佳实践、常见问题排查 |
|
||||
|
||||
加载:激活 Power → `readSteering("workflow.md")`
|
||||
|
||||
## 注意事项
|
||||
- 操作元素前必须先 `get_page_snapshot` 获取 uid
|
||||
- `navigate_to` 不能跳转 TabBar 页面,用 `switch_tab`
|
||||
- `evaluate_script` 在 AppService 上下文执行,可访问 `wx`、`getApp()`、`getCurrentPages()`
|
||||
- 截图默认返回 base64,传 `path` 参数可保存到文件
|
||||
18
_DEL/weixin-devtools/mcp.json
Normal file
18
_DEL/weixin-devtools/mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"weixin-devtools": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"weixin-devtools-mcp",
|
||||
"--tools-profile=full",
|
||||
"--ws-endpoint=ws://127.0.0.1:9420"
|
||||
],
|
||||
"env": {
|
||||
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
|
||||
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
|
||||
},
|
||||
"autoApprove": ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
179
_DEL/weixin-devtools/steering/workflow.md
Normal file
179
_DEL/weixin-devtools/steering/workflow.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 微信开发者工具自动化工作流
|
||||
|
||||
## 连接策略
|
||||
|
||||
### 首次连接
|
||||
```
|
||||
connect_devtools(strategy: "auto")
|
||||
```
|
||||
auto 策略会依次尝试:发现已运行实例 → 通过 CLI 启动 → ws 直连。
|
||||
|
||||
### 连接失败排查
|
||||
1. `check_environment` — 检查 CLI 路径、项目路径是否正确
|
||||
2. `diagnose_connection` — 检查端口、进程状态
|
||||
3. 确认开发者工具「设置 → 安全 → 服务端口」已开启
|
||||
|
||||
### 重连
|
||||
```
|
||||
reconnect_devtools // 复用上次参数
|
||||
```
|
||||
|
||||
## 页面导航模式
|
||||
|
||||
### 普通页面跳转
|
||||
```
|
||||
navigate_to(url: "/pages/task/task-list/index", params: { status: "pending" })
|
||||
```
|
||||
|
||||
### TabBar 页面
|
||||
```
|
||||
switch_tab(url: "/pages/index/index")
|
||||
```
|
||||
注意:`navigate_to` 无法跳转 TabBar 页面,必须用 `switch_tab`。
|
||||
|
||||
### 重启并跳转
|
||||
```
|
||||
relaunch(url: "/pages/index/index")
|
||||
```
|
||||
清空页面栈,适合需要干净状态的场景。
|
||||
|
||||
### 返回
|
||||
```
|
||||
navigate_back(delta: 1) // 返回上一页
|
||||
navigate_back(delta: 2) // 返回两层
|
||||
```
|
||||
|
||||
## 元素操作最佳实践
|
||||
|
||||
### 操作流程(必须遵循)
|
||||
1. `get_page_snapshot` 获取当前页面所有元素的 uid
|
||||
2. 从快照中找到目标元素的 uid
|
||||
3. 用 uid 执行操作(click / input_text / get_value / assert_*)
|
||||
|
||||
### 快照格式选择
|
||||
- `compact`(默认推荐):含 uid、标签、文本、位置、尺寸,token 用量减少 60-70%
|
||||
- `minimal`:只含 uid、标签、文本,最省 token
|
||||
- `json`:完整 JSON,需要属性详情时用
|
||||
|
||||
### CSS 选择器查找
|
||||
```
|
||||
$("view.container") // 类名
|
||||
$("#myId") // ID
|
||||
$("text=按钮") // 文本匹配
|
||||
```
|
||||
返回匹配元素的详细信息,适合精确定位。
|
||||
|
||||
### 输入文本
|
||||
```
|
||||
input_text(uid: "xxx", text: "测试内容")
|
||||
input_text(uid: "xxx", text: "追加内容", append: true)
|
||||
input_text(uid: "xxx", text: "新内容", clear: true) // 先清空再输入
|
||||
```
|
||||
|
||||
### 表单控件
|
||||
```
|
||||
set_form_control(uid: "xxx", value: 2) // picker 选第3项
|
||||
set_form_control(uid: "xxx", value: true) // switch 开启
|
||||
set_form_control(uid: "xxx", value: 50) // slider 设为50
|
||||
```
|
||||
|
||||
## 断言验证
|
||||
|
||||
### 文本断言
|
||||
```
|
||||
assert_text(uid: "xxx", text: "精确匹配")
|
||||
assert_text(uid: "xxx", textContains: "包含")
|
||||
assert_text(uid: "xxx", textMatches: "\\d{4}-\\d{2}-\\d{2}") // 正则
|
||||
```
|
||||
|
||||
### 状态断言
|
||||
```
|
||||
assert_state(uid: "xxx", visible: true)
|
||||
assert_state(uid: "xxx", enabled: false)
|
||||
assert_state(uid: "xxx", checked: true)
|
||||
```
|
||||
|
||||
### 属性断言
|
||||
```
|
||||
assert_attribute(uid: "xxx", attributeKey: "class", attributeValue: "active")
|
||||
```
|
||||
|
||||
## JS 执行
|
||||
|
||||
### 读取页面数据
|
||||
```javascript
|
||||
evaluate_script({
|
||||
function: "() => { const pages = getCurrentPages(); return pages[pages.length-1].data; }"
|
||||
})
|
||||
```
|
||||
|
||||
### 读取全局数据
|
||||
```javascript
|
||||
evaluate_script({
|
||||
function: "() => { return getApp().globalData; }"
|
||||
})
|
||||
```
|
||||
|
||||
### 调用 wx API
|
||||
```javascript
|
||||
evaluate_script({
|
||||
function: "() => { return wx.getSystemInfoSync(); }"
|
||||
})
|
||||
```
|
||||
|
||||
### 带参数执行
|
||||
```javascript
|
||||
evaluate_script({
|
||||
function: "(key) => { return wx.getStorageSync(key); }",
|
||||
args: ["userToken"]
|
||||
})
|
||||
```
|
||||
|
||||
## 截图与视觉验证
|
||||
|
||||
### 保存截图到文件
|
||||
```
|
||||
screenshot(path: "C:/NeoZQYY/export/screenshots/task-list.png")
|
||||
```
|
||||
|
||||
### 配合 pixel-audit Power 做视觉对比
|
||||
1. H5 页面用 Playwright 截图
|
||||
2. MP 页面用 `screenshot` 截图
|
||||
3. 用 `image-compare`(已集成在 pixel-audit)对比差异
|
||||
|
||||
## 网络请求监控
|
||||
|
||||
### 查看请求
|
||||
```
|
||||
get_network_requests(urlPattern: "api/v1/tasks", limit: 10)
|
||||
get_network_requests(type: "request", successOnly: true)
|
||||
```
|
||||
|
||||
### 清空并重新监控
|
||||
```
|
||||
clear_network_requests → 执行操作 → get_network_requests
|
||||
```
|
||||
|
||||
## 等待策略
|
||||
|
||||
### 等待元素出现
|
||||
```
|
||||
waitFor(selector: ".loading", disappear: true, timeout: 10000) // 等 loading 消失
|
||||
waitFor(selector: ".task-card", timeout: 5000) // 等元素出现
|
||||
waitFor(text: "加载完成", timeout: 5000) // 等文本出现
|
||||
```
|
||||
|
||||
### 固定延时
|
||||
```
|
||||
waitFor(delay: 2000) // 等待 2 秒
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| 连接超时 | 检查 9420 端口是否开启,`diagnose_connection` |
|
||||
| 元素找不到 | 先 `get_page_snapshot` 确认页面已加载完成 |
|
||||
| navigate_to 失败 | TabBar 页面必须用 `switch_tab` |
|
||||
| evaluate_script 报错 | 函数不能引用外部闭包变量,必须自包含 |
|
||||
| 截图空白 | 页面可能还在加载,先 `waitFor` |
|
||||
1028
_DEL/微信小程序迁移手册_PRD_技术规范.md
Normal file
1028
_DEL/微信小程序迁移手册_PRD_技术规范.md
Normal file
File diff suppressed because it is too large
Load Diff
222
apps/backend/app/ai/apps/app1_chat.py
Normal file
222
apps/backend/app/ai/apps/app1_chat.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""应用 1:通用对话(SSE 流式)。
|
||||
|
||||
每次进入 chat 页面新建 ai_conversations 记录(不复用),
|
||||
首条消息注入页面上下文,流式返回 AI 回复。
|
||||
|
||||
app_id = "app1_chat"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import SSEEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app1_chat"
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
page_context: dict | None = None,
|
||||
screen_content: str | None = None,
|
||||
bailian: BailianClient,
|
||||
conv_svc: ConversationService,
|
||||
) -> AsyncGenerator[SSEEvent, None]:
|
||||
"""流式对话入口,返回 SSEEvent 异步生成器。
|
||||
|
||||
流程:
|
||||
1. 创建 conversation 记录
|
||||
2. 写入 user message
|
||||
3. 构建 system prompt(注入页面上下文)
|
||||
4. 调用 bailian.chat_stream 流式获取回复
|
||||
5. 逐 chunk yield SSEEvent(type="chunk")
|
||||
6. 完成后写入 assistant message,yield SSEEvent(type="done")
|
||||
7. 异常时 yield SSEEvent(type="error")
|
||||
"""
|
||||
conversation_id: int | None = None
|
||||
|
||||
try:
|
||||
# 1. 每次新建 conversation(不复用)
|
||||
source_ctx = _build_source_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
source_context=source_ctx,
|
||||
)
|
||||
logger.info(
|
||||
"App1 新建对话: conversation_id=%s user_id=%s site_id=%s",
|
||||
conversation_id, user_id, site_id,
|
||||
)
|
||||
|
||||
# 2. 立即写入 user message
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=message,
|
||||
)
|
||||
|
||||
# 3. 构建消息列表(system prompt + user message)
|
||||
messages = _build_messages(
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
|
||||
# 4-5. 流式调用百炼,逐 chunk yield
|
||||
full_reply_parts: list[str] = []
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
full_reply_parts.append(chunk)
|
||||
yield SSEEvent(type="chunk", content=chunk)
|
||||
|
||||
# 6. 流式完成,拼接完整回复并写入 assistant message
|
||||
full_reply = "".join(full_reply_parts)
|
||||
# 百炼流式模式不返回 tokens_used,按字符数估算(粗略)
|
||||
estimated_tokens = len(full_reply)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=full_reply,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
yield SSEEvent(
|
||||
type="done",
|
||||
conversation_id=conversation_id,
|
||||
tokens_used=estimated_tokens,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"App1 对话异常: conversation_id=%s error=%s",
|
||||
conversation_id, e,
|
||||
exc_info=True,
|
||||
)
|
||||
yield SSEEvent(type="error", message=str(e))
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> list[dict]:
|
||||
"""构建发送给百炼的消息列表。
|
||||
|
||||
首条 system 消息注入页面上下文和用户信息。
|
||||
"""
|
||||
system_content = _build_system_prompt(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_prompt(
|
||||
*,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON。
|
||||
|
||||
通过 biz_params.user_prompt_params 传入用户信息,
|
||||
注入页面上下文供 AI 理解当前场景。
|
||||
"""
|
||||
prompt: dict = {
|
||||
"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。",
|
||||
"biz_params": {
|
||||
"user_prompt_params": {
|
||||
"User_ID": str(user_id),
|
||||
"Role": role,
|
||||
"Nickname": nickname,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 注入页面上下文(首条消息)
|
||||
page_ctx = _build_page_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
if page_ctx:
|
||||
prompt["page_context"] = page_ctx
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def _build_page_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict:
|
||||
"""构建页面上下文信息。
|
||||
|
||||
P5-A 阶段:直接透传前端传入的上下文字段。
|
||||
P5-B 阶段:各页面逐步实现文本化工具,丰富 screen_content。
|
||||
"""
|
||||
# TODO: P5-B 各页面文本化工具细化
|
||||
ctx: dict = {}
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
return ctx
|
||||
|
||||
|
||||
def _build_source_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
) -> dict | None:
|
||||
"""构建存入 ai_conversations.source_context 的 JSON。"""
|
||||
ctx: dict = {}
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
return ctx if ctx else None
|
||||
210
apps/backend/app/ai/apps/app2_finance.py
Normal file
210
apps/backend/app/ai/apps/app2_finance.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""应用 2:财务洞察。
|
||||
|
||||
8 个时间维度独立调用,每次调用结果写入 ai_cache,
|
||||
同时创建 ai_conversations + ai_messages 记录。
|
||||
|
||||
营业日分界点:每日 08:00(BUSINESS_DAY_START_HOUR 环境变量,默认 8)。
|
||||
|
||||
app_id = "app2_finance"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app2_finance_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app2_finance"
|
||||
|
||||
# 8 个时间维度编码
|
||||
TIME_DIMENSIONS = (
|
||||
"this_month",
|
||||
"last_month",
|
||||
"this_week",
|
||||
"last_week",
|
||||
"last_3_months",
|
||||
"this_quarter",
|
||||
"last_quarter",
|
||||
"last_6_months",
|
||||
)
|
||||
|
||||
|
||||
def get_business_date() -> date:
|
||||
"""根据营业日分界点计算当前营业日。
|
||||
|
||||
分界点前(如 07:59)视为前一天营业日,
|
||||
分界点及之后(如 08:00)视为当天营业日。
|
||||
"""
|
||||
hour = int(os.environ.get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
now = datetime.now()
|
||||
if now.hour < hour:
|
||||
return (now - timedelta(days=1)).date()
|
||||
return now.date()
|
||||
|
||||
|
||||
def compute_time_range(dimension: str, business_date: date) -> tuple[date, date]:
|
||||
"""计算时间维度对应的日期范围 [start, end](闭区间)。
|
||||
|
||||
Args:
|
||||
dimension: 时间维度编码
|
||||
business_date: 当前营业日
|
||||
|
||||
Returns:
|
||||
(start_date, end_date) 元组
|
||||
"""
|
||||
y, m, d = business_date.year, business_date.month, business_date.day
|
||||
|
||||
if dimension == "this_month":
|
||||
start = date(y, m, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_month":
|
||||
prev = _month_offset(y, m, -1)
|
||||
start = date(prev[0], prev[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_week":
|
||||
# 周一起算
|
||||
weekday = business_date.weekday() # 0=周一
|
||||
start = business_date - timedelta(days=weekday)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_week":
|
||||
weekday = business_date.weekday()
|
||||
this_monday = business_date - timedelta(days=weekday)
|
||||
last_monday = this_monday - timedelta(days=7)
|
||||
last_sunday = this_monday - timedelta(days=1)
|
||||
return last_monday, last_sunday
|
||||
|
||||
if dimension == "last_3_months":
|
||||
# 当前月 - 3 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -3)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
# end = 上月最后一天
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if dimension == "this_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
start = date(y, q_start_month, 1)
|
||||
return start, business_date
|
||||
|
||||
if dimension == "last_quarter":
|
||||
q_start_month = ((m - 1) // 3) * 3 + 1
|
||||
# 上季度结束 = 本季度第一天 - 1
|
||||
this_q_start = date(y, q_start_month, 1)
|
||||
end = this_q_start - timedelta(days=1)
|
||||
# 上季度开始
|
||||
ly, lm = end.year, end.month
|
||||
lq_start_month = ((lm - 1) // 3) * 3 + 1
|
||||
start = date(ly, lq_start_month, 1)
|
||||
return start, end
|
||||
|
||||
if dimension == "last_6_months":
|
||||
# 当前月 - 6 ~ 当前月 - 1
|
||||
end_ym = _month_offset(y, m, -1)
|
||||
start_ym = _month_offset(y, m, -6)
|
||||
start = date(start_ym[0], start_ym[1], 1)
|
||||
end = date(y, m, 1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
raise ValueError(f"未知时间维度: {dimension}")
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App2 财务洞察调用。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, time_dimension, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(insights 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
time_dimension = context["time_dimension"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 构建 Prompt
|
||||
prompt_context = {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"current_data": context.get("current_data", {}),
|
||||
"previous_data": context.get("previous_data", {}),
|
||||
}
|
||||
messages = build_prompt(prompt_context)
|
||||
|
||||
# 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"time_dimension": time_dimension},
|
||||
)
|
||||
|
||||
# 写入 system prompt 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
# 写入 user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP2_FINANCE.value,
|
||||
site_id=site_id,
|
||||
target_id=time_dimension,
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App2 财务洞察完成: site_id=%s dimension=%s conversation_id=%s tokens=%d",
|
||||
site_id, time_dimension, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _month_offset(year: int, month: int, offset: int) -> tuple[int, int]:
|
||||
"""计算月份偏移,返回 (year, month)。"""
|
||||
# 转为 0-based 计算
|
||||
total = (year * 12 + (month - 1)) + offset
|
||||
return total // 12, total % 12 + 1
|
||||
213
apps/backend/app/ai/apps/app3_clue.py
Normal file
213
apps/backend/app/ai/apps/app3_clue.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""应用 3:客户数据维客线索分析(骨架)。
|
||||
|
||||
客户新增消费时自动触发,通过 AI 分析客户数据提取维客线索。
|
||||
线索 category 限定 3 个枚举值:客户基础、消费习惯、玩法偏好。
|
||||
线索提供者统一标记为"系统"。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
app_id = "app3_clue"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app3_clue"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_records 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, nickname 等
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", "content": ...}, {"role": "user", ...}]
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:App6 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析客户消费数据,提取维客线索。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": ["客户基础", "消费习惯", "玩法偏好"],
|
||||
"providers": "系统",
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
},
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(客户基础/消费习惯/玩法偏好)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
]
|
||||
},
|
||||
# TODO: P9-T1 细化 - consumption_records 等客户消费数据
|
||||
"data": {
|
||||
"consumption_records": "待 P9-T1 补充",
|
||||
"member_info": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请分析会员 {member_id} 的消费数据,提取维客线索。"
|
||||
"每条线索包含 category、summary、detail、emoji 四个字段。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App6 备注分析线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App6 备注分析线索
|
||||
app6_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, target_id,
|
||||
)
|
||||
if app6_latest:
|
||||
reference["app6_note_clues"] = {
|
||||
"result_json": app6_latest.get("result_json"),
|
||||
"generated_at": app6_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App3 客户数据维客线索分析。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, user_id(默认'system'), nickname(默认'')
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP3_CLUE.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App3 线索分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
200
apps/backend/app/ai/apps/app4_analysis.py
Normal file
200
apps/backend/app/ai/apps/app4_analysis.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""应用 4:关系分析/任务建议(骨架)。
|
||||
|
||||
助教参与新结算或被分配召回任务时自动触发,
|
||||
生成关系分析和任务建议。
|
||||
|
||||
Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at)。
|
||||
缓存不存在时 reference 传空对象,标注"暂无历史线索"。
|
||||
|
||||
app_id = "app4_analysis"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app4_analysis"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:App8 最新 + 最近 2 套历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析助教与客户的关系,生成任务建议。",
|
||||
"app_id": APP_ID,
|
||||
"output_format": {
|
||||
"task_description": "任务描述文本",
|
||||
"action_suggestions": ["建议1", "建议2"],
|
||||
"one_line_summary": "一句话总结",
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
# 缓存不存在时在 user prompt 中标注
|
||||
no_history_hint = ""
|
||||
if not reference:
|
||||
no_history_hint = "(暂无历史线索,请基于现有信息分析)"
|
||||
|
||||
user_content = (
|
||||
f"请分析助教 {assistant_id} 与会员 {member_id} 的关系,"
|
||||
f"生成任务建议。{no_history_hint}"
|
||||
"返回 task_description、action_suggestions、one_line_summary 三个字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App4 关系分析。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(task_description, action_suggestions, one_line_summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP4_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App4 关系分析完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
182
apps/backend/app/ai/apps/app5_tactics.py
Normal file
182
apps/backend/app/ai/apps/app5_tactics.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""应用 5:话术参考(骨架)。
|
||||
|
||||
App4 完成后自动联动触发,接收 App4 完整返回结果
|
||||
作为 Prompt 中的 task_suggestion 字段。
|
||||
|
||||
Prompt reference 包含最近 2 套 App8 历史(附 generated_at)。
|
||||
|
||||
app_id = "app5_tactics"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app5_tactics"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info(随 App4 同步)。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id, app4_result(dict)
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
app4_result = context.get("app4_result", {})
|
||||
|
||||
# 构建 reference:最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "基于关系分析和任务建议,生成沟通话术参考。",
|
||||
"app_id": APP_ID,
|
||||
"task_suggestion": app4_result,
|
||||
"output_format": {
|
||||
"tactics": [
|
||||
{"scenario": "场景描述", "script": "话术内容"}
|
||||
]
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info(随 App4 同步)
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。"
|
||||
"返回 tactics 数组,每条包含 scenario 和 script 字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含最近 2 套 App8 历史(附 generated_at)。
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App5 话术参考。
|
||||
|
||||
Args:
|
||||
context: site_id, assistant_id, member_id, app4_result(dict)
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(tactics 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"assistant_id": assistant_id, "member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(target_id = {assistant_id}_{member_id})
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP5_TACTICS.value,
|
||||
site_id=site_id,
|
||||
target_id=f"{assistant_id}_{member_id}",
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App5 话术参考完成: site_id=%s assistant=%s member=%s conversation_id=%s tokens=%d",
|
||||
site_id, assistant_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
217
apps/backend/app/ai/apps/app6_note.py
Normal file
217
apps/backend/app/ai/apps/app6_note.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""应用 6:备注分析(骨架)。
|
||||
|
||||
助教提交备注后自动触发,通过 AI 分析备注内容,
|
||||
提取维客线索并评分。
|
||||
|
||||
返回 score(1-10)+ clues 数组。
|
||||
评分规则:6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分。
|
||||
线索 category 限定 6 个枚举值。
|
||||
线索提供者标记为当前备注提供人(context.noted_by_name)。
|
||||
|
||||
app_id = "app6_note"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app6_note"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_data 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, note_content, noted_by_name
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
note_content = context.get("note_content", "")
|
||||
noted_by_name = context.get("noted_by_name", "")
|
||||
|
||||
# 构建 reference:App3 线索 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "分析备注内容,提取维客线索并评分。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"providers": noted_by_name,
|
||||
"scoring": "6 分为标准分,重复/低价值/时效性低酌情扣分,高价值信息酌情加分",
|
||||
"score_range": "1-10",
|
||||
},
|
||||
"output_format": {
|
||||
"score": "1-10 整数",
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
}
|
||||
],
|
||||
},
|
||||
"note_content": note_content,
|
||||
"noted_by_name": noted_by_name,
|
||||
# TODO: P9-T1 细化 - consumption_data 等客户消费数据
|
||||
"data": {
|
||||
"consumption_data": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请分析以下备注内容,提取维客线索并评分。\n"
|
||||
f"备注提供人:{noted_by_name}\n"
|
||||
f"备注内容:{note_content}\n"
|
||||
"返回 score(1-10 整数)和 clues 数组。"
|
||||
"category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App3 客户数据线索(最新一条,如有)
|
||||
- 最近 2 套 App8 维客线索整理历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App3 客户数据线索
|
||||
app3_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, target_id,
|
||||
)
|
||||
if app3_latest:
|
||||
reference["app3_clues"] = {
|
||||
"result_json": app3_latest.get("result_json"),
|
||||
"generated_at": app3_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App6 备注分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, note_content, noted_by_name
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(score + clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存(score 存入 ai_cache.score)
|
||||
score = result.get("score")
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP6_NOTE_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
score=score,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App6 备注分析完成: site_id=%s member_id=%s score=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, score, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
200
apps/backend/app/ai/apps/app7_customer.py
Normal file
200
apps/backend/app/ai/apps/app7_customer.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""应用 7:客户分析(骨架)。
|
||||
|
||||
消费事件链中 App8 完成后串行触发,生成客户全量分析与运营建议。
|
||||
|
||||
使用 items_sum 口径(= table_charge_money + goods_money
|
||||
+ assistant_pd_money + assistant_cx_money + electricity_money),
|
||||
禁止使用 consume_money。
|
||||
|
||||
对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】。
|
||||
|
||||
app_id = "app7_customer"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app7_customer"
|
||||
|
||||
|
||||
def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 objective_data 等完整数据。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id
|
||||
cache_svc: 缓存服务,用于获取 reference 历史数据
|
||||
|
||||
Returns:
|
||||
消息列表
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 构建 reference:最新 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "综合分析客户数据,生成运营策略建议。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
"amount_caliber": "items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money",
|
||||
"禁止使用": "consume_money",
|
||||
"subjective_info_label": "对主观信息(来自备注)标注【来源:XXX,请甄别信息真实性】",
|
||||
},
|
||||
"output_format": {
|
||||
"strategies": [
|
||||
{"title": "策略标题", "content": "策略内容"}
|
||||
],
|
||||
"summary": "一句话总结",
|
||||
},
|
||||
# TODO: P9-T1 细化 - objective_data 等客户消费数据
|
||||
"data": {
|
||||
"objective_data": "待 P9-T1 补充",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。"
|
||||
"返回 strategies 数组(每条含 title 和 content)和 summary 字段。"
|
||||
"对来自备注的主观信息,请标注【来源:XXX,请甄别信息真实性】。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_reference(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
cache_svc: AICacheService | None,
|
||||
) -> dict:
|
||||
"""构建 Prompt reference 字段。
|
||||
|
||||
包含:
|
||||
- App8 最新维客线索(如有)
|
||||
- 最近 2 套 App8 历史(附 generated_at)
|
||||
|
||||
缓存不存在时返回空对象 {}。
|
||||
"""
|
||||
if cache_svc is None:
|
||||
return {}
|
||||
|
||||
reference: dict = {}
|
||||
target_id = str(member_id)
|
||||
|
||||
# App8 最新
|
||||
app8_latest = cache_svc.get_latest(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id,
|
||||
)
|
||||
if app8_latest:
|
||||
reference["app8_latest"] = {
|
||||
"result_json": app8_latest.get("result_json"),
|
||||
"generated_at": app8_latest.get("created_at"),
|
||||
}
|
||||
|
||||
# 最近 2 套 App8 历史
|
||||
app8_history = cache_svc.get_history(
|
||||
CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, target_id, limit=2,
|
||||
)
|
||||
if app8_history:
|
||||
reference["app8_history"] = [
|
||||
{
|
||||
"result_json": h.get("result_json"),
|
||||
"generated_at": h.get("created_at"),
|
||||
}
|
||||
for h in app8_history
|
||||
]
|
||||
|
||||
return reference
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App7 客户分析。
|
||||
|
||||
Args:
|
||||
context: site_id, member_id
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(strategies 数组 + summary)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App7 客户分析完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
211
apps/backend/app/ai/apps/app8_consolidation.py
Normal file
211
apps/backend/app/ai/apps/app8_consolidation.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""应用 8:维客线索整理。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的线索,
|
||||
通过百炼 AI 整合去重,然后全量替换写入 member_retention_clue 表。
|
||||
|
||||
app_id = "app8_consolidation"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app8_consolidation_prompt import build_prompt
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app8_consolidation"
|
||||
|
||||
|
||||
class ClueWriter:
|
||||
"""维客线索全量替换写入器。
|
||||
|
||||
DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)。
|
||||
人工线索(source='manual')不受影响。
|
||||
"""
|
||||
|
||||
def replace_ai_clues(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
clues: list[dict],
|
||||
) -> int:
|
||||
"""全量替换该客户的 AI 来源线索,返回写入数量。
|
||||
|
||||
在单个事务中执行 DELETE + INSERT,失败时回滚保留原有线索。
|
||||
|
||||
字段映射:
|
||||
- category → category
|
||||
- emoji + " " + summary → summary(如 "📅 偏好周末下午时段消费")
|
||||
- detail → detail
|
||||
- providers → recorded_by_name
|
||||
- source: 根据 providers 判断(见 _determine_source)
|
||||
- recorded_by_assistant_id: NULL(系统触发)
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 1. 删除该客户所有 AI 来源线索
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
AND source IN ('ai_consumption', 'ai_note')
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
|
||||
# 2. 插入新线索
|
||||
for clue in clues:
|
||||
emoji = clue.get("emoji", "")
|
||||
raw_summary = clue.get("summary", "")
|
||||
summary = f"{emoji} {raw_summary}" if emoji else raw_summary
|
||||
source = _determine_source(clue.get("providers", ""))
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO member_retention_clue
|
||||
(member_id, site_id, category, summary, detail,
|
||||
source, recorded_by_name, recorded_by_assistant_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NULL)
|
||||
""",
|
||||
(
|
||||
member_id,
|
||||
site_id,
|
||||
clue.get("category", ""),
|
||||
summary,
|
||||
clue.get("detail", ""),
|
||||
source,
|
||||
clue.get("providers", ""),
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return len(clues)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _determine_source(providers: str) -> str:
|
||||
"""根据 providers 判断 source 值。
|
||||
|
||||
- 纯 App3(providers 仅含"系统")→ ai_consumption
|
||||
- 纯 App6(providers 不含"系统")→ ai_note
|
||||
- 混合来源 → ai_consumption
|
||||
"""
|
||||
if not providers:
|
||||
return "ai_consumption"
|
||||
provider_list = [p.strip() for p in providers.split(",")]
|
||||
has_system = "系统" in provider_list
|
||||
has_human = any(p != "系统" for p in provider_list if p)
|
||||
if has_system and not has_human:
|
||||
# 纯 App3(系统自动分析)
|
||||
return "ai_consumption"
|
||||
elif has_human and not has_system:
|
||||
# 纯 App6(人工备注分析)
|
||||
return "ai_note"
|
||||
else:
|
||||
# 混合来源
|
||||
return "ai_consumption"
|
||||
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
"""执行 App8 维客线索整理。
|
||||
|
||||
流程:
|
||||
1. build_prompt 构建 Prompt
|
||||
2. bailian.chat_json 调用百炼
|
||||
3. 写入 conversation + messages
|
||||
4. 写入 ai_cache
|
||||
5. ClueWriter 全量替换 member_retention_clue
|
||||
6. 返回结果
|
||||
|
||||
Args:
|
||||
context: site_id, member_id, app3_clues, app6_clues,
|
||||
app3_generated_at, app6_generated_at
|
||||
bailian: 百炼客户端
|
||||
cache_svc: 缓存服务
|
||||
conv_svc: 对话服务
|
||||
|
||||
Returns:
|
||||
百炼返回的结构化 JSON(clues 数组)
|
||||
"""
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
user_id = context.get("user_id", "system")
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
app_id=APP_ID,
|
||||
site_id=site_id,
|
||||
source_context={"member_id": member_id},
|
||||
)
|
||||
|
||||
# 写入 system + user 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="system",
|
||||
content=messages[0]["content"],
|
||||
)
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=messages[1]["content"],
|
||||
)
|
||||
|
||||
# 3. 调用百炼 API
|
||||
result, tokens_used = await bailian.chat_json(messages)
|
||||
|
||||
# 4. 写入 assistant 消息
|
||||
conv_svc.add_message(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=json.dumps(result, ensure_ascii=False),
|
||||
tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 5. 写入缓存
|
||||
cache_svc.write_cache(
|
||||
cache_type=CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value,
|
||||
site_id=site_id,
|
||||
target_id=str(member_id),
|
||||
result_json=result,
|
||||
triggered_by=f"user:{user_id}",
|
||||
)
|
||||
|
||||
# 6. 全量替换 member_retention_clue
|
||||
clues = result.get("clues", [])
|
||||
if clues:
|
||||
writer = ClueWriter()
|
||||
written = writer.replace_ai_clues(member_id, site_id, clues)
|
||||
logger.info(
|
||||
"App8 线索写入完成: site_id=%s member_id=%s written=%d",
|
||||
site_id, member_id, written,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"App8 线索整理完成: site_id=%s member_id=%s conversation_id=%s tokens=%d",
|
||||
site_id, member_id, conversation_id, tokens_used,
|
||||
)
|
||||
|
||||
return result
|
||||
338
apps/backend/app/ai/dispatcher.py
Normal file
338
apps/backend/app/ai/dispatcher.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""AI 事件调度与调用链编排器。
|
||||
|
||||
根据业务事件(消费、备注、任务分配)编排 AI 应用调用链,
|
||||
确保执行顺序和数据依赖正确。
|
||||
|
||||
调用链:
|
||||
- 消费事件(无助教):App3 → App8 → App7
|
||||
- 消费事件(有助教):App3 → App8 → App7 + App4 → App5
|
||||
- 备注事件:App6 → App8
|
||||
- 任务分配事件:App4 → App5(读已有 App8 缓存)
|
||||
|
||||
容错策略:
|
||||
- 某步失败记录错误日志,后续应用使用已有缓存继续
|
||||
- 失败应用写入失败 conversation 记录
|
||||
- 整条链后台异步执行,不阻塞业务请求
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIDispatcher:
|
||||
"""AI 应用调用链编排器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bailian: BailianClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> None:
|
||||
self.bailian = bailian
|
||||
self.cache_svc = cache_svc
|
||||
self.conv_svc = conv_svc
|
||||
|
||||
async def handle_consumption_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
settle_id: int,
|
||||
assistant_id: int | None = None,
|
||||
) -> None:
|
||||
"""消费事件链:App3 → App8 → App7(+ App4 → App5 如有助教)。"""
|
||||
from app.ai.apps.app3_clue import run as app3_run
|
||||
from app.ai.apps.app4_analysis import run as app4_run
|
||||
from app.ai.apps.app5_tactics import run as app5_run
|
||||
from app.ai.apps.app7_customer import run as app7_run
|
||||
from app.ai.apps.app8_consolidation import run as app8_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"settle_id": settle_id,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App3 线索分析
|
||||
app3_result = await self._run_step("app3_clue", app3_run, context)
|
||||
|
||||
# 步骤 2:App8 线索整理(需要 App3 的 clues)
|
||||
app8_context = {**context}
|
||||
# 从 App3 结果提取 clues;同时从缓存获取 App6 已有线索
|
||||
if app3_result:
|
||||
app8_context["app3_clues"] = app3_result.get("clues", [])
|
||||
app8_context["app3_generated_at"] = None # 刚生成,无需时间戳
|
||||
else:
|
||||
app8_context["app3_clues"] = []
|
||||
app8_context["app3_generated_at"] = None
|
||||
|
||||
# 从缓存获取 App6 已有线索
|
||||
app6_cache = self.cache_svc.get_latest(
|
||||
CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, str(member_id),
|
||||
)
|
||||
if app6_cache:
|
||||
app6_result_json = app6_cache.get("result_json", {})
|
||||
if isinstance(app6_result_json, str):
|
||||
try:
|
||||
app6_result_json = json.loads(app6_result_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
app6_result_json = {}
|
||||
app8_context["app6_clues"] = app6_result_json.get("clues", [])
|
||||
app8_context["app6_generated_at"] = app6_cache.get("created_at")
|
||||
else:
|
||||
app8_context["app6_clues"] = []
|
||||
app8_context["app6_generated_at"] = None
|
||||
|
||||
await self._run_step("app8_consolidation", app8_run, app8_context)
|
||||
|
||||
# 步骤 3:App7 客户分析
|
||||
await self._run_step("app7_customer", app7_run, context)
|
||||
|
||||
# 步骤 4(可选):如有助教,App4 → App5
|
||||
if assistant_id is not None:
|
||||
app4_context = {**context, "assistant_id": assistant_id}
|
||||
app4_result = await self._run_step("app4_analysis", app4_run, app4_context)
|
||||
|
||||
app5_context = {
|
||||
**context,
|
||||
"assistant_id": assistant_id,
|
||||
"app4_result": app4_result or {},
|
||||
}
|
||||
await self._run_step("app5_tactics", app5_run, app5_context)
|
||||
|
||||
async def handle_note_event(
|
||||
self,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
note_id: int,
|
||||
note_content: str,
|
||||
noted_by_name: str,
|
||||
) -> None:
|
||||
"""备注事件链:App6 → App8。"""
|
||||
from app.ai.apps.app6_note import run as app6_run
|
||||
from app.ai.apps.app8_consolidation import run as app8_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"note_id": note_id,
|
||||
"note_content": note_content,
|
||||
"noted_by_name": noted_by_name,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App6 备注分析
|
||||
app6_result = await self._run_step("app6_note", app6_run, context)
|
||||
|
||||
# 步骤 2:App8 线索整理(需要 App6 的 clues)
|
||||
app8_context: dict[str, Any] = {
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
if app6_result:
|
||||
app8_context["app6_clues"] = app6_result.get("clues", [])
|
||||
app8_context["app6_generated_at"] = None
|
||||
else:
|
||||
app8_context["app6_clues"] = []
|
||||
app8_context["app6_generated_at"] = None
|
||||
|
||||
# 从缓存获取 App3 已有线索
|
||||
app3_cache = self.cache_svc.get_latest(
|
||||
CacheTypeEnum.APP3_CLUE.value, site_id, str(member_id),
|
||||
)
|
||||
if app3_cache:
|
||||
app3_result_json = app3_cache.get("result_json", {})
|
||||
if isinstance(app3_result_json, str):
|
||||
try:
|
||||
app3_result_json = json.loads(app3_result_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
app3_result_json = {}
|
||||
app8_context["app3_clues"] = app3_result_json.get("clues", [])
|
||||
app8_context["app3_generated_at"] = app3_cache.get("created_at")
|
||||
else:
|
||||
app8_context["app3_clues"] = []
|
||||
app8_context["app3_generated_at"] = None
|
||||
|
||||
await self._run_step("app8_consolidation", app8_run, app8_context)
|
||||
|
||||
async def handle_task_assign_event(
|
||||
self,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
site_id: int,
|
||||
task_type: str,
|
||||
) -> None:
|
||||
"""任务分配事件链:App4 → App5(读已有 App8 缓存)。"""
|
||||
from app.ai.apps.app4_analysis import run as app4_run
|
||||
from app.ai.apps.app5_tactics import run as app5_run
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"assistant_id": assistant_id,
|
||||
"member_id": member_id,
|
||||
"site_id": site_id,
|
||||
"task_type": task_type,
|
||||
"user_id": "system",
|
||||
"nickname": "",
|
||||
}
|
||||
|
||||
# 步骤 1:App4 关系分析
|
||||
app4_result = await self._run_step("app4_analysis", app4_run, context)
|
||||
|
||||
# 步骤 2:App5 话术参考
|
||||
app5_context = {
|
||||
**context,
|
||||
"app4_result": app4_result or {},
|
||||
}
|
||||
await self._run_step("app5_tactics", app5_run, app5_context)
|
||||
|
||||
async def _run_chain(
|
||||
self,
|
||||
chain: list[tuple[str, Callable[..., Coroutine], dict]],
|
||||
) -> None:
|
||||
"""串行执行调用链,某步失败记录日志后继续。
|
||||
|
||||
Args:
|
||||
chain: [(app_name, run_func, context), ...] 的列表
|
||||
"""
|
||||
for app_name, run_func, ctx in chain:
|
||||
await self._run_step(app_name, run_func, ctx)
|
||||
|
||||
async def _run_step(
|
||||
self,
|
||||
app_name: str,
|
||||
run_func: Callable[..., Coroutine],
|
||||
context: dict,
|
||||
) -> dict | None:
|
||||
"""执行单个应用步骤,失败时记录日志并写入失败 conversation。
|
||||
|
||||
Returns:
|
||||
应用返回结果,失败时返回 None
|
||||
"""
|
||||
try:
|
||||
result = await run_func(
|
||||
context,
|
||||
self.bailian,
|
||||
self.cache_svc,
|
||||
self.conv_svc,
|
||||
)
|
||||
logger.info("调用链步骤成功: %s", app_name)
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("调用链步骤失败: %s", app_name)
|
||||
# 写入失败 conversation 记录
|
||||
try:
|
||||
site_id = context.get("site_id", 0)
|
||||
conv_id = self.conv_svc.create_conversation(
|
||||
user_id="system",
|
||||
nickname="",
|
||||
app_id=app_name,
|
||||
site_id=site_id,
|
||||
source_context={"error": True, "chain_step": app_name},
|
||||
)
|
||||
self.conv_svc.add_message(
|
||||
conversation_id=conv_id,
|
||||
role="system",
|
||||
content=f"调用链步骤 {app_name} 执行失败",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("写入失败 conversation 记录也失败: %s", app_name)
|
||||
return None
|
||||
|
||||
def _create_ai_event_handlers(dispatcher: AIDispatcher) -> dict[str, Callable]:
|
||||
"""创建 AI 事件处理器,用于注册到 trigger_scheduler。
|
||||
|
||||
每个处理器从 payload 提取参数,通过 asyncio.create_task 后台执行,
|
||||
不阻塞同步的 fire_event 调用。
|
||||
|
||||
Returns:
|
||||
{event_job_type: handler_func} 映射
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
def _get_or_create_loop() -> asyncio.AbstractEventLoop:
|
||||
"""获取当前事件循环,兼容同步调用场景。"""
|
||||
try:
|
||||
return asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.new_event_loop()
|
||||
|
||||
def handle_consumption_settled(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""消费结算事件处理器(同步入口,内部异步执行)。"""
|
||||
if not payload:
|
||||
logger.warning("consumption_settled 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_consumption_event(
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
settle_id=payload["settle_id"],
|
||||
assistant_id=payload.get("assistant_id"),
|
||||
)
|
||||
)
|
||||
|
||||
def handle_note_created(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""备注创建事件处理器。"""
|
||||
if not payload:
|
||||
logger.warning("note_created 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_note_event(
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
note_id=payload["note_id"],
|
||||
note_content=payload.get("note_content", ""),
|
||||
noted_by_name=payload.get("noted_by_name", ""),
|
||||
)
|
||||
)
|
||||
|
||||
def handle_task_assigned(payload: dict | None = None, **_kw: Any) -> None:
|
||||
"""任务分配事件处理器。"""
|
||||
if not payload:
|
||||
logger.warning("task_assigned 事件缺少 payload")
|
||||
return
|
||||
loop = _get_or_create_loop()
|
||||
loop.create_task(
|
||||
dispatcher.handle_task_assign_event(
|
||||
assistant_id=payload["assistant_id"],
|
||||
member_id=payload["member_id"],
|
||||
site_id=payload["site_id"],
|
||||
task_type=payload.get("task_type", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"ai_consumption_settled": handle_consumption_settled,
|
||||
"ai_note_created": handle_note_created,
|
||||
"ai_task_assigned": handle_task_assigned,
|
||||
}
|
||||
|
||||
|
||||
def register_ai_handlers(dispatcher: AIDispatcher) -> None:
|
||||
"""将 AI 事件处理器注册到 trigger_scheduler。
|
||||
|
||||
在 FastAPI lifespan 中调用,将三个 AI 事件处理器
|
||||
注册为 trigger_scheduler 的 job handler。
|
||||
"""
|
||||
from app.services.trigger_scheduler import register_job
|
||||
|
||||
handlers = _create_ai_event_handlers(dispatcher)
|
||||
for job_type, handler in handlers.items():
|
||||
register_job(job_type, handler)
|
||||
logger.info("已注册 AI 事件处理器: %s", job_type)
|
||||
145
apps/backend/app/ai/prompts/app2_finance_prompt.py
Normal file
145
apps/backend/app/ai/prompts/app2_finance_prompt.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""应用 2 财务洞察 Prompt 模板。
|
||||
|
||||
构建包含当期和上期收入结构的完整 Prompt,供百炼 API 生成财务洞察。
|
||||
|
||||
收入字段映射(严格遵守 items_sum 口径):
|
||||
- table_fee = table_charge_money(台费)
|
||||
- assistant_pd = assistant_pd_money(陪打费)
|
||||
- assistant_cx = assistant_cx_money(超休费)
|
||||
- goods = goods_money(商品收入)
|
||||
- recharge = 充值 pay_amount settle_type=5(充值收入)
|
||||
|
||||
禁止使用 consume_money,统一使用:
|
||||
items_sum = table_charge_money + goods_money + assistant_pd_money
|
||||
+ assistant_cx_money + electricity_money
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App2 财务洞察 Prompt 消息列表。
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int,门店 ID
|
||||
- time_dimension: str,时间维度编码
|
||||
- current_data: dict,当期数据
|
||||
- previous_data: dict,上期数据
|
||||
|
||||
Returns:
|
||||
messages 列表(system + user),供 BailianClient.chat_json 调用
|
||||
"""
|
||||
site_id = context.get("site_id", 0)
|
||||
time_dimension = context.get("time_dimension", "")
|
||||
current_data = context.get("current_data", {})
|
||||
previous_data = context.get("previous_data", {})
|
||||
|
||||
system_content = _build_system_content(
|
||||
site_id=site_id,
|
||||
time_dimension=time_dimension,
|
||||
current_data=current_data,
|
||||
previous_data=previous_data,
|
||||
)
|
||||
|
||||
user_content = (
|
||||
f"请根据以上数据,为门店 {site_id} 生成 {_dimension_label(time_dimension)} 的财务洞察分析。"
|
||||
"以 JSON 格式返回,包含 insights 数组,每项含 seq(序号)、title(标题)、body(正文)。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_content(
|
||||
*,
|
||||
site_id: int,
|
||||
time_dimension: str,
|
||||
current_data: dict,
|
||||
previous_data: dict,
|
||||
) -> dict:
|
||||
"""构建 system prompt JSON 结构。"""
|
||||
return {
|
||||
"task": (
|
||||
"你是台球门店的财务分析 AI 助手。"
|
||||
"根据提供的当期和上期经营数据,生成结构化的财务洞察。"
|
||||
"分析维度包括:收入结构变化、各收入项占比、环比趋势、异常波动。"
|
||||
"输出 JSON 格式:{\"insights\": [{\"seq\": 1, \"title\": \"...\", \"body\": \"...\"}]}"
|
||||
),
|
||||
"data": {
|
||||
"site_id": site_id,
|
||||
"time_dimension": time_dimension,
|
||||
"time_dimension_label": _dimension_label(time_dimension),
|
||||
"current_period": _build_period_data(current_data),
|
||||
"previous_period": _build_period_data(previous_data),
|
||||
},
|
||||
"reference": {
|
||||
"field_mapping": {
|
||||
"items_sum": (
|
||||
"table_charge_money + goods_money + assistant_pd_money"
|
||||
" + assistant_cx_money + electricity_money"
|
||||
),
|
||||
"table_fee": "table_charge_money(台费收入)",
|
||||
"assistant_pd": "assistant_pd_money(陪打费)",
|
||||
"assistant_cx": "assistant_cx_money(超休费)",
|
||||
"goods": "goods_money(商品收入)",
|
||||
"recharge": "充值 pay_amount(settle_type=5,充值收入)",
|
||||
"electricity": "electricity_money(电费,当前未启用,全为 0)",
|
||||
},
|
||||
"rules": [
|
||||
"统一使用 items_sum 口径计算营收总额",
|
||||
"助教费用必须拆分为 assistant_pd_money(陪打)和 assistant_cx_money(超休)",
|
||||
"支付渠道恒等式:balance_amount = recharge_card_amount + gift_card_amount",
|
||||
"金额单位:元(CNY),保留两位小数",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_period_data(data: dict) -> dict:
|
||||
"""构建单期数据结构,确保字段名遵守 items_sum 口径。"""
|
||||
return {
|
||||
# 收入结构(items_sum 口径)
|
||||
"table_charge_money": data.get("table_charge_money", 0),
|
||||
"goods_money": data.get("goods_money", 0),
|
||||
"assistant_pd_money": data.get("assistant_pd_money", 0),
|
||||
"assistant_cx_money": data.get("assistant_cx_money", 0),
|
||||
"electricity_money": data.get("electricity_money", 0),
|
||||
# 充值收入
|
||||
"recharge_income": data.get("recharge_income", 0),
|
||||
# 储值资产
|
||||
"balance_pay": data.get("balance_pay", 0),
|
||||
"recharge_card_pay": data.get("recharge_card_pay", 0),
|
||||
"gift_card_pay": data.get("gift_card_pay", 0),
|
||||
# 费用汇总
|
||||
"discount_amount": data.get("discount_amount", 0),
|
||||
"adjust_amount": data.get("adjust_amount", 0),
|
||||
# 平台结算
|
||||
"platform_settlement_amount": data.get("platform_settlement_amount", 0),
|
||||
"groupbuy_pay_amount": data.get("groupbuy_pay_amount", 0),
|
||||
# 汇总
|
||||
"order_count": data.get("order_count", 0),
|
||||
"member_count": data.get("member_count", 0),
|
||||
}
|
||||
|
||||
|
||||
# 时间维度编码 → 中文标签
|
||||
_DIMENSION_LABELS: dict[str, str] = {
|
||||
"this_month": "本月",
|
||||
"last_month": "上月",
|
||||
"this_week": "本周",
|
||||
"last_week": "上周",
|
||||
"last_3_months": "近三个月",
|
||||
"this_quarter": "本季度",
|
||||
"last_quarter": "上季度",
|
||||
"last_6_months": "近六个月",
|
||||
}
|
||||
|
||||
|
||||
def _dimension_label(dimension: str) -> str:
|
||||
"""将时间维度编码转为中文标签。"""
|
||||
return _DIMENSION_LABELS.get(dimension, dimension)
|
||||
93
apps/backend/app/ai/prompts/app8_consolidation_prompt.py
Normal file
93
apps/backend/app/ai/prompts/app8_consolidation_prompt.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""应用 8:维客线索整理 Prompt 模板。
|
||||
|
||||
接收 App3(消费分析)和 App6(备注分析)的全部线索,
|
||||
整合去重后输出统一维客线索。
|
||||
|
||||
分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致):
|
||||
客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈。
|
||||
|
||||
合并规则:
|
||||
- 相似线索合并,providers 以逗号分隔
|
||||
- 其余线索原文返回
|
||||
- 最小改动原则
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def build_prompt(context: dict) -> list[dict]:
|
||||
"""构建 App8 维客线索整理 Prompt。
|
||||
|
||||
Args:
|
||||
context: 包含以下字段:
|
||||
- site_id: int
|
||||
- member_id: int
|
||||
- app3_clues: list[dict] — App3 产出的线索列表
|
||||
- app6_clues: list[dict] — App6 产出的线索列表
|
||||
- app3_generated_at: str | None — App3 线索生成时间
|
||||
- app6_generated_at: str | None — App6 线索生成时间
|
||||
|
||||
Returns:
|
||||
消息列表 [{"role": "system", ...}, {"role": "user", ...}]
|
||||
"""
|
||||
member_id = context["member_id"]
|
||||
app3_clues = context.get("app3_clues", [])
|
||||
app6_clues = context.get("app6_clues", [])
|
||||
app3_generated_at = context.get("app3_generated_at")
|
||||
app6_generated_at = context.get("app6_generated_at")
|
||||
|
||||
system_content = {
|
||||
"task": "整合去重来自消费分析和备注分析的维客线索,输出统一线索列表。",
|
||||
"app_id": "app8_consolidation",
|
||||
"rules": {
|
||||
"category_enum": [
|
||||
"客户基础", "消费习惯", "玩法偏好",
|
||||
"促销偏好", "社交关系", "重要反馈",
|
||||
],
|
||||
"merge_strategy": (
|
||||
"相似线索合并为一条,providers 以逗号分隔(如 '系统,张三');"
|
||||
"不相似的线索原文保留,不做修改。最小改动原则。"
|
||||
),
|
||||
"output_format": {
|
||||
"clues": [
|
||||
{
|
||||
"category": "枚举值(6 选 1)",
|
||||
"summary": "一句话摘要",
|
||||
"detail": "详细说明",
|
||||
"emoji": "表情符号",
|
||||
"providers": "提供者(逗号分隔)",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
"input": {
|
||||
"app3_clues": {
|
||||
"source": "消费数据分析(App3)",
|
||||
"generated_at": app3_generated_at,
|
||||
"clues": app3_clues,
|
||||
},
|
||||
"app6_clues": {
|
||||
"source": "备注分析(App6)",
|
||||
"generated_at": app6_generated_at,
|
||||
"clues": app6_clues,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
user_content = (
|
||||
f"请整合会员 {member_id} 的维客线索。\n"
|
||||
"输入包含两个来源的线索:App3(消费数据分析)和 App6(备注分析)。\n"
|
||||
"规则:\n"
|
||||
"1. 相似线索合并为一条,providers 字段以逗号分隔多个提供者\n"
|
||||
"2. 不相似的线索原文保留\n"
|
||||
"3. category 必须是:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈 之一\n"
|
||||
"4. 每条线索包含 category、summary、detail、emoji、providers 五个字段\n"
|
||||
"5. 最小改动原则,尽量保留原始表述"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
@@ -17,7 +17,9 @@ from app import config
|
||||
# CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue(维客线索重构)
|
||||
# CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核)
|
||||
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由(AI SSE 对话 + 历史对话)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由(AI 缓存查询)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_ai_chat, xcx_ai_cache
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -57,6 +59,25 @@ async def lifespan(app: FastAPI):
|
||||
register_job("recall_completion_check", recall_detector.run)
|
||||
register_job("note_reclassify_backfill", note_reclassifier.run)
|
||||
|
||||
# CHANGE 2026-03-10 | 注册 AI 事件处理器(消费/备注/任务分配 → AI 调用链)
|
||||
try:
|
||||
import os
|
||||
_api_key = os.environ.get("BAILIAN_API_KEY", "")
|
||||
_base_url = os.environ.get("BAILIAN_BASE_URL", "")
|
||||
_model = os.environ.get("BAILIAN_MODEL", "qwen-plus")
|
||||
if _api_key and _base_url:
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.dispatcher import AIDispatcher, register_ai_handlers
|
||||
|
||||
_bailian = BailianClient(api_key=_api_key, base_url=_base_url, model=_model)
|
||||
_dispatcher = AIDispatcher(_bailian, AICacheService(), ConversationService())
|
||||
register_ai_handlers(_dispatcher)
|
||||
except Exception:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).warning("AI 事件处理器注册失败,AI 功能不可用", exc_info=True)
|
||||
|
||||
yield
|
||||
# 关闭
|
||||
await scheduler.stop()
|
||||
@@ -100,6 +121,8 @@ app.include_router(admin_applications.router)
|
||||
app.include_router(business_day.router)
|
||||
app.include_router(xcx_tasks.router)
|
||||
app.include_router(xcx_notes.router)
|
||||
app.include_router(xcx_ai_chat.router)
|
||||
app.include_router(xcx_ai_cache.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
|
||||
59
apps/backend/app/routers/xcx_ai_cache.py
Normal file
59
apps/backend/app/routers/xcx_ai_cache.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序 AI 缓存查询路由 —— 查询各 AI 应用的最新缓存结果。
|
||||
|
||||
端点清单:
|
||||
- GET /api/ai/cache/{cache_type}?target_id=xxx — 查询最新缓存
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["小程序 AI 缓存"])
|
||||
|
||||
|
||||
@router.get("/cache/{cache_type}")
|
||||
async def get_ai_cache(
|
||||
cache_type: str,
|
||||
target_id: str = Query(..., description="目标 ID(member_id / assistant_id_member_id / 时间维度编码)"),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询指定类型的最新 AI 缓存结果。
|
||||
|
||||
site_id 从 JWT 提取,强制过滤,确保门店隔离。
|
||||
"""
|
||||
# 校验 cache_type 合法性
|
||||
valid_types = {e.value for e in CacheTypeEnum}
|
||||
if cache_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"无效的 cache_type: {cache_type},合法值: {sorted(valid_types)}",
|
||||
)
|
||||
|
||||
cache_svc = AICacheService()
|
||||
result = cache_svc.get_latest(
|
||||
cache_type=cache_type,
|
||||
site_id=user.site_id,
|
||||
target_id=target_id,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": result.get("id"),
|
||||
"cache_type": result.get("cache_type"),
|
||||
"target_id": result.get("target_id"),
|
||||
"result_json": result.get("result_json"),
|
||||
"score": result.get("score"),
|
||||
"created_at": result.get("created_at"),
|
||||
}
|
||||
223
apps/backend/app/routers/xcx_ai_chat.py
Normal file
223
apps/backend/app/routers/xcx_ai_chat.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序 AI 对话路由 —— SSE 流式对话、历史对话列表、消息查询。
|
||||
|
||||
端点清单:
|
||||
- POST /api/ai/chat/stream — SSE 流式对话
|
||||
- GET /api/ai/conversations — 历史对话列表(分页)
|
||||
- GET /api/ai/conversations/{conversation_id}/messages — 对话消息列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.apps.app1_chat import chat_stream
|
||||
from app.ai.schemas import ChatStreamRequest, SSEEvent
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["小程序 AI 对话"])
|
||||
|
||||
|
||||
# ── 辅助:获取用户 nickname ──────────────────────────────────
|
||||
|
||||
|
||||
def _get_user_nickname(user_id: int) -> str:
|
||||
"""从 auth.users 查询用户 nickname,查不到返回空字符串。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT nickname FROM auth.users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── 辅助:获取用户主要角色 ───────────────────────────────────
|
||||
|
||||
|
||||
def _get_user_role_label(roles: list[str]) -> str:
|
||||
"""从角色列表提取主要角色标签,用于 AI 上下文。"""
|
||||
if "store_manager" in roles or "owner" in roles:
|
||||
return "管理者"
|
||||
if "assistant" in roles or "coach" in roles:
|
||||
return "助教"
|
||||
return "用户"
|
||||
|
||||
|
||||
# ── 辅助:构建 BailianClient 实例 ────────────────────────────
|
||||
|
||||
|
||||
def _get_bailian_client() -> BailianClient:
|
||||
"""从环境变量构建 BailianClient,缺失时报错。"""
|
||||
api_key = os.environ.get("BAILIAN_API_KEY")
|
||||
base_url = os.environ.get("BAILIAN_BASE_URL")
|
||||
model = os.environ.get("BAILIAN_MODEL")
|
||||
if not api_key or not base_url or not model:
|
||||
raise RuntimeError(
|
||||
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
|
||||
)
|
||||
return BailianClient(api_key=api_key, base_url=base_url, model=model)
|
||||
|
||||
|
||||
# ── SSE 流式对话 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def ai_chat_stream(
|
||||
body: ChatStreamRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""SSE 流式对话端点。
|
||||
|
||||
接收用户消息,通过百炼 API 流式返回 AI 回复。
|
||||
每个 SSE 事件格式:data: {json}\n\n
|
||||
事件类型:chunk(文本片段)/ done(完成)/ error(错误)
|
||||
"""
|
||||
if not body.message or not body.message.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="消息内容不能为空",
|
||||
)
|
||||
|
||||
nickname = _get_user_nickname(user.user_id)
|
||||
role_label = _get_user_role_label(user.roles)
|
||||
bailian = _get_bailian_client()
|
||||
conv_svc = ConversationService()
|
||||
|
||||
async def event_generator():
|
||||
"""SSE 事件生成器,逐事件 yield data: {json}\n\n 格式。"""
|
||||
try:
|
||||
async for event in chat_stream(
|
||||
message=body.message.strip(),
|
||||
user_id=user.user_id,
|
||||
nickname=nickname,
|
||||
role=role_label,
|
||||
site_id=user.site_id,
|
||||
source_page=body.source_page,
|
||||
page_context=body.page_context,
|
||||
screen_content=body.screen_content,
|
||||
bailian=bailian,
|
||||
conv_svc=conv_svc,
|
||||
):
|
||||
yield f"data: {event.model_dump_json()}\n\n"
|
||||
except Exception as e:
|
||||
# 兜底:生成器内部异常也以 SSE error 事件返回
|
||||
logger.error("SSE 生成器异常: %s", e, exc_info=True)
|
||||
error_event = SSEEvent(type="error", message=str(e))
|
||||
yield f"data: {error_event.model_dump_json()}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # nginx 禁用缓冲
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 历史对话列表 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询当前用户的历史对话列表,按时间倒序,分页。"""
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1 or page_size > 100:
|
||||
page_size = 20
|
||||
|
||||
conv_svc = ConversationService()
|
||||
conversations = conv_svc.get_conversations(
|
||||
user_id=user.user_id,
|
||||
site_id=user.site_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# 为每条对话附加首条消息预览
|
||||
result = []
|
||||
for conv in conversations:
|
||||
item = {
|
||||
"id": conv["id"],
|
||||
"app_id": conv["app_id"],
|
||||
"source_page": conv.get("source_page"),
|
||||
"created_at": conv["created_at"],
|
||||
"first_message_preview": None,
|
||||
}
|
||||
# 查询首条 user 消息作为预览
|
||||
messages = conv_svc.get_messages(conv["id"])
|
||||
for msg in messages:
|
||||
if msg["role"] == "user":
|
||||
content = msg["content"] or ""
|
||||
item["first_message_preview"] = content[:50] if len(content) > 50 else content
|
||||
break
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 对话消息列表 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
async def get_conversation_messages(
|
||||
conversation_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询指定对话的所有消息,按时间升序。
|
||||
|
||||
验证对话归属当前用户和 site_id,防止越权访问。
|
||||
"""
|
||||
# 先验证对话归属
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE id = %s AND user_id = %s AND site_id = %s
|
||||
""",
|
||||
(conversation_id, str(user.user_id), user.site_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="对话不存在或无权访问",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conv_svc = ConversationService()
|
||||
messages = conv_svc.get_messages(conversation_id)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": msg["id"],
|
||||
"role": msg["role"],
|
||||
"content": msg["content"],
|
||||
"tokens_used": msg.get("tokens_used"),
|
||||
"created_at": msg["created_at"],
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
@@ -26,6 +26,8 @@ class TaskListItem(BaseModel):
|
||||
# RS 指数 + 爱心 icon
|
||||
rs_score: float | None
|
||||
heart_icon: str # 💖 / 🧡 / 💛 / 💙
|
||||
# 放弃原因(仅 abandoned 任务有值)
|
||||
abandon_reason: str | None = None
|
||||
|
||||
|
||||
class AbandonRequest(BaseModel):
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""
|
||||
备注回溯重分类器(Note Reclassifier)
|
||||
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注。
|
||||
查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up →
|
||||
触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务。
|
||||
召回完成后,回溯检查是否有普通备注需重分类为回访备注,并创建回访任务。
|
||||
|
||||
流程:
|
||||
1. 查找 service_time 之后的第一条 normal 备注
|
||||
2. 若找到 → 重分类为 follow_up,任务状态 = completed(回溯完成)
|
||||
3. 若未找到 → 任务状态 = active(等待备注)
|
||||
4. 冲突检查:已有 completed → 跳过;已有 active → 顶替;否则正常创建
|
||||
5. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段
|
||||
|
||||
由 trigger_jobs 中的 note_reclassify_backfill 配置驱动(event: recall_completed)。
|
||||
"""
|
||||
@@ -62,21 +67,27 @@ def ai_analyze_note(note_id: int) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
"""
|
||||
备注回溯主流程。
|
||||
|
||||
payload 包含: {site_id, assistant_id, member_id, service_time}
|
||||
|
||||
1. 查找 biz.notes 中该 (site_id, target_type='member', target_id=member_id)
|
||||
在 service_time 之后提交的第一条 type='normal' 的备注
|
||||
2. 将该备注 type 从 'normal' 更新为 'follow_up'
|
||||
3. 触发 AI 应用 6 接口(P5 实现,本 SPEC 仅定义触发接口):
|
||||
- 调用 ai_analyze_note(note_id) → 返回 ai_score
|
||||
4. 若 ai_score >= 6:
|
||||
- 生成 follow_up_visit 任务,status='completed'(回溯完成)
|
||||
5. 若 ai_score < 6:
|
||||
- 生成 follow_up_visit 任务,status='active'(需助教重新备注)
|
||||
流程:
|
||||
1. 查找 service_time 之后的第一条 normal 备注 → note_id
|
||||
2. 若 note_id 存在:重分类为 follow_up,task_status = 'completed'(回溯完成)
|
||||
3. 若 note_id 不存在:task_status = 'active'(等待备注)
|
||||
4. 保留 ai_analyze_note() 占位调用,返回值仅更新 ai_score 字段
|
||||
5. 冲突检查(T3):
|
||||
- 已有 completed → 跳过创建
|
||||
- 已有 active → 旧任务标记 inactive + superseded 历史,创建新任务
|
||||
- 不存在(或仅 inactive/abandoned)→ 正常创建
|
||||
6. 创建 follow_up_visit 任务
|
||||
|
||||
参数:
|
||||
payload: 事件载荷(由 trigger_scheduler 传入)
|
||||
job_id: 触发器 job ID(由 trigger_scheduler 传入),用于在最终事务中
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 原子提交
|
||||
|
||||
返回: {"reclassified_count": int, "tasks_created": int}
|
||||
"""
|
||||
@@ -119,84 +130,166 @@ def run(payload: dict | None = None) -> dict:
|
||||
note_id = row[0]
|
||||
conn.commit()
|
||||
|
||||
if note_id is None:
|
||||
logger.info(
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
return {"reclassified_count": 0, "tasks_created": 0}
|
||||
|
||||
# ── 2. 将备注 type 从 'normal' 更新为 'follow_up' ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
reclassified_count = 1
|
||||
|
||||
# ── 3. 触发 AI 应用 6 接口(占位,当前返回 None) ──
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
|
||||
# ── 4/5. 根据 ai_score 生成 follow_up_visit 任务 ──
|
||||
if ai_score is not None:
|
||||
if ai_score >= 6:
|
||||
# 回溯完成:生成 completed 任务
|
||||
task_status = "completed"
|
||||
else:
|
||||
# 需助教重新备注:生成 active 任务
|
||||
task_status = "active"
|
||||
|
||||
# ── 2. 根据是否找到备注确定任务状态(T4) ──
|
||||
if note_id is not None:
|
||||
# 找到备注 → 重分类为 follow_up
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
UPDATE biz.notes
|
||||
SET type = 'follow_up', updated_at = NOW()
|
||||
WHERE id = %s AND type = 'normal'
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
(note_id,),
|
||||
)
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
reclassified_count = 1
|
||||
|
||||
# 保留 AI 占位调用,返回值仅用于更新 ai_score 字段
|
||||
ai_score = ai_analyze_note(note_id)
|
||||
if ai_score is not None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.notes
|
||||
SET ai_score = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(ai_score, note_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# 有备注 → 回溯完成
|
||||
task_status = "completed"
|
||||
else:
|
||||
# AI 未就绪,跳过任务创建
|
||||
# 未找到备注 → 等待备注
|
||||
logger.info(
|
||||
"AI 接口未就绪,跳过任务创建: note_id=%s", note_id
|
||||
"未找到符合条件的 normal 备注: site_id=%s, member_id=%s",
|
||||
site_id, member_id,
|
||||
)
|
||||
ai_score = None
|
||||
task_status = "active"
|
||||
|
||||
# ── 3. 冲突检查(T3):查询已有 follow_up_visit 任务 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, status
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
|
||||
AND task_type = 'follow_up_visit'
|
||||
AND status IN ('active', 'completed')
|
||||
ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if existing:
|
||||
existing_id, existing_status = existing
|
||||
if existing_status == "completed":
|
||||
# 已完成 → 跳过创建(回访完成语义已满足)
|
||||
logger.info(
|
||||
"已存在 completed 回访任务 id=%s,跳过创建: "
|
||||
"site_id=%s, assistant_id=%s, member_id=%s",
|
||||
existing_id, site_id, assistant_id, member_id,
|
||||
)
|
||||
# 事务安全(T5):即使跳过创建,handler 仍成功,更新 last_run_at
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import (
|
||||
update_job_last_run_at,
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
update_job_last_run_at(cur, job_id)
|
||||
conn.commit()
|
||||
return {
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": 0,
|
||||
}
|
||||
elif existing_status == "active":
|
||||
# 顶替:旧任务 → inactive + superseded 历史
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(existing_id,),
|
||||
)
|
||||
_insert_history(
|
||||
cur,
|
||||
existing_id,
|
||||
action="superseded",
|
||||
old_status="active",
|
||||
new_status="inactive",
|
||||
detail={
|
||||
"reason": "new_reclassify_task_supersedes",
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"顶替旧 active 回访任务 id=%s → inactive: "
|
||||
"site_id=%s, assistant_id=%s, member_id=%s",
|
||||
existing_id, site_id, assistant_id, member_id,
|
||||
)
|
||||
|
||||
# ── 4. 创建 follow_up_visit 任务 ──
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, assistant_id, member_id, task_type,
|
||||
status, completed_at, completed_task_type)
|
||||
VALUES (
|
||||
%s, %s, %s, 'follow_up_visit',
|
||||
%s,
|
||||
CASE WHEN %s = 'completed' THEN NOW() ELSE NULL END,
|
||||
CASE WHEN %s = 'completed' THEN 'follow_up_visit' ELSE NULL END
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
site_id, assistant_id, member_id,
|
||||
task_status, task_status, task_status,
|
||||
),
|
||||
)
|
||||
new_task_row = cur.fetchone()
|
||||
new_task_id = new_task_row[0]
|
||||
|
||||
# 记录任务创建历史
|
||||
_insert_history(
|
||||
cur,
|
||||
new_task_id,
|
||||
action="created_by_reclassify",
|
||||
old_status=None,
|
||||
new_status=task_status,
|
||||
old_task_type=None,
|
||||
new_task_type="follow_up_visit",
|
||||
detail={
|
||||
"note_id": note_id,
|
||||
"ai_score": ai_score,
|
||||
"source": "note_reclassifier",
|
||||
},
|
||||
)
|
||||
|
||||
# 事务安全(T5):在最终 commit 前更新 last_run_at
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import update_job_last_run_at
|
||||
|
||||
update_job_last_run_at(cur, job_id)
|
||||
|
||||
conn.commit()
|
||||
tasks_created = 1
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -215,3 +308,4 @@ def run(payload: dict | None = None) -> dict:
|
||||
"reclassified_count": reclassified_count,
|
||||
"tasks_created": tasks_created,
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ async def create_note(
|
||||
- 否则 → type='normal'
|
||||
3. INSERT INTO biz.notes
|
||||
4. 若 type='follow_up':
|
||||
- 触发 AI 应用 6 分析(P5 实现)
|
||||
- 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
- 保留 AI 占位调用(P5 接入时调用链不变),返回值仅更新 ai_score
|
||||
- 不论 ai_score 如何,有备注即标记关联 active 回访任务 completed
|
||||
5. 返回创建的备注记录
|
||||
|
||||
注意:星星评分不参与回访完成判定,不参与 AI 分析,仅存储。
|
||||
@@ -171,8 +171,9 @@ async def create_note(
|
||||
"updated_at": row[13].isoformat() if row[13] else None,
|
||||
}
|
||||
|
||||
# 若 type='follow_up',触发 AI 分析并可能标记任务完成
|
||||
# 若 type='follow_up',触发 AI 分析并标记回访任务完成
|
||||
if note_type == "follow_up" and task_id is not None:
|
||||
# 保留 AI 占位调用(P5 接入时调用链不变)
|
||||
ai_score = ai_analyze_note(note["id"])
|
||||
|
||||
if ai_score is not None:
|
||||
@@ -187,32 +188,32 @@ async def create_note(
|
||||
)
|
||||
note["ai_score"] = ai_score
|
||||
|
||||
# 若 ai_score >= 6 且关联任务 status='active' → 标记任务 completed
|
||||
if ai_score >= 6 and task_info and task_info["status"] == "active":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW(),
|
||||
completed_task_type = task_type,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed_by_note",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_info["task_type"],
|
||||
new_task_type=task_info["task_type"],
|
||||
detail={
|
||||
"note_id": note["id"],
|
||||
"ai_score": ai_score,
|
||||
},
|
||||
)
|
||||
# 不论 ai_score 如何,有备注即标记回访任务完成(T4)
|
||||
if task_info and task_info["status"] == "active":
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW(),
|
||||
completed_task_type = task_type,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND status = 'active'
|
||||
""",
|
||||
(task_id,),
|
||||
)
|
||||
_record_history(
|
||||
cur,
|
||||
task_id,
|
||||
action="completed_by_note",
|
||||
old_status="active",
|
||||
new_status="completed",
|
||||
old_task_type=task_info["task_type"],
|
||||
new_task_type=task_info["task_type"],
|
||||
detail={
|
||||
"note_id": note["id"],
|
||||
"ai_score": ai_score,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return note
|
||||
|
||||
@@ -52,7 +52,7 @@ def _insert_history(
|
||||
)
|
||||
|
||||
|
||||
def run(payload: dict | None = None) -> dict:
|
||||
def run(payload: dict | None = None, job_id: int | None = None) -> dict:
|
||||
"""
|
||||
召回完成检测主流程。
|
||||
|
||||
@@ -69,6 +69,11 @@ def run(payload: dict | None = None) -> dict:
|
||||
6. 记录 coach_task_history
|
||||
7. 触发 fire_event('recall_completed', {site_id, assistant_id, member_id, service_time})
|
||||
|
||||
参数:
|
||||
payload: 事件载荷(event 触发时由 trigger_scheduler 传入)
|
||||
job_id: 触发器 job ID(由 trigger_scheduler 传入),用于在最终事务中
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 原子提交
|
||||
|
||||
返回: {"completed_count": int}
|
||||
"""
|
||||
completed_count = 0
|
||||
@@ -111,6 +116,17 @@ def run(payload: dict | None = None) -> dict:
|
||||
)
|
||||
conn.rollback()
|
||||
|
||||
# ── 事务安全(T5):handler 成功后更新 last_run_at ──
|
||||
# job_id 由 trigger_scheduler 传入,在 handler 最终事务中更新
|
||||
# handler 异常时此处不会执行(异常向上传播),last_run_at 不变
|
||||
if job_id is not None:
|
||||
from app.services.trigger_scheduler import update_job_last_run_at
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
update_job_last_run_at(cur, job_id)
|
||||
conn.commit()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -193,7 +209,7 @@ def _process_service_record(
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
|
||||
# 查找匹配的 active 任务
|
||||
# 查找匹配的 active 召回类任务(仅完成召回任务,回访/关系构建不在此处理)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type
|
||||
@@ -202,6 +218,7 @@ def _process_service_record(
|
||||
AND assistant_id = %s
|
||||
AND member_id = %s
|
||||
AND status = 'active'
|
||||
AND task_type IN ('high_priority_recall', 'priority_recall')
|
||||
""",
|
||||
(site_id, assistant_id, member_id),
|
||||
)
|
||||
|
||||
@@ -314,22 +314,55 @@ class TaskExecutor:
|
||||
async def cancel(self, execution_id: str) -> bool:
|
||||
"""向子进程发送终止信号。
|
||||
|
||||
如果进程仍在内存中,发送 terminate 信号;
|
||||
如果进程已不在内存中(如后端重启后),但数据库中仍为 running,
|
||||
则直接将数据库状态标记为 cancelled(幽灵记录兜底)。
|
||||
|
||||
Returns:
|
||||
True 表示成功发送终止信号,False 表示进程不存在或已退出。
|
||||
True 表示成功取消,False 表示任务不存在或已完成。
|
||||
"""
|
||||
proc = self._processes.get(execution_id)
|
||||
if proc is None:
|
||||
return False
|
||||
# subprocess.Popen: poll() 返回 None 表示仍在运行
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
if proc is not None:
|
||||
# 进程仍在内存中
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
logger.info("取消 ETL 子进程 [%s], pid=%s", execution_id, proc.pid)
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
return True
|
||||
|
||||
logger.info("取消 ETL 子进程 [%s], pid=%s", execution_id, proc.pid)
|
||||
# 进程不在内存中(后端重启等场景),尝试兜底修正数据库幽灵记录
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
return True
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_execution_log
|
||||
SET status = 'cancelled',
|
||||
finished_at = NOW(),
|
||||
error_log = COALESCE(error_log, '')
|
||||
|| E'\n[cancel 兜底] 进程已不在内存中,标记为 cancelled'
|
||||
WHERE id = %s AND status = 'running'
|
||||
""",
|
||||
(execution_id,),
|
||||
)
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if updated:
|
||||
logger.info(
|
||||
"兜底取消 execution_log [%s]:数据库状态从 running → cancelled",
|
||||
execution_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("兜底取消 execution_log [%s] 失败", execution_id)
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 数据库操作(同步,在线程池中执行也可,此处简单直连)
|
||||
|
||||
@@ -121,13 +121,13 @@ def _verify_task_ownership(
|
||||
|
||||
async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
"""
|
||||
获取助教的活跃任务列表。
|
||||
获取助教的任务列表(含有效 + 已放弃)。
|
||||
|
||||
1. 通过 auth.user_assistant_binding 获取 assistant_id
|
||||
2. 查询 biz.coach_tasks WHERE status='active'
|
||||
2. 查询 biz.coach_tasks WHERE status IN ('active', 'abandoned')
|
||||
3. 通过 FDW 读取客户基本信息(dim_member)和 RS 指数
|
||||
4. 计算爱心 icon 档位
|
||||
5. 排序:is_pinned DESC, priority_score DESC, created_at ASC
|
||||
5. 排序:abandoned 排最后 → is_pinned DESC → priority_score DESC → created_at ASC
|
||||
|
||||
FDW 查询需要 SET LOCAL app.current_site_id。
|
||||
"""
|
||||
@@ -135,17 +135,21 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
try:
|
||||
assistant_id = _get_assistant_id(conn, user_id, site_id)
|
||||
|
||||
# 查询活跃任务
|
||||
# 查询有效 + 已放弃任务(abandoned 排最后)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, task_type, status, priority_score, is_pinned,
|
||||
expires_at, created_at, member_id
|
||||
expires_at, created_at, member_id, abandon_reason
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
AND assistant_id = %s
|
||||
AND status = 'active'
|
||||
ORDER BY is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
|
||||
AND status IN ('active', 'abandoned')
|
||||
ORDER BY
|
||||
CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC,
|
||||
is_pinned DESC,
|
||||
priority_score DESC NULLS LAST,
|
||||
created_at ASC
|
||||
""",
|
||||
(site_id, assistant_id),
|
||||
)
|
||||
@@ -201,7 +205,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
result = []
|
||||
for task_row in tasks:
|
||||
(task_id, task_type, status, priority_score,
|
||||
is_pinned, expires_at, created_at, member_id) = task_row
|
||||
is_pinned, expires_at, created_at, member_id, abandon_reason) = task_row
|
||||
|
||||
info = member_info_map.get(member_id, {})
|
||||
rs_score = rs_map.get(member_id, Decimal("0"))
|
||||
@@ -220,6 +224,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
"member_phone": info.get("member_phone"),
|
||||
"rs_score": float(rs_score),
|
||||
"heart_icon": heart_icon,
|
||||
"abandon_reason": abandon_reason,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -372,6 +377,7 @@ async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'active',
|
||||
is_pinned = FALSE,
|
||||
abandon_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
@@ -389,7 +395,7 @@ async def cancel_abandon(task_id: int, user_id: int, site_id: int) -> dict:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": task_id, "status": "active"}
|
||||
return {"id": task_id, "status": "active", "is_pinned": False}
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -366,6 +366,9 @@ class TaskQueue:
|
||||
|
||||
async def _process_once(self, executor: Any) -> None:
|
||||
"""单次处理:扫描所有门店的 pending 队列并执行。"""
|
||||
# CHANGE 2026-03-09 | 每次轮询先回收僵尸 running 任务
|
||||
self._recover_zombie_tasks()
|
||||
|
||||
site_ids = self._get_pending_site_ids()
|
||||
|
||||
for site_id in site_ids:
|
||||
@@ -415,6 +418,13 @@ class TaskQueue:
|
||||
except Exception:
|
||||
logger.exception("队列任务执行异常 [%s]", queue_id)
|
||||
self._mark_failed(queue_id, "执行过程中发生未捕获异常")
|
||||
finally:
|
||||
# CHANGE 2026-03-09 | 兜底:确保 task_queue 不会卡在 running
|
||||
# 背景:_update_execution_log 内部异常(如 duration_ms integer 溢出)
|
||||
# 被吞掉后,_update_queue_status_from_log 读到的 execution_log 仍是
|
||||
# running,导致 task_queue 永远卡住,后续任务全部排队。
|
||||
self._ensure_not_stuck_running(queue_id)
|
||||
|
||||
|
||||
def _get_pending_site_ids(self) -> list[int]:
|
||||
"""获取所有有 pending 任务的 site_id 列表(仅限本实例入队的)。"""
|
||||
@@ -484,6 +494,84 @@ class TaskQueue:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _ensure_not_stuck_running(self, queue_id: str) -> None:
|
||||
"""兜底检查:如果 task_queue 仍是 running,强制标记 failed。
|
||||
|
||||
CHANGE 2026-03-09 | 防止 _update_execution_log 内部异常导致
|
||||
task_queue 永远卡在 running 状态。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status FROM task_queue WHERE id = %s",
|
||||
(queue_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] == "running":
|
||||
logger.warning(
|
||||
"兜底修正:task_queue [%s] 执行完毕但仍为 running,"
|
||||
"强制标记 failed",
|
||||
queue_id,
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_queue
|
||||
SET status = 'failed', finished_at = NOW(),
|
||||
error_message = %s
|
||||
WHERE id = %s AND status = 'running'
|
||||
""",
|
||||
(
|
||||
"[兜底修正] 执行流程结束但状态未同步,"
|
||||
"可能因 execution_log 更新失败",
|
||||
queue_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_ensure_not_stuck_running 异常 [%s]", queue_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _recover_zombie_tasks(self, max_running_minutes: int = 180) -> None:
|
||||
"""恢复僵尸 running 任务:超过阈值时间仍为 running 的任务强制标记 failed。
|
||||
|
||||
CHANGE 2026-03-09 | 在 process_loop 每次轮询时调用,作为最后防线。
|
||||
场景:后端进程崩溃/重启后,之前的 running 任务永远不会被更新。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE task_queue
|
||||
SET status = 'failed', finished_at = NOW(),
|
||||
error_message = %s
|
||||
WHERE status = 'running'
|
||||
AND (enqueued_by = %s OR enqueued_by IS NULL)
|
||||
AND started_at < NOW() - INTERVAL '%s minutes'
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"[僵尸回收] running 超过 {max_running_minutes} 分钟,"
|
||||
"自动标记 failed",
|
||||
_INSTANCE_ID,
|
||||
max_running_minutes,
|
||||
),
|
||||
)
|
||||
recovered = cur.fetchall()
|
||||
if recovered:
|
||||
ids = [r[0] for r in recovered]
|
||||
logger.warning(
|
||||
"僵尸回收:%d 个 running 任务超时,已标记 failed: %s",
|
||||
len(ids), ids,
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("_recover_zombie_tasks 异常")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 生命周期
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -86,6 +86,9 @@ DWS_TASKS: list[TaskDefinition] = [
|
||||
TaskDefinition("DWS_ASSISTANT_FINANCE", "助教财务汇总", "汇总助教财务数据", "助教", "DWS"),
|
||||
TaskDefinition("DWS_MEMBER_CONSUMPTION", "会员消费分析", "汇总会员消费数据", "会员", "DWS"),
|
||||
TaskDefinition("DWS_MEMBER_VISIT", "会员到店分析", "汇总会员到店频次", "会员", "DWS"),
|
||||
# CHANGE [2026-03-09] intent: 注册项目标签任务,与 ETL 侧 task_registry 同步;全量重建不依赖日期窗口
|
||||
TaskDefinition("DWS_ASSISTANT_PROJECT_TAG", "助教项目标签", "按时间窗口计算助教各项目时长占比标签", "助教", "DWS", requires_window=False),
|
||||
TaskDefinition("DWS_MEMBER_PROJECT_TAG", "客户项目标签", "按时间窗口计算客户各项目消费时长占比标签", "会员", "DWS", requires_window=False),
|
||||
TaskDefinition("DWS_FINANCE_DAILY", "财务日报", "汇总每日财务数据", "财务", "DWS"),
|
||||
TaskDefinition("DWS_FINANCE_RECHARGE", "充值汇总", "汇总充值数据", "财务", "DWS"),
|
||||
TaskDefinition("DWS_FINANCE_INCOME_STRUCTURE", "收入结构", "分析收入结构", "财务", "DWS"),
|
||||
|
||||
@@ -31,6 +31,20 @@ def register_job(job_type: str, handler: Callable) -> None:
|
||||
_JOB_REGISTRY[job_type] = handler
|
||||
|
||||
|
||||
def update_job_last_run_at(cur, job_id: int) -> None:
|
||||
"""
|
||||
在 handler 的事务内更新 last_run_at。
|
||||
|
||||
handler 在最终 commit 前调用此函数,将 last_run_at 更新纳入同一事务。
|
||||
handler 成功 → last_run_at 随事务一起 commit。
|
||||
handler 失败 → last_run_at 随事务一起 rollback。
|
||||
"""
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
|
||||
def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
"""
|
||||
触发事件驱动型任务。
|
||||
@@ -38,6 +52,10 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
查找 trigger_condition='event' 且 trigger_config.event_name 匹配的 enabled job,
|
||||
立即执行对应的 handler。
|
||||
|
||||
事务安全:将 job_id 传入 handler,由 handler 在最终 commit 前
|
||||
更新 last_run_at,保证 handler 数据变更与 last_run_at 在同一事务中。
|
||||
handler 失败时整个事务回滚,last_run_at 不更新。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
@@ -55,6 +73,7 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
(event_name,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
for job_id, job_type, job_name in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
@@ -64,18 +83,11 @@ def fire_event(event_name: str, payload: dict[str, Any] | None = None) -> int:
|
||||
)
|
||||
continue
|
||||
try:
|
||||
handler(payload=payload)
|
||||
# 将 job_id 传入 handler,handler 在最终 commit 前更新 last_run_at
|
||||
handler(payload=payload, job_id=job_id)
|
||||
executed += 1
|
||||
# 更新 last_run_at
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
|
||||
(job_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -87,6 +99,11 @@ def check_scheduled_jobs() -> int:
|
||||
检查 cron/interval 类型的到期 job 并执行。
|
||||
|
||||
由 Scheduler 后台循环调用。
|
||||
|
||||
事务安全:将 conn 和 job_id 传入 handler,由 handler 在最终 commit 前
|
||||
更新 last_run_at 和 next_run_at,保证 handler 数据变更与时间戳在同一事务中。
|
||||
handler 失败时整个事务回滚。
|
||||
|
||||
返回: 执行的 job 数量
|
||||
"""
|
||||
conn = _get_connection()
|
||||
@@ -104,6 +121,7 @@ def check_scheduled_jobs() -> int:
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
for job_id, job_type, job_name, trigger_condition, trigger_config in rows:
|
||||
handler = _JOB_REGISTRY.get(job_type)
|
||||
@@ -111,11 +129,12 @@ def check_scheduled_jobs() -> int:
|
||||
logger.warning("未注册的 job_type: %s", job_type)
|
||||
continue
|
||||
try:
|
||||
handler()
|
||||
executed += 1
|
||||
# 计算 next_run_at 并更新
|
||||
# cron/interval handler 接受 conn + job_id,在最终 commit 前更新时间戳
|
||||
handler(conn=conn, job_id=job_id)
|
||||
# 计算 next_run_at 并更新(在 handler commit 后的新事务中)
|
||||
next_run = _calculate_next_run(trigger_condition, trigger_config)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.trigger_jobs
|
||||
@@ -125,6 +144,7 @@ def check_scheduled_jobs() -> int:
|
||||
(next_run, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
executed += 1
|
||||
except Exception:
|
||||
logger.exception("触发器 %s 执行失败", job_name)
|
||||
conn.rollback()
|
||||
@@ -156,6 +176,6 @@ def _calculate_next_run(
|
||||
from apps.backend.app.services.scheduler import _parse_simple_cron
|
||||
|
||||
return _parse_simple_cron(
|
||||
trigger_config.get("cron_expression", "0 4 * * *"), now
|
||||
trigger_config.get("cron_expression", "0 7 * * *"), now
|
||||
)
|
||||
return None # event 类型无 next_run_at
|
||||
|
||||
@@ -34,3 +34,4 @@ dev = [
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -132,7 +132,7 @@ class TestDequeue:
|
||||
config_dict = {"tasks": ["ODS_MEMBER"], "flow": "api_ods"}
|
||||
row = (
|
||||
task_id, 42, json.dumps(config_dict), "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
conn = _mock_conn(cur)
|
||||
@@ -152,7 +152,7 @@ class TestDequeue:
|
||||
config_dict = {"tasks": ["ODS_MEMBER"], "flow": "api_ods"}
|
||||
row = (
|
||||
task_id, 42, json.dumps(config_dict), "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
conn = _mock_conn(cur)
|
||||
@@ -322,14 +322,17 @@ class TestProcessLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_once_skips_when_running(self, mock_get_conn, queue):
|
||||
"""有 running 任务时不 dequeue"""
|
||||
# _get_pending_site_ids 返回 [42]
|
||||
# has_running(42) 返回 True
|
||||
# 调用顺序:_recover_zombie_tasks → _get_pending_site_ids → has_running
|
||||
call_count = 0
|
||||
|
||||
def side_effect_conn():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
# _get_pending_site_ids
|
||||
cur = _mock_cursor(fetchall_val=[(42,)])
|
||||
return _mock_conn(cur)
|
||||
@@ -372,10 +375,14 @@ class TestProcessLoop:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
# _get_pending_site_ids
|
||||
cur = _mock_cursor(fetchall_val=[(42,)])
|
||||
return _mock_conn(cur)
|
||||
elif call_count == 2:
|
||||
elif call_count == 3:
|
||||
# has_running → False
|
||||
cur = _mock_cursor(fetchone_val=(False,))
|
||||
return _mock_conn(cur)
|
||||
@@ -383,7 +390,7 @@ class TestProcessLoop:
|
||||
# dequeue → 返回任务
|
||||
row = (
|
||||
task_id, 42, config_json, "pending", 1,
|
||||
None, None, None, None, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
cur = _mock_cursor(fetchone_val=row)
|
||||
return _mock_conn(cur)
|
||||
@@ -402,9 +409,21 @@ class TestProcessLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_once_no_pending(self, mock_get_conn, queue):
|
||||
"""无 pending 任务时什么都不做"""
|
||||
cur = _mock_cursor(fetchall_val=[])
|
||||
conn = _mock_conn(cur)
|
||||
mock_get_conn.return_value = conn
|
||||
call_count = 0
|
||||
|
||||
def side_effect_conn():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# _recover_zombie_tasks(无僵尸任务)
|
||||
cur = _mock_cursor()
|
||||
return _mock_conn(cur)
|
||||
else:
|
||||
# _get_pending_site_ids → 空
|
||||
cur = _mock_cursor(fetchall_val=[])
|
||||
return _mock_conn(cur)
|
||||
|
||||
mock_get_conn.side_effect = side_effect_conn
|
||||
|
||||
mock_executor = MagicMock()
|
||||
await queue._process_once(mock_executor)
|
||||
|
||||
@@ -26,7 +26,7 @@ SCHEMA_ETL=meta
|
||||
# API 配置(上游 SaaS API)
|
||||
# ------------------------------------------------------------------------------
|
||||
API_BASE=https://pc.ficoo.vip/apiprod/admin/v1/
|
||||
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IlI5THQvRkVjSGZubkdiOTZJZ3lmdWhjaXU5WnIwREQrZFh1amhVY1RCSDQ9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzMvMTEg5LiL5Y2INjo0MjozMSIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzMyMjU3NTEsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.8H5V3W0NfGJrcYo9Ex-35D-SzxhC2tRaZGrgo2reYr4
|
||||
API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQtdHlwZSI6IjQiLCJ1c2VyLXR5cGUiOiIxIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiMTIiLCJyb2xlLWlkIjoiMTIiLCJ0ZW5hbnQtaWQiOiIyNzkwNjgzMTYwNzA5OTU3Iiwibmlja25hbWUiOiLnp5_miLfnrqHnkIblkZjvvJrmganmgakxIiwic2l0ZS1pZCI6IjAiLCJtb2JpbGUiOiIxMzgxMDUwMjMwNCIsInNpZCI6IjI5NTA0ODk2NTgzOTU4NDUiLCJzdGFmZi1pZCI6IjMwMDk5MTg2OTE1NTkwNDUiLCJvcmctaWQiOiIwIiwicm9sZS10eXBlIjoiMyIsInJlZnJlc2hUb2tlbiI6IncxTkhzalRIeHU1b0Ric0hnQXp6SUgrU2Q2d2M3YndUTTU1ZTZnSXg0RTQ9IiwicmVmcmVzaEV4cGlyeVRpbWUiOiIyMDI2LzMvMTkg5LiK5Y2IMToxNzowMyIsIm5lZWRDaGVja1Rva2VuIjoiZmFsc2UiLCJleHAiOjE3NzM4NTQyMjMsImlzcyI6InRlc3QiLCJhdWQiOiJVc2VyIn0.E7oh3g_-3fyeC1-oHsJXZ-tTGqcvUCMDrd9TifJAs4U
|
||||
API_TIMEOUT=20
|
||||
API_PAGE_SIZE=200
|
||||
API_RETRY_MAX=3
|
||||
|
||||
@@ -58,10 +58,11 @@ class DatabaseConnection:
|
||||
c.execute(sql, args)
|
||||
return c.fetchall()
|
||||
|
||||
def execute(self, sql: str, args=None):
|
||||
"""Execute a SQL statement without returning rows."""
|
||||
def execute(self, sql: str, args=None) -> int:
|
||||
"""Execute a SQL statement without returning rows. Returns rowcount."""
|
||||
with self.conn.cursor() as c:
|
||||
c.execute(sql, args)
|
||||
return c.rowcount
|
||||
|
||||
def commit(self):
|
||||
"""Commit current transaction."""
|
||||
|
||||
@@ -119,9 +119,9 @@ class DatabaseOperations:
|
||||
"""执行查询并返回结果"""
|
||||
return self._connection.query(sql, args)
|
||||
|
||||
def execute(self, sql: str, args=None):
|
||||
"""执行任意 SQL"""
|
||||
self._connection.execute(sql, args)
|
||||
def execute(self, sql: str, args=None) -> int:
|
||||
"""执行任意 SQL,返回 rowcount"""
|
||||
return self._connection.execute(sql, args)
|
||||
|
||||
def cursor(self):
|
||||
"""暴露原生 cursor,供特殊操作使用"""
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
| 10 | settle_type | INTEGER | YES | | 结账类型。**枚举值**: 1=台桌结账, 3=商城订单, 6=退货订单, 7=退款订单 |
|
||||
| 11 | revoke_order_id | BIGINT | YES | | 撤销订单 ID(当前数据全为 0) |
|
||||
| 12 | member_id | BIGINT | YES | | 会员 ID → dim_member(0=散客,占比约 82.8%) |
|
||||
| 13 | member_name | VARCHAR(100) | YES | | 会员名称 |
|
||||
| 14 | member_phone | VARCHAR(50) | YES | | 会员电话 |
|
||||
| 15 | member_card_account_id | BIGINT | YES | | 会员卡账户 ID(当前数据全为 0) |
|
||||
| 16 | member_card_type_name | VARCHAR(100) | YES | | 卡类型名称(当前数据全为空) |
|
||||
| 13 | member_name | VARCHAR(100) | YES | | 会员名称。⚠️ **DQ-6 断档**:2025-12 起全为 NULL,需通过 `member_id` JOIN `dim_member.nickname` 获取 |
|
||||
| 14 | member_phone | VARCHAR(50) | YES | | 会员电话。⚠️ **DQ-6 断档**:2025-12 起全为 NULL,需通过 `member_id` JOIN `dim_member.mobile` 获取(`scd2_is_current=1`) |
|
||||
| 15 | member_card_account_id | BIGINT | YES | | 会员卡账户 ID。⚠️ **DQ-7 断档**:全为 0,需通过 `member_id` JOIN `dim_member_card_account.tenant_member_id` 获取(`scd2_is_current=1`) |
|
||||
| 16 | member_card_type_name | VARCHAR(100) | YES | | 卡类型名称。⚠️ **DQ-7 断档**:2025-07-21 起全为 NULL,需通过 `member_id` JOIN `dim_member_card_account` 获取 |
|
||||
| 17 | is_bind_member | BOOLEAN | YES | | 是否绑定会员。**枚举值**: False=否 |
|
||||
| 18 | member_discount_amount | NUMERIC(18,2) | YES | | 会员折扣金额 |
|
||||
| 19 | consume_money | NUMERIC(18,2) | YES | | 消费总金额(元)。⚠️ **口径不稳定**:存在三种历史口径(A/B/C),DWS 层不应直接使用,应使用 `items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money`。详见 [consume_money 口径](../../../../docs/reports/DWD-DOC/consume/consume-money-caliber.md) |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# BD 手册:dws_assistant_project_tag / dws_member_project_tag(项目标签表)
|
||||
|
||||
> 变更日期:2026-03-12
|
||||
> 变更类型:补充任务注册(seed 脚本遗漏修复)
|
||||
|
||||
## 变更说明
|
||||
|
||||
两张项目标签表(`dws.dws_assistant_project_tag`、`dws.dws_member_project_tag`)于 2026-03-07 创建,代码层(task class + TaskRegistry)已完整实现,但 `db/etl_feiqiu/seeds/seed_scheduler_tasks.sql` 遗漏了这两个任务码,导致 `meta.etl_task` 表中无记录,调度时报"未启用或不存在"。
|
||||
|
||||
本次修复:
|
||||
- 在 seed 脚本中补充 `DWS_ASSISTANT_PROJECT_TAG` 和 `DWS_MEMBER_PROJECT_TAG`
|
||||
- 在测试库 `test_etl_feiqiu` 的 `meta.etl_task` 表中插入两条记录(`enabled = TRUE`)
|
||||
|
||||
## 表概览
|
||||
|
||||
| 表名 | Schema | 粒度 | 时间窗口数 | 数据来源 |
|
||||
|------|--------|------|-----------|----------|
|
||||
| `dws_assistant_project_tag` | dws | 助教 + 时间窗口 + 项目类型 | 6 | `dwd_assistant_service_log` + `dim_table` + `cfg_area_category` |
|
||||
| `dws_member_project_tag` | dws | 会员 + 时间窗口 + 项目类型 | 2 | `dwd_table_fee_log` + `dim_table` + `cfg_area_category` |
|
||||
|
||||
两表共享相同的标签计算逻辑:按时间窗口聚合各项目时长,计算占比,≥25% 分配标签。
|
||||
|
||||
## 兼容性影响
|
||||
|
||||
- ETL 调度:修复后两个任务可正常被 admin 后台调度执行
|
||||
- 后端 API:无影响(API 层直接查询 DWS 表,表结构未变)
|
||||
- 小程序/管理后台:无影响(前端查询不依赖 `meta.etl_task`)
|
||||
- 其他 ETL 任务:无影响(两个任务独立运行,不被其他任务依赖)
|
||||
|
||||
## 回滚策略
|
||||
|
||||
```sql
|
||||
-- 1. 从 meta.etl_task 移除注册
|
||||
DELETE FROM meta.etl_task
|
||||
WHERE task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG');
|
||||
|
||||
-- 2. 还原 seed 脚本:从 task_codes 数组中移除这两行
|
||||
-- 'DWS_ASSISTANT_PROJECT_TAG',
|
||||
-- 'DWS_MEMBER_PROJECT_TAG',
|
||||
```
|
||||
|
||||
## 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认 meta.etl_task 中两个任务已注册且启用
|
||||
SELECT task_code, store_id, enabled, created_at
|
||||
FROM meta.etl_task
|
||||
WHERE task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG');
|
||||
-- 期望:2 行,enabled = TRUE
|
||||
|
||||
-- 2. 确认 seed 脚本与 task_registry 一致(无遗漏)
|
||||
-- 手动比对 seed_scheduler_tasks.sql 中的 task_codes 与 task_registry.py 中的注册列表
|
||||
|
||||
-- 3. 确认两张 DWS 表存在且结构正确
|
||||
SELECT table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'dws'
|
||||
AND table_name IN ('dws_assistant_project_tag', 'dws_member_project_tag')
|
||||
ORDER BY table_name, ordinal_position;
|
||||
-- 期望:每张表 15 个字段
|
||||
|
||||
-- 4. 确认调度可正常执行(在 admin 后台触发后检查)
|
||||
SELECT task_code, status, started_at, finished_at
|
||||
FROM meta.etl_run r
|
||||
JOIN meta.etl_task t ON r.task_id = t.task_id
|
||||
WHERE t.task_code IN ('DWS_ASSISTANT_PROJECT_TAG', 'DWS_MEMBER_PROJECT_TAG')
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT 4;
|
||||
```
|
||||
|
||||
## 详细 BD 手册
|
||||
|
||||
两张表的完整字段说明、索引、数据链路、业务规则见模块级 BD 手册:
|
||||
- `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_assistant_project_tag.md`
|
||||
- `apps/etl/connectors/feiqiu/docs/database/DWS/main/BD_manual_dws_member_project_tag.md`
|
||||
|
||||
ETL 任务详细说明见:
|
||||
- `apps/etl/connectors/feiqiu/docs/etl_tasks/dws_tasks.md`(项目标签域章节)
|
||||
@@ -1,6 +1,6 @@
|
||||
# cfg_area_category 台区分类映射表
|
||||
|
||||
> 生成时间:2026-02-03 | 更新时间:2026-03-07
|
||||
> 生成时间:2026-02-03 | 更新时间:2026-03-09
|
||||
|
||||
## 表信息
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
| 表名 | cfg_area_category |
|
||||
| 主键 | category_id |
|
||||
| 唯一约束 | (source_area_name, COALESCE(source_table_name, '')) |
|
||||
| 数据来源 | 手工维护/seed脚本(基于dim_table实际数据) |
|
||||
| 说明 | 将dim_table的台区/台桌映射到项目分类,支持台桌级细分 |
|
||||
| 数据来源 | 手工维护/seed脚本(基于用户提供的完整台桌清单) |
|
||||
| 说明 | 纯台桌级精确映射,每台桌一行 (区域, 台桌名) → 分类代码 |
|
||||
|
||||
## 字段说明
|
||||
|
||||
@@ -19,47 +19,124 @@
|
||||
|------|--------|------|------|------|------|
|
||||
| 1 | category_id | SERIAL | NO | PK | 分类ID(自增) |
|
||||
| 2 | source_area_name | VARCHAR(100) | NO | UK | 源区域名称(来自dim_table.site_table_area_name) |
|
||||
| 3 | source_table_name | VARCHAR(100) | YES | UK | 源台桌名称(来自dim_table.table_name),NULL表示区域级映射 |
|
||||
| 3 | source_table_name | VARCHAR(100) | YES | UK | 源台桌名称(来自dim_table.table_name),NULL仅用于DEFAULT兜底 |
|
||||
| 4 | category_code | VARCHAR(20) | NO | | 分类代码。**枚举值**: BILLIARD, SNOOKER, MAHJONG, KTV, SPECIAL, OTHER |
|
||||
| 5 | category_name | VARCHAR(50) | NO | | 分类名称(含emoji) |
|
||||
| 6 | display_name | VARCHAR(50) | YES | | 显示名称(用于筛选器) |
|
||||
| 7 | short_name | VARCHAR(20) | YES | | 简写(用于列表标签) |
|
||||
| 8 | match_type | VARCHAR(10) | NO | | 匹配类型。**枚举值**: EXACT(精确), LIKE(模糊), DEFAULT(兜底) |
|
||||
| 9 | match_priority | INTEGER | NO | | 匹配优先级(数字越小优先级越高) |
|
||||
| 8 | match_type | VARCHAR(10) | NO | | 匹配类型。**枚举值**: EXACT(精确), DEFAULT(兜底)。LIKE 已废弃 |
|
||||
| 9 | match_priority | INTEGER | NO | | 匹配优先级(统一为10,兜底999) |
|
||||
| 10 | is_active | BOOLEAN | NO | | 是否启用 |
|
||||
| 11 | description | TEXT | YES | | 说明 |
|
||||
| 12 | created_at | TIMESTAMPTZ | NO | | 创建时间 |
|
||||
| 13 | updated_at | TIMESTAMPTZ | NO | | 更新时间 |
|
||||
|
||||
## 变更说明(2026-03-07)
|
||||
## 变更说明(2026-03-09)
|
||||
|
||||
### 新增字段
|
||||
- `source_table_name`:支持台桌级细分映射(如 VIP包厢 V5 → SNOOKER)
|
||||
- `display_name`:前端筛选器显示名称
|
||||
- `short_name`:列表中的简写标签
|
||||
### 架构变更:纯台桌级精确映射
|
||||
- 删除所有 LIKE 模糊匹配规则(%VIP%、%斯诺克%、%麻将%、%K包%、%KTV%)
|
||||
- 删除所有区域级映射(source_table_name IS NULL 的 EXACT 记录)
|
||||
- 改为每台桌一行精确映射:source_area_name=区域, source_table_name=台桌名
|
||||
- 仅保留 DEFAULT 兜底规则(source_table_name IS NULL)
|
||||
|
||||
### 删除类型
|
||||
- `BILLIARD_VIP` 已废弃,VIP包厢 V1-V4 归入 `BILLIARD`,V5 归入 `SNOOKER`
|
||||
### 新增台桌
|
||||
- KTV:888、常乐、幸会(纯k)、虚拟188、大包、小包、纯k
|
||||
- MAHJONG:董事办、大包麻将房、666(台桌)
|
||||
- BILLIARD:VIP1-VIP3(台桌级)、TV、A1-A18、B1-B15、C1-C6
|
||||
- SNOOKER:S1-S4、VIP5
|
||||
- SPECIAL:补时长2-7、虚拟台1号
|
||||
|
||||
### 唯一约束变更
|
||||
- 从 `(source_area_name)` 改为 `(source_area_name, COALESCE(source_table_name, ''))`
|
||||
### ETL 代码变更
|
||||
- `get_area_category()` 去掉区域级精确匹配和 LIKE 模糊匹配分支
|
||||
- 仅保留台桌级精确匹配 + DEFAULT 兜底
|
||||
|
||||
## 匹配优先级
|
||||
## 匹配规则
|
||||
|
||||
| 优先级 | 匹配方式 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 5 | 台桌级精确 | source_area_name + source_table_name 都匹配 |
|
||||
| 10 | 区域级精确 | source_area_name 匹配,source_table_name 为 NULL |
|
||||
| 50 | 模糊匹配 | source_area_name 包含模式匹配 |
|
||||
| 999 | 兜底 | 无法匹配的区域归入 OTHER |
|
||||
| 10 | 台桌级精确 | source_area_name + source_table_name 都匹配 |
|
||||
| 999 | 兜底 | 无法匹配的归入 OTHER |
|
||||
|
||||
## 分类映射
|
||||
## 分类映射(完整台桌清单)
|
||||
|
||||
| 分类代码 | 显示名称 | 简写 | 源区域 |
|
||||
|----------|---------|------|--------|
|
||||
| BILLIARD | 🎱 中式/追分 | 🎱 | A区、B区、C区、TV台、VIP包厢(V1-V4) |
|
||||
| SNOOKER | 斯诺克 | 斯 | 斯诺克区、VIP包厢(V5) |
|
||||
| MAHJONG | 🀄 麻将/棋牌 | 🀄 | 麻将房、M7、M8、666、发财 |
|
||||
| KTV | 🎤 团建/K歌 | 🎤 | K包、k包活动区、幸会158 |
|
||||
| SPECIAL | 补时长 | 补 | 补时长 |
|
||||
| OTHER | 其他 | 他 | 兜底 |
|
||||
### BILLIARD 🎱 中式/追分(43台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| A区 | A1-A18(18台) |
|
||||
| B区 | B1-B15(15台) |
|
||||
| C区 | C1-C6(6台) |
|
||||
| VIP包厢 | VIP1, VIP2, VIP3 |
|
||||
| TV台 | TV |
|
||||
|
||||
### SNOOKER 斯诺克(5台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 斯诺克区 | S1, S2, S3, S4 |
|
||||
| VIP包厢 | VIP5 |
|
||||
|
||||
### MAHJONG 🀄 麻将/棋牌(11台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 麻将房 | M1, M2, M3, M4, M5 |
|
||||
| M7 | M7, 大包麻将房 |
|
||||
| M8 | M8 |
|
||||
| 666 | 董事办, 666 |
|
||||
| 发财 | 发财 |
|
||||
|
||||
### KTV 🎤 团建/K歌(7台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| K包 | 常乐, 幸会(纯k), 虚拟188, 888 |
|
||||
| k包活动区 | 大包, 小包 |
|
||||
| 幸会158 | 纯k |
|
||||
|
||||
### SPECIAL 补时长(8台)
|
||||
| 区域 | 台桌 |
|
||||
|------|------|
|
||||
| 补时长 | 补时长, 补时长2-7 |
|
||||
| 虚拟台 | 虚拟台1号 |
|
||||
|
||||
### OTHER 其他
|
||||
| 匹配 | 说明 |
|
||||
|------|------|
|
||||
| DEFAULT | 兜底规则,无法匹配的归入其他 |
|
||||
|
||||
## 历史变更
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-02-03 | 初始创建,区域级精确 + LIKE 模糊匹配 |
|
||||
| 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP |
|
||||
| 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 |
|
||||
|
||||
## 验证 SQL
|
||||
|
||||
```sql
|
||||
-- 1. 确认无 LIKE 匹配记录
|
||||
SELECT COUNT(*) AS like_count FROM dws.cfg_area_category WHERE match_type = 'LIKE';
|
||||
-- 期望: 0
|
||||
|
||||
-- 2. 确认所有 EXACT 记录都有 source_table_name
|
||||
SELECT COUNT(*) AS null_table_count
|
||||
FROM dws.cfg_area_category
|
||||
WHERE match_type = 'EXACT' AND source_table_name IS NULL;
|
||||
-- 期望: 0
|
||||
|
||||
-- 3. 确认 KTV 大类包含 888
|
||||
SELECT source_area_name, source_table_name, category_code
|
||||
FROM dws.cfg_area_category
|
||||
WHERE category_code = 'KTV' AND is_active = true
|
||||
ORDER BY source_area_name, source_table_name;
|
||||
-- 期望: 7 行(常乐, 幸会(纯k), 虚拟188, 888, 大包, 小包, 纯k)
|
||||
|
||||
-- 4. 确认总记录数
|
||||
SELECT COUNT(*) AS total FROM dws.cfg_area_category;
|
||||
-- 期望: 75(74 台桌 + 1 DEFAULT 兜底)
|
||||
|
||||
-- 5. 按分类汇总
|
||||
SELECT category_code, COUNT(*) AS cnt
|
||||
FROM dws.cfg_area_category
|
||||
WHERE match_type = 'EXACT'
|
||||
GROUP BY category_code ORDER BY category_code;
|
||||
-- 期望: BILLIARD=43, KTV=7, MAHJONG=11, SNOOKER=5, SPECIAL=8
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user