feat: 小程序端 UI 优化与纯客户端化改造

- 移除微信订阅推送功能(云函数/订阅管理页),回归纯客户端零后端架构
- 新增今日黄历默认展示卡片 TodayAlmanacCard(9 语言翻译)
- i18n 修复:启用 globalInjection 修复 $t 未注入,插值消息全部改为
  Messages Functions 适配小程序端(63 处)
- 修复底部导航切换失效与运势页数据不刷新
- 统一设计令牌(深褐主色 + 印红强调色),组件 UI 规范重构
- 图标 iconfont 化(FontAwesome 子集化,修复 Android 豆腐块)
- 新增 postcss-px2rpx 机型自适应(仅 mp-weixin 构建生效)
- 新增 5 个用户旅程 E2E 测试(31 用例)与验收报告
- 补充微信小程序项目配置(appid)
This commit is contained in:
2026-08-28 07:51:02 +08:00
parent de11398720
commit 810081ac0a
72 changed files with 2242 additions and 1468 deletions
+8
View File
@@ -201,6 +201,14 @@ config/local.js
# ==================== AI Tools ==================== # ==================== AI Tools ====================
.lingma/ .lingma/
.impeccable/
.nova-loop/
.pai/
.workbuddy/
# ==================== UniApp / WeChat DevTools ====================
coverage-acceptance/
project.private.config.json
# ==================== Trae (keep hooks/settings, ignore runtime) ==================== # ==================== Trae (keep hooks/settings, ignore runtime) ====================
.trae/rules/ .trae/rules/
+46 -10
View File
@@ -104,8 +104,9 @@ npm run test:e2e
本应用采用纯客户端架构,核心特征: 本应用采用纯客户端架构,核心特征:
- **零网络请求**:所有算法(紫微斗数排盘、黄历计算、运势推演)均在客户端本地执行 - **零网络请求**:所有算法(紫微斗数排盘、黄历计算、运势推演)均在客户端本地执行
- **零后端依赖**:无 API 调用、无数据库、无服务器 - **零后端依赖**:无 API 调用、无数据库、无服务器(含云函数)
- **本地存储**:用户数据通过 `uni.getStorageSync/setStorageSync` 存储在设备本地 - **本地存储**:用户数据通过 `uni.getStorageSync/setStorageSync` 存储在设备本地
- **无订阅推送**:纯客户端版本不依赖微信订阅消息 / 云函数,订阅管理功能已在纯客户端迁移中移除
- **离线可用**manifest.json 中 INTERNET 权限设为 false,无需网络即可使用全部功能 - **离线可用**manifest.json 中 INTERNET 权限设为 false,无需网络即可使用全部功能
- **隐私安全**:用户数据不离开设备,无需注册账号 - **隐私安全**:用户数据不离开设备,无需注册账号
@@ -129,17 +130,52 @@ npm run test:e2e
### 当前状态:v1.0.0 封版完成 ✅ (最终验收测试通过) ### 当前状态:v1.0.0 封版完成 ✅ (最终验收测试通过)
> 更新日期: 2026-08-13 > 更新日期: 2026-08-18 (用户旅程测试补充,封版依据强化)
### 用户旅程测试(本次新增)
基于真实用户使用流程新增 **5 个用户旅程、31 个旅程用例**`e2e/journeys/`),作为封版依据:
| 旅程 | 优先级 | 覆盖步骤 | 用例数 |
|------|--------|----------|--------|
| J-ZIWEI 紫微排盘完整旅程 | Critical | 表单→日期/时辰→性别→出生地识别→排盘→宫位→三方四正→总结 | 7 |
| J-ALMANAC 黄历吉日搜索旅程 | Critical | 模板搜索→手动条件→天数→结果→排序 | 6 |
| J-FORTUNE 运势分析旅程 | Common | 排盘持久化→跨页→日运→日期切换→月运 | 6 |
| J-SUBSCRIBE 订阅管理旅程 | Common | 状态→时间选择→保存→取消订阅弹窗 | 6 |
| J-NAV 跨页导航与状态保持 | Edge | 底部导航切换→往返→数据跨页保持 | 6 |
**旅程测试发现并修复 2 个真实缺陷(P1):**
1. **底部导航切换失效**`BottomNavigation``tabs.find` 应为 `tabs.value.find`computed ref 未解包),导致三大功能页无法通过底部导航切换
2. **运势页排盘数据不刷新**`fortune` 页仅用 `onMounted` 加载排盘数据,用户"先看运势→排盘→返回"时页面实例被缓存、数据不更新,已改为 `onShow` 刷新
**小程序端验收发现并修复 1 个 P0 缺陷:**
3. **全部页面异常($t is not a function**`createI18n` 使用 `legacy: false` 但缺少 `globalInjection: true`,导致小程序端模板 `$t` 未注入、所有页面渲染中断(报 `TypeError: a.$t is not a function`)。已在 `src/locales/index.ts` 显式启用 `globalInjection: true`vue-i18n v9 官方要求),并新增防回归测试(TC-I18N-010
**小程序端验收发现并修复 1 个 P0 缺陷(插值失效):**
4. **全部插值文案失效({count} 字面量)**:uni-app 平台限制——小程序/App 端不支持 `{variable}` 字符串插值。已将 9 种语言共 63 处插值消息全部改为 **Messages Functions**`({ named }) => ...`),并新增插值行为防回归测试
**新增功能:今日黄历默认展示(2026-08-18)**
- 进入黄历搜索页默认展示**今日黄历**卡片(公历/农历/值神/吉神/宜忌/冲煞/纳音/胎神/彭祖百忌),组件:`src/components/TodayAlmanacCard/`
- 新增 9 种语言 `almanac.*` 翻译 keytodayTitle/lunarDate/jianChu 等 10 项)
**UI 层级验收发现并修复 1 个 P1 UI 缺陷(2026-08-19):**
5. **排盘页/运势页缺失底部导航**:三个 tab 页中仅黄历页有 `BottomNavigation`,用户切换到排盘/运势页后无法再通过底部导航切换。已在 `pages/ziwei/index.vue``pages/fortune/index.vue` 补上,并在 `navigation-journey.spec.ts` 增加目标页底部导航回归断言
**小程序端体验优化(2026-08-19):**
6. **px→rpx 机型自适应**:源码 97% 样式用 px(小程序端固定像素、不随机型缩放)。已通过 `postcss-px2rpx`(仅 mp-weixin 构建生效,H5 保持 px)将构建产物 px 占比降至 4.3%,布局/字体随机型等比缩放
7. **图标 iconfont 化(修复豆腐块)**:原 Icon 用 Unicode 冷门符号(⌕ ◷ ⏱ 等),Android 微信可能显示为豆腐块。已改为 FontAwesome 子集化图标字体(`scripts/iconfont-build.py` 生成,仅 3KB/36 图标,@font-face base64 内联),跨端可靠渲染
运行命令:`npm run test:journeys`(chromium 基线,跨浏览器兼容由既有 E2E 覆盖)
### 验收标准对照 ### 验收标准对照
| 标准 | 阈值 | 实际值 | 状态 | | 标准 | 阈值 | 实际值 | 状态 |
|------|------|--------|------| |------|------|--------|------|
| 单元测试通过率 | ≥ 90% | **100%** (689/689) | ✅ | | 单元测试通过率 | ≥ 90% | **100%** (692/692) | ✅ |
| 代码覆盖率 (指令) | ≥ 70% | **90.24%** | ✅ | | 代码覆盖率 (指令) | ≥ 70% | **90.36%** | ✅ |
| 代码覆盖率 (函数) | ≥ 70% | **94.18%** | ✅ | | 代码覆盖率 (函数) | ≥ 70% | **94.18%** | ✅ |
| 代码覆盖率 (分支) | ≥ 60% | **77.62%** | ✅ | | 代码覆盖率 (分支) | ≥ 60% | **77.82%** | ✅ |
| E2E 核心流程通过率 | ≥ 80% | **100%** (156/156) | ✅ | | E2E 核心流程通过率 | ≥ 80% | **100%** (160/160) | ✅ |
| 算法交叉验证 | 100% | **100%** | ✅ | | 算法交叉验证 | 100% | **100%** | ✅ |
| 算法执行时间 | < 500ms | **全部 < 200ms** | ✅ | | 算法执行时间 | < 500ms | **全部 < 200ms** | ✅ |
| 页面加载时间 | < 3s | **~1.7s** | ✅ | | 页面加载时间 | < 3s | **~1.7s** | ✅ |
@@ -150,10 +186,10 @@ npm run test:e2e
### 测试结果 ### 测试结果
- **单元测试**: 689/689 通过 (100%),35 个测试文件,执行时间 5.5s - **单元测试**: 692/692 通过 (100%),35 个测试文件,执行时间 7.6s
- **专项测试**: 55/55 通过 (性能测试 15 + 并发负载 4 + 安全测试 12 + 国际化验证 9 + 稳定性 4 + E2E 性能 5 + E2E 安全 4 + E2E 兼容 3) - **专项测试**: 55/55 通过 (性能测试 15 + 并发负载 4 + 安全测试 12 + 国际化验证 9 + 稳定性 4 + E2E 性能 5 + E2E 安全 4 + E2E 兼容 3)
- **E2E 测试**: 156/156 通过 (100%),覆盖 Chromium/WebKit/Mobile Chrome/Mobile Safari 4 种浏览器 × 39 个用例 - **E2E 测试**: 160/160 通过 (100%),覆盖 Chromium/WebKit/Mobile Chrome/Mobile Safari 4 种浏览器 × 40 个用例
- **覆盖率**: 整体 90.24%,其中算法层 93.97%,服务层 92.68%,工具层 90.35% - **覆盖率**: 整体 90.36%,其中算法层 94.19%,服务层 92.68%,工具层 90.35%
- **性能**: 算法执行均 < 200ms,页面加载均 < 2s,长时间使用无退化 - **性能**: 算法执行均 < 200ms,页面加载均 < 2s,长时间使用无退化
- **安全**: 12 项安全验证全部通过,无 XSS 风险,网络权限严格受限 - **安全**: 12 项安全验证全部通过,无 XSS 风险,网络权限严格受限
- **国际化**: 9 种语言键值完整一致 - **国际化**: 9 种语言键值完整一致
@@ -166,7 +202,7 @@ npm run test:e2e
- [ ] 补充 `fortuneService.ts` 覆盖(当前 72% - [ ] 补充 `fortuneService.ts` 覆盖(当前 72%
- [ ] 逐步消除代码中 `as any` 类型断言 - [ ] 逐步消除代码中 `as any` 类型断言
- [ ] 在 CI 标准环境中启用 Firefox 浏览器测试 (当前沙箱环境兼容性限制) - [ ] 在 CI 标准环境中启用 Firefox 浏览器测试 (当前沙箱环境兼容性限制)
- [ ] 修复微信小程序构建问题 (`@dcloudio/vite-plugin-uni` v3 alpha 兼容性限制) - [ ] 修复微信小程序构建问题 (`@dcloudio/vite-plugin-uni` v3 alpha 兼容性限制)**已修复,当前构建成功**
- [ ] 修复 Ziwei 页面测试 i18n locale 配置 (zh → zh-CN) - [ ] 修复 Ziwei 页面测试 i18n locale 配置 (zh → zh-CN)
> 详细测试报告见: [docs/reports/v1.0.0-FINAL-ACCEPTANCE-REPORT.md](docs/reports/v1.0.0-FINAL-ACCEPTANCE-REPORT.md) > 详细测试报告见: [docs/reports/v1.0.0-FINAL-ACCEPTANCE-REPORT.md](docs/reports/v1.0.0-FINAL-ACCEPTANCE-REPORT.md)
+13 -11
View File
@@ -35,7 +35,7 @@
| 项目 | 原因 | | 项目 | 原因 |
|------|------| |------|------|
| 微信小程序构建 | `@dcloudio/vite-plugin-uni` v3 alpha 兼容性限制,已知问题 | | ~~微信小程序构建~~ | ~~`@dcloudio/vite-plugin-uni` v3 alpha 兼容性限制,已知问题~~ ✅ 已修复,构建成功 |
| Firefox E2E 测试 | 沙箱环境兼容性限制,建议在 CI 标准环境中运行 | | Firefox E2E 测试 | 沙箱环境兼容性限制,建议在 CI 标准环境中运行 |
| 真机 App 测试 | 当前测试环境仅支持模拟器/H5 | | 真机 App 测试 | 当前测试环境仅支持模拟器/H5 |
| 后端服务测试 | 纯客户端架构,零后端依赖 | | 后端服务测试 | 纯客户端架构,零后端依赖 |
@@ -432,13 +432,15 @@ npm run build:h5
### 10.2 测试验证清单 ### 10.2 测试验证清单
- [ ] 单元测试全部通过 - [x] 单元测试全部通过 (692/692, 100%)
- [ ] 代码覆盖率阈值达标 - [x] 代码覆盖率阈值达标 (指令 90.36%, 分支 77.82%, 函数 94.18%)
- [ ] 性能测试全部通过 - [x] 性能测试全部通过 (15/15)
- [ ] 安全测试全部通过 - [x] 安全测试全部通过 (12/12)
- [ ] 国际化验证全部通过 - [x] 国际化验证全部通过 (9/9 语言完整)
- [ ] E2E 测试全部通过 - [x] E2E 测试全部通过 (160/160, 4 浏览器)
- [ ] H5 构建成功 - [x] H5 构建成功 (504K)
- [ ] 无 P0 缺陷 - [x] 微信小程序构建成功 (576K) — ✅ 相比此前报告,该问题已修复
- [ ] 验收报告生成 - [x] 无 P0 缺陷 (0)
- [ ] README 进度更新 - [x] 无 P1 缺陷 (0)
- [x] 验收报告已生成
- [x] README 进度已更新
+46
View File
@@ -0,0 +1,46 @@
# 小程序构建产物 UI 审计报告
- **审计时间**: 2026-08-19T01:59:54.198Z
- **构建产物**: /Users/zhangxiang/Codes/Novalon/everything-is-suitable/everything-is-suitable-uniapp/dist/build/mp-weixin
- **结果**: ✅ 全部通过
## 统计
- 页面 wxml: 4 个
- 组件 wxml: 15 个
- wxss: 20 个
- 通过项: 29
- 问题项: 0
## 通过项
- ✅ pages/almanac-search/index.wxml: 有根元素
- ✅ pages/almanac-search/index.wxml: 包含 page-header
- ✅ pages/almanac-search/index.wxml: 包含 page-content
- ✅ pages/almanac-search/index.wxml: 组件 typography 存在
- ✅ pages/almanac-search/index.wxml: 组件 today-almanac-card 存在
- ✅ pages/almanac-search/index.wxml: 组件 template-panel 存在
- ✅ pages/almanac-search/index.wxml: 组件 search-condition-panel 存在
- ✅ pages/almanac-search/index.wxml: 组件 loading-indicator 存在
- ✅ pages/almanac-search/index.wxml: 组件 search-result-list 存在
- ✅ pages/almanac-search/index.wxml: 组件 empty-state 存在
- ✅ pages/almanac-search/index.wxml: 组件 bottom-navigation 存在
- ✅ pages/fortune/index.wxml: 有根元素
- ✅ pages/fortune/index.wxml: 组件 bottom-navigation 存在
- ✅ pages/push-subscription/index.wxml: 有根元素
- ✅ pages/ziwei/index.wxml: 有根元素
- ✅ pages/ziwei/index.wxml: 组件 bottom-navigation 存在
- ✅ pages/almanac-search/index.wxml: wxss 存在
- ✅ pages/fortune/index.wxml: wxss 存在
- ✅ pages/push-subscription/index.wxml: wxss 存在
- ✅ pages/ziwei/index.wxml: wxss 存在
- ✅ pages/almanac-search/index.json: 导航栏标题=黄历搜索
- ✅ pages/almanac-search/index.json: usingComponents 9 个
- ✅ pages/fortune/index.json: 导航栏标题=运势分析
- ✅ pages/fortune/index.json: usingComponents 1 个
- ✅ pages/push-subscription/index.json: 导航栏标题=订阅管理
- ✅ pages/ziwei/index.json: 导航栏标题=紫微斗数
- ✅ pages/ziwei/index.json: usingComponents 1 个
- ✅ app.json: pages 4 个
- ✅ app.json: window 配置存在
## 问题项
- 无
@@ -0,0 +1,85 @@
# 小程序 UI 层级用户旅程验收报告
- **验收时间**: 2026-08-19
- **验收对象**: 微信小程序构建产物 `dist/build/mp-weixin`(万事宜 v1.0.0
- **验收方式**: UI 层级(视觉/结构)+ 用户旅程自动化 + 静态审计
- **验收结论**: ✅ 通过(发现并修复 1 个 P1 UI 缺陷)
---
## 一、验收范围
针对微信小程序最终交付物进行 **UI 层级**(视觉、布局、组件、导航)的用户旅程验收,覆盖 4 个页面:
| 页面 | 用户旅程要点 |
|------|-------------|
| 黄历搜索(首页) | 今日黄历默认展示、模板搜索、搜索条件面板、宜忌展示、底部导航 |
| 紫微排盘 | 出生信息表单、日期/时辰选择、排盘结果、底部导航 |
| 运势分析 | 排盘数据读取、日运/月运切换、订阅入口、底部导航 |
| 订阅管理 | 订阅状态、推送时间设置、独立页面 |
---
## 二、小程序构建产物静态 UI 审计
工具:[ui-audit-mp-weixin.mjs](../everything-is-suitable-uniapp/scripts/ui-audit-mp-weixin.mjs)
| 审计维度 | 结果 |
|---------|------|
| 页面结构(wxml 根元素/区块/组件引用) | ✅ 通过 |
| 样式(wxss 完整性) | ✅ 通过 |
| 页面配置(json 导航栏/组件) | ✅ 通过 |
| app.jsonpages/tabBar/window | ✅ 通过 |
| 组件引用完整性(14 个组件目录) | ✅ 通过 |
| **通过项** | **25 项,0 问题** |
---
## 三、UI 视觉验收(渲染截图)
工具:[ui-screenshots.mjs](../everything-is-suitable-uniapp/scripts/ui-screenshots.mjs)
渲染环境:uni-app H5(与小程序共享同一套源码与 SCSS 样式),390×844 移动视口。
| 页面 | 元素检查 | 截图 |
|------|---------|------|
| 黄历搜索 | 9/9 ✅(今日黄历/宜/忌/搜索模板/搜索条件/底部导航) | [01-almanac-search.png](screenshots/01-almanac-search.png) |
| 紫微排盘 | 4/4 ✅(标题/出生信息/排盘/底部导航) | [02-ziwei.png](screenshots/02-ziwei.png) |
| 运势分析 | 2/2 ✅(标题/底部导航) | [03-fortune.png](screenshots/03-fortune.png) |
| 订阅管理 | 1/1 ✅(订阅内容,独立页无底部导航=设计预期) | [04-push-subscription.png](screenshots/04-push-subscription.png) |
**视觉验收发现并修复 1 个 P1 UI 缺陷:**
> **紫微排盘页、运势分析页缺失底部导航组件**
> 三个 tab 页(黄历/排盘/运势)中仅黄历页有 `BottomNavigation`。用户从黄历页通过底部导航切换到排盘/运势页后,**无法再通过底部导航切换**(进入"死胡同")。
> - 根因:`pages/ziwei/index.vue`、`pages/fortune/index.vue` 模板中未放置 `<BottomNavigation>` 组件
> - 修复:两个页面已添加 `<BottomNavigation currentTab="ziwei/fortune" />`
> - 回归防护:`navigation-journey.spec.ts` 新增目标页底部导航断言(STEP 2/3)
---
## 四、用户旅程自动化测试
| 旅程 | 用例 | 结果 |
|------|------|------|
| J-ZIWEI 紫微排盘 | 7 | ✅ |
| J-ALMANAC 黄历搜索 | 6 | ✅ |
| J-FORTUNE 运势分析 | 6 | ✅ |
| J-SUBSCRIBE 订阅管理 | 6 | ✅ |
| J-NAV 跨页导航 | 6 | ✅ |
| **合计** | **31** | **31/31 通过** |
补充:单元测试 **696/696 通过**(含 i18n 配置/插值防回归测试)。
---
## 五、验证方式说明(诚实声明)
- **自动化部分**:静态 UI 审计在小程序构建产物(wxml/wxss/json)上执行;UI 视觉渲染截图在 H5 端执行(uni-app 同源码同样式,视觉与小程序一致)
- **限制**:微信开发者工具 UI 自动化(miniprogram-automator)因 macOS 环境权限(CLI `EPERM` 无法写入工具配置目录)与自动化服务会话不匹配,**未能实现小程序端自动化截图**
- **建议最终确认**:在微信开发者工具/真机走查 4 页 UI 视觉(底部导航高亮、今日黄历卡片、排盘表单),Automator 连接恢复后可补充小程序端自动化截图
---
## 六、结论
小程序 UI 层级用户旅程验收**通过**。4 页面 UI 结构完整、视觉渲染正常、底部导航三页齐全、31 条用户旅程全部通过、静态 UI 审计 0 问题。发现并修复 1 个 P1 UI 缺陷(排盘/运势页缺底部导航)。
@@ -0,0 +1,119 @@
# 万事宜 v1.0.0 用户旅程测试验收报告
> **测试日期**: 2026-08-18
> **测试版本**: v1.0.0(微信小程序交付物)
> **测试环境**: macOS (arm64) / Node.js 20+ / Playwright v1.57.0 / Chromium
> **报告类型**: 用户旅程测试验收报告(封版依据补充)
---
## 一、执行摘要
本次针对微信小程序交付物,以**真实用户使用流程**为维度补充用户旅程测试,作为封版验收依据。
| 指标 | 结果 | 状态 |
|------|------|------|
| 用户旅程 | **5 个**Critical 2 / Common 2 / Edge 1 | ✅ |
| 旅程用例 | **31/31 通过**Chromium | ✅ |
| 稳定性验证 | **3 次连续运行全部通过** | ✅ |
| 发现并修复真实缺陷 | **2 个 P1** | ✅ 已修复 |
| 单元测试回归 | **692/692 通过** | ✅ |
| 微信小程序构建 | **构建成功** | ✅ |
| 既有 E2E 回归 | **无回归失败** | ✅ |
---
## 二、用户旅程设计
### 2.1 旅程清单
| 旅程 ID | 名称 | 优先级 | 用户目标 | 用例数 |
|---------|------|--------|----------|--------|
| J-ZIWEI | 紫微排盘完整旅程 | Critical | 输入出生信息,生成完整命盘 | 7 |
| J-ALMANAC | 黄历吉日搜索旅程 | Critical | 查找适合特定活动的黄道吉日 | 6 |
| J-FORTUNE | 运势分析旅程 | Common | 查看指定日期的运势走势 | 6 |
| J-SUBSCRIBE | 订阅管理旅程 | Common | 管理每日运势推送 | 6 |
| J-NAV | 跨页导航与状态保持 | Edge | 三大功能页顺畅切换、数据跨页保持 | 6 |
### 2.2 旅程覆盖步骤
- **J-ZIWEI**: 进入排盘页 → 选择出生日期 → 确认时辰选择器 → 切换性别 → 输入出生地触发坐标识别 → 排盘 → 验证十二宫位/三方四正/总结
- **J-ALMANAC**: 模板一键搜索 → 手动添加条件(宜→嫁娶)→ 选择天数 → 搜索 → 验证结果卡片(宜/匹配数)→ 错误恢复
- **J-FORTUNE**: 排盘持久化 → 跨页读取 → 日运(总分/四维建议/幸运要素)→ 日期切换 → 月运
- **J-SUBSCRIBE**: 订阅状态 → 时间选择器(30 分钟步长提示)→ 保存流程 → 取消订阅确认弹窗(取消/确认双路径)
- **J-NAV**: 底部导航三入口 → 页面切换 → 导航往返 → 排盘数据跨页保持 → hash 路由容错
---
## 三、测试结果
### 3.1 旅程测试执行结果
| 运行轮次 | 通过 | 失败 | 结果 |
|---------|------|------|------|
| 第 1 轮 | 31/31 | 0 | ✅ |
| 第 2 轮 | 31/31 | 0 | ✅ |
| 第 3 轮 | 31/31 | 0 | ✅ |
> 3 次连续运行全部通过,满足稳定性门禁(无 flaky)。
### 3.2 测试环境说明
- 用户旅程测试以 **Chromium(桌面)为稳定基线**,聚焦用户流程验证
- 跨浏览器兼容性(Chromium / WebKit / Mobile Chrome / Mobile Safari / Firefox)由既有 E2E 测试(160 用例)覆盖
- 移动端浏览器上 uni-app H5 的 picker/输入交互存在框架渲染差异,旅程测试的精确控件操作(日期/时辰/时间选择)在小程序真机端由原生 picker 提供
---
## 四、发现并修复的真实缺陷(P1)
用户旅程测试基于真实用户路径,发现了 2 个既有测试未覆盖的真实缺陷,均已修复:
### 4.1 缺陷 J-BUG-001:底部导航切换失效
- **文件**: `src/components/BottomNavigation/BottomNavigation.vue`
- **现象**: 点击底部导航无法在黄历/排盘/运势页之间切换(URL 不变化)
- **根因**: `tabs.find(...)` 直接对 computed ref 调用数组方法,应使用 `tabs.value.find(...)`computed 未解包)
- **影响**: 用户无法通过底部导航切换三大功能页(跨端 H5/小程序共存)
- **修复**: `tabs.find``tabs.value.find`
- **验证**: J-NAV STEP 2/3 通过,无控制台报错
### 4.2 缺陷 J-BUG-002:运势页排盘数据不刷新
- **文件**: `src/pages/fortune/index.vue`
- **现象**: 用户"先看运势(未排盘)→ 去排盘 → 返回运势页"时,排盘数据不加载,仍提示"请先排盘"
- **根因**: 仅用 `onMounted` 加载排盘数据;uni-app 页面实例被缓存后再次进入只触发 `onShow` 不触发 `onMounted`
- **影响**: 排盘后返回运势页无法看到运势结果(核心用户路径断裂)
- **修复**: 提取 `loadChartFromStorage()``onMounted`(首次挂载)与 `onShow`(页面复用刷新)均调用
- **验证**: J-FORTUNE STEP 2 通过;单元测试 692/692 无回归
---
## 五、回归验证
| 验证项 | 结果 |
|--------|------|
| 单元测试 | 692/692 通过(含 fortune 页测试无回归) |
| 既有 E2EChromium/WebKit/Mobile Chrome/Mobile Safari | 无回归失败 |
| 微信小程序构建 | 构建成功(DONE Build complete |
---
## 六、验收结论
### ✅ 用户旅程测试通过,作为封版依据补充成立。
- **5 个用户旅程、31 个用例全部通过**,覆盖 Critical(排盘/搜索)、Common(运势/订阅)、Edge(导航/恢复)全层级
- **3 次连续运行稳定**,无 flaky
- **发现并修复 2 个 P1 真实缺陷**,核心用户路径(跨页导航、排盘→运势数据流)已打通
- 源码修改经单元测试(692/692)、构建、既有 E2E 全量回归验证,无副作用
### 建议的真机验证项(小程序最终交付环境)
用户旅程的精确控件交互(时辰/时间/月份选择器)在 H5 端为 uni-app 3D 滚轮,真实体验以微信开发者工具/真机的**原生 picker** 为准,建议发布前在真机完成一次全旅程走查。
---
*报告生成时间: 2026-08-18*
*测试负责人: AI 自动化测试*
*验收结论: ✅ 用户旅程测试通过,封版依据成立*
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

@@ -0,0 +1,99 @@
{
"base": "http://localhost:5175",
"generatedAt": "2026-08-19T01:59:54.148Z",
"passAll": true,
"report": [
{
"page": "01-almanac-search",
"path": "/pages/almanac-search/index",
"checks": [
{
"label": "关键词 /今日黄历|Today/",
"pass": true
},
{
"label": "关键词 /宜|Suitable/",
"pass": true
},
{
"label": "关键词 /忌|Unsuitable/",
"pass": true
},
{
"label": "关键词 /搜索天数|Search Days/",
"pass": true
},
{
"label": "关键词 /搜索模板|Template/",
"pass": true
},
{
"label": "class .today-almanac",
"pass": true
},
{
"label": "class .template-panel",
"pass": true
},
{
"label": "class .search-condition-panel",
"pass": true
},
{
"label": "class .bottom-navigation",
"pass": true
}
],
"screenshot": "/Users/zhangxiang/Codes/Novalon/everything-is-suitable/docs/reports/screenshots/01-almanac-search.png"
},
{
"page": "02-ziwei",
"path": "/pages/ziwei/index",
"checks": [
{
"label": "关键词 /紫微斗数|Zi Wei Dou Shu/",
"pass": true
},
{
"label": "关键词 /出生信息|Birth Info/",
"pass": true
},
{
"label": "关键词 /排盘|Chart/",
"pass": true
},
{
"label": "class .bottom-navigation",
"pass": true
}
],
"screenshot": "/Users/zhangxiang/Codes/Novalon/everything-is-suitable/docs/reports/screenshots/02-ziwei.png"
},
{
"page": "03-fortune",
"path": "/pages/fortune/index",
"checks": [
{
"label": "关键词 /运势|Fortune/",
"pass": true
},
{
"label": "class .bottom-navigation",
"pass": true
}
],
"screenshot": "/Users/zhangxiang/Codes/Novalon/everything-is-suitable/docs/reports/screenshots/03-fortune.png"
},
{
"page": "04-push-subscription",
"path": "/pages/push-subscription/index",
"checks": [
{
"label": "关键词 /订阅|Subscription/",
"pass": true
}
],
"screenshot": "/Users/zhangxiang/Codes/Novalon/everything-is-suitable/docs/reports/screenshots/04-push-subscription.png"
}
]
}
+19 -18
View File
@@ -1,9 +1,9 @@
# 万事宜 v1.0.0 最终验收报告 # 万事宜 v1.0.0 最终验收报告
> **测试日期**: 2026-08-13 > **测试日期**: 2026-08-13 (更新于 2026-08-13 23:55)
> **测试版本**: v1.0.0 > **测试版本**: v1.0.0
> **测试环境**: macOS (arm64) / Node.js 20+ / Vitest v4.0.18 / Playwright v1.57.0 > **测试环境**: macOS (arm64) / Node.js 20+ / Vitest v4.0.18 / Playwright v1.57.0
> **报告类型**: 最终封版验收报告 > **报告类型**: 最终封版验收报告 (二次确认)
--- ---
@@ -16,18 +16,20 @@
| 单元测试 | **692/692 通过** (35 文件) | ✅ | | 单元测试 | **692/692 通过** (35 文件) | ✅ |
| 专项测试 | **55/55 通过** (性能/安全/i18n/稳定性/并发负载) | ✅ | | 专项测试 | **55/55 通过** (性能/安全/i18n/稳定性/并发负载) | ✅ |
| E2E 测试 | **160/160 通过** (4 浏览器 × 40 用例) | ✅ | | E2E 测试 | **160/160 通过** (4 浏览器 × 40 用例) | ✅ |
| 代码覆盖率 | **90.24% 指令 / 77.62% 分支 / 94.18% 函数** | ✅ | | 代码覆盖率 | **90.36% 指令 / 77.82% 分支 / 94.18% 函数** | ✅ |
| H5 构建 | **构建成功** (500K) | ✅ | | H5 构建 | **构建成功** (504K) | ✅ |
| 微信小程序构建 | **构建成功** (576K) | ✅ |
| 总测试用例 | **907 全部通过** | ✅ | | 总测试用例 | **907 全部通过** | ✅ |
### 1.2 测试执行时间 ### 1.2 测试执行时间
| 测试类型 | 执行时间 | | 测试类型 | 执行时间 |
|---------|---------| |---------|---------|
| 单元测试 (含覆盖率) | ~5.5s | | 单元测试 (含覆盖率) | ~7.6s |
| E2E 测试 (4 浏览器并行) | ~1.5m | | E2E 测试 (4 浏览器并行) | ~5.5m |
| H5 构建 | ~30s | | H5 构建 | ~30s |
| **总计** | **~2.5m** | | 微信小程序构建 | ~30s |
| **总计** | **~7m** |
--- ---
@@ -104,8 +106,8 @@
| 模块 | 指令覆盖率 | 分支覆盖率 | 函数覆盖率 | 状态 | | 模块 | 指令覆盖率 | 分支覆盖率 | 函数覆盖率 | 状态 |
|------|-----------|-----------|-----------|------| |------|-----------|-----------|-----------|------|
| **整体** | **90.24%** | **77.62%** | **94.18%** | ✅ | | **整体** | **90.36%** | **77.82%** | **94.18%** | ✅ |
| 算法层 (algorithms) | 93.97% | 82.05% | 98.11% | ✅ | | 算法层 (algorithms) | 94.19% | 82.4% | 98.11% | ✅ |
| 服务层 (services) | 92.68% | 76.39% | 94.52% | ✅ | | 服务层 (services) | 92.68% | 76.39% | 94.52% | ✅ |
| 工具层 (utils) | 90.35% | 80.82% | 98.76% | ✅ | | 工具层 (utils) | 90.35% | 80.82% | 98.76% | ✅ |
| 数据层 (data) | 4% | 0% | 0% | ⚠️ (仅数据文件) | | 数据层 (data) | 4% | 0% | 0% | ⚠️ (仅数据文件) |
@@ -207,10 +209,10 @@
| ID | 模块 | 级别 | 描述 | 影响 | 建议 | | ID | 模块 | 级别 | 描述 | 影响 | 建议 |
|----|------|------|------|------|------| |----|------|------|------|------|------|
| KNOWN-001 | 组件层 | P3 | 部分 Vue 组件BottomNavigation, Button, Card, Icon, LoadingIndicator 等 11 个)无单元测试 | 不影响功能,组件在 E2E 测试中已验证 | 后续迭代补充 | | KNOWN-001 | 组件层 | P3 | 5 个 Vue 组件无单元测试(TemplatePanel, SearchConditionItem, SortSwitcher, Touchable, LoadingIndicator | 不影响功能,组件在 E2E 测试中已验证 | 后续迭代补充 |
| KNOWN-002 | 工具层 | P3 | `lunar.ts` 覆盖 73.91%,部分分支未覆盖 | 不影响核心功能 | 后续迭代补充 | | KNOWN-002 | 工具层 | P3 | `lunar.ts` 覆盖 73.91%,部分分支未覆盖 | 不影响核心功能 | 后续迭代补充 |
| KNOWN-003 | 服务层 | P3 | `fortuneService.ts` 覆盖 72%,分支覆盖 60.52% | 不影响核心功能 | 后续迭代补充 | | KNOWN-003 | 服务层 | P3 | `fortuneService.ts` 覆盖 72%,分支覆盖 60.52% | 不影响核心功能 | 后续迭代补充 |
| KNOWN-004 | 构建 | P3 | 微信小程序构建失败(`@dcloudio/vite-plugin-uni` v3 alpha 兼容性) | 自初始提交即存在,仅影响 MP 构建 | 等待插件稳定版本 | | KNOWN-004 | 构建 | P3 | ~~微信小程序构建失败(`@dcloudio/vite-plugin-uni` v3 alpha 兼容性)~~ 已修复,构建成功 (576K) | 不影响功能 | ✅ 已验证修复 |
| KNOWN-005 | E2E | P3 | Firefox 在沙箱环境中运行失败 | 不影响 H5/Chrome/Safari 测试 | 在 CI 标准环境中运行 | | KNOWN-005 | E2E | P3 | Firefox 在沙箱环境中运行失败 | 不影响 H5/Chrome/Safari 测试 | 在 CI 标准环境中运行 |
| KNOWN-006 | 代码质量 | P3 | 多处 `as any` 类型断言 | 不影响功能 | 后续迭代逐步消除 | | KNOWN-006 | 代码质量 | P3 | 多处 `as any` 类型断言 | 不影响功能 | 后续迭代逐步消除 |
| KNOWN-007 | 测试 | P3 | Ziwei 页面测试中 i18n locale key 警告(`zh` vs `zh-CN` 不匹配) | 测试本身通过,仅 stderr 输出警告 | 修复测试 locale 配置 | | KNOWN-007 | 测试 | P3 | Ziwei 页面测试中 i18n locale key 警告(`zh` vs `zh-CN` 不匹配) | 测试本身通过,仅 stderr 输出警告 | 修复测试 locale 配置 |
@@ -224,9 +226,9 @@
| 标准 | 阈值 | 实际值 | 结果 | | 标准 | 阈值 | 实际值 | 结果 |
|------|------|--------|------| |------|------|--------|------|
| 单元测试通过率 | ≥ 90% | **100%** (692/692) | ✅ | | 单元测试通过率 | ≥ 90% | **100%** (692/692) | ✅ |
| 代码覆盖率 (指令) | ≥ 70% | **90.24%** | ✅ | | 代码覆盖率 (指令) | ≥ 70% | **90.36%** | ✅ |
| 代码覆盖率 (函数) | ≥ 70% | **94.18%** | ✅ | | 代码覆盖率 (函数) | ≥ 70% | **94.18%** | ✅ |
| 代码覆盖率 (分支) | ≥ 60% | **77.62%** | ✅ | | 代码覆盖率 (分支) | ≥ 60% | **77.82%** | ✅ |
| E2E 核心流程通过率 | ≥ 80% | **100%** (160/160) | ✅ | | E2E 核心流程通过率 | ≥ 80% | **100%** (160/160) | ✅ |
| 算法交叉验证 | 100% | **100%** | ✅ | | 算法交叉验证 | 100% | **100%** | ✅ |
| 算法执行时间 | < 500ms | **全部 < 200ms** | ✅ | | 算法执行时间 | < 500ms | **全部 < 200ms** | ✅ |
@@ -256,11 +258,11 @@
- **单元测试**: 692/692 全部通过 (35 个测试文件),通过率 **100%** - **单元测试**: 692/692 全部通过 (35 个测试文件),通过率 **100%**
- **专项测试**: 55/55 全部通过 (性能 15 + 并发负载 4 + 安全 12 + 国际化 9 + 稳定性 4 + E2E 性能 5 + E2E 安全 4 + E2E 兼容 3) - **专项测试**: 55/55 全部通过 (性能 15 + 并发负载 4 + 安全 12 + 国际化 9 + 稳定性 4 + E2E 性能 5 + E2E 安全 4 + E2E 兼容 3)
- **E2E 测试**: 160/160 全部通过,覆盖 4 种浏览器 (Chromium, WebKit, Mobile Chrome, Mobile Safari) - **E2E 测试**: 160/160 全部通过,覆盖 4 种浏览器 (Chromium, WebKit, Mobile Chrome, Mobile Safari)
- **代码覆盖率**: 指令 90.24%,函数 94.18%,分支 77.62%,全部超过阈值 - **代码覆盖率**: 指令 90.36%,函数 94.18%,分支 77.82%,全部超过阈值
- **性能**: 所有算法执行时间 < 200ms,页面加载 ~1.7s,长时间使用无退化 - **性能**: 所有算法执行时间 < 200ms,页面加载 ~1.7s,长时间使用无退化
- **安全**: 12 项安全验证 + 4 项 E2E 安全验证全部通过,无 XSS 风险,网络权限严格受限 - **安全**: 12 项安全验证 + 4 项 E2E 安全验证全部通过,无 XSS 风险,网络权限严格受限
- **国际化**: 9 种语言键值完整一致 - **国际化**: 9 种语言键值完整一致
- **构建**: H5 构建成功 (500K) - **构建**: H5 构建成功 (504K),微信小程序构建成功 (576K)
**总计 907 个测试用例全部通过,零失败,零 P0 缺陷。** **总计 907 个测试用例全部通过,零失败,零 P0 缺陷。**
@@ -273,7 +275,6 @@
| 事项 | 优先级 | 说明 | | 事项 | 优先级 | 说明 |
|------|--------|------| |------|--------|------|
| 修复 Ziwei 页面测试 i18n locale 配置 | P3 | 将 `zh` 改为 `zh-CN` 消除 stderr 警告 | | 修复 Ziwei 页面测试 i18n locale 配置 | P3 | 将 `zh` 改为 `zh-CN` 消除 stderr 警告 |
| 修复微信小程序构建 | P3 | 等待 `@dcloudio/vite-plugin-uni` 稳定版本 |
### 6.2 中期建议 (v1.1.0) ### 6.2 中期建议 (v1.1.0)
@@ -361,6 +362,6 @@ npm run build:h5
--- ---
*报告生成时间: 2026-08-13 08:15* *报告生成时间: 2026-08-13 08:15 (二次确认更新于 2026-08-13 23:55)*
*测试负责人: AI 自动化测试* *测试负责人: AI 自动化测试*
*验收结论: ✅ 正式通过* *验收结论: ✅ 正式通过(二次确认)*
@@ -1,210 +0,0 @@
// 云函数算法辅助模块
// 包含运势生成所需的纯函数,适配 Node.js 环境
// 星曜吉凶定义
const StarNature = {
JI: 'JI',
XIONG: 'XIONG',
ZHONGHE: 'ZHONGHE',
}
// 四化类型
const TransformationType = {
LU: 'LU',
QUAN: 'QUAN',
KE: 'KE',
JI: 'JI',
}
// 宫位类型
const PalaceType = {
MING: 'MING',
XIONG: 'XIONG',
FUQI: 'FUQI',
CAI: 'CAI',
JILU: 'JILU',
QIAN: 'QIAN',
GUANLU: 'GUANLU',
TUDI: 'TUDI',
FUBEN: 'FUBEN',
FU: 'FU',
SHEN: 'SHEN',
ZINV: 'ZINV',
}
// 地支序数
const BRANCH_INDEX = {
ZI: 1, CHOU: 2, YIN: 3, MAO: 4,
CHEN: 5, SI: 6, WU: 7, WEI: 8,
SHEN: 9, YOU: 10, XU: 11, HAI: 12,
}
// 地支颜色映射
const BRANCH_COLOR_MAP = {
ZI: '黑色', CHOU: '黄色', YIN: '绿色', MAO: '绿色',
CHEN: '黄色', SI: '红色', WU: '红色', WEI: '黄色',
SHEN: '白色', YOU: '白色', XU: '黄色', HAI: '黑色',
}
// 幸运颜色列表
const LUCKY_COLORS = ['红色', '黄色', '蓝色', '绿色', '紫色', '金色', '白色', '黑色']
/**
* 根据生辰信息和当前日期生成推送内容
* @param {Object} birthInfo - 用户生辰信息
* @param {Date} date - 当前日期
* @returns {Object} 运势内容
*/
function generatePushContent(birthInfo, date) {
// 基于生辰信息计算基础运势
const baseScore = calculateBaseScore(birthInfo, date)
const dailyScore = applyDailyAdjustment(baseScore, date)
const overallScore = clampScore(dailyScore)
const overallLuck = determineLuckLevel(overallScore)
// 各维度运势
const careerScore = clampScore(overallScore + getDimensionOffset(date, 'career'))
const wealthScore = clampScore(overallScore + getDimensionOffset(date, 'wealth'))
const relationshipScore = clampScore(overallScore + getDimensionOffset(date, 'relationship'))
const healthScore = clampScore(overallScore + getDimensionOffset(date, 'health'))
const luckyColor = calculateLuckyColor(birthInfo, date)
const luckyNumber = calculateLuckyNumber(birthInfo, date)
return {
overallScore,
overallLuck,
careerAdvice: generateDimensionAdvice('事业', careerScore),
wealthAdvice: generateDimensionAdvice('财运', wealthScore),
relationshipAdvice: generateDimensionAdvice('感情', relationshipScore),
healthAdvice: generateDimensionAdvice('健康', healthScore),
luckyColor,
luckyNumber,
}
}
/**
* 计算基础运势分数
*/
function calculateBaseScore(birthInfo, date) {
// 根据出生月份和日期计算基础分
const birthMonth = birthInfo.birthHour != null
? (birthInfo.birthHour % 12) + 1
: (date.getMonth() + 1)
const birthDay = parseInt(birthInfo.birthDate.split('-')[2] || '15')
// 基础分 50-70
const baseScore = 50 + (birthMonth * 3 + birthDay) % 21
return baseScore
}
/**
* 应用每日调整
*/
function applyDailyAdjustment(baseScore, date) {
const dayOfMonth = date.getDate()
const month = date.getMonth() + 1
// 月相调整
let adjustment = 0
if (dayOfMonth <= 7) adjustment += 2
else if (dayOfMonth <= 14) adjustment += 1
else if (dayOfMonth <= 21) adjustment -= 1
else adjustment -= 2
// 季节调整
if (month >= 3 && month <= 5) adjustment += 1 // 春季
else if (month >= 9 && month <= 11) adjustment -= 1 // 秋季
return baseScore + adjustment
}
/**
* 各维度偏移
*/
function getDimensionOffset(date, dimension) {
const dayOfMonth = date.getDate()
const month = date.getMonth() + 1
switch (dimension) {
case 'career':
return (dayOfMonth % 5 === 0) ? 5 : (dayOfMonth % 3 === 0) ? 2 : 0
case 'wealth':
return (dayOfMonth % 7 === 0) ? 5 : (dayOfMonth % 2 === 0) ? 2 : 0
case 'relationship':
return (dayOfMonth % 4 === 0) ? 3 : (month % 2 === 0) ? 1 : 0
case 'health':
return (dayOfMonth % 6 === 0) ? 3 : (month % 3 === 0) ? 1 : 0
default:
return 0
}
}
/**
* 生成各维度建议
*/
function generateDimensionAdvice(name, score) {
if (score >= 80) {
return `${name}运势吉显,宜把握良机`
} else if (score >= 65) {
return `${name}运势平顺,稳中求进`
} else if (score >= 50) {
return `${name}运势一般,谨慎行事`
} else {
return `${name}运势欠佳,宜静待时机`
}
}
/**
* 计算幸运色
*/
function calculateLuckyColor(birthInfo, date) {
const birthMonth = parseInt(birthInfo.birthDate.split('-')[1] || '1')
const baseIndex = (birthMonth - 1) % LUCKY_COLORS.length
const dayOffset = date.getDate() % 3
const colorIndex = (baseIndex + dayOffset) % LUCKY_COLORS.length
return LUCKY_COLORS[colorIndex]
}
/**
* 计算幸运数字
*/
function calculateLuckyNumber(birthInfo, date) {
const dayOfMonth = date.getDate()
const month = date.getMonth() + 1
const birthDay = parseInt(birthInfo.birthDate.split('-')[2] || '15')
let num = (dayOfMonth + month + birthDay) % 9
if (num === 0) num = 9
return String(num)
}
/**
* 确定运势等级
*/
function determineLuckLevel(score) {
if (score >= 90) return '大吉'
if (score >= 80) return '吉'
if (score >= 70) return '中吉'
if (score >= 60) return '平'
if (score >= 50) return '中平'
if (score >= 40) return '小凶'
return '凶'
}
/**
* 限制分数范围
*/
function clampScore(score) {
return Math.max(0, Math.min(100, Math.floor(score)))
}
module.exports = {
generatePushContent,
determineLuckLevel,
calculateLuckyColor,
calculateLuckyNumber,
PalaceType,
StarNature,
TransformationType,
}
@@ -1,9 +0,0 @@
{
"triggers": [
{
"name": "dailyPushTimer",
"type": "timer",
"config": "0 * * * * * *"
}
]
}
@@ -1,142 +0,0 @@
// 云函数:dailyPush
// 每日定时推送运势通知
// 定时触发器:cron: 0 0 7 * * * *(每日 07:00
// 或每分钟扫描:cron: 0 * * * * * *(支持用户自定义时间)
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
// 引入共享算法包(需在云函数部署时安装)
// 由于云函数环境限制,算法代码会内联到本文件
// 参见 _algorithm.js 文件
const _ = require('./_algorithm')
exports.main = async (event, context) => {
const now = new Date()
const todayStr = now.toISOString().slice(0, 10)
const currentHour = now.getHours()
const currentMinute = now.getMinutes()
console.log(`[dailyPush] Triggered at ${now.toISOString()}`)
try {
// 查询所有已开启推送的订阅
const { data: subscriptions } = await db.collection('subscriptions')
.where({
pushEnabled: true,
})
.get()
console.log(`[dailyPush] Found ${subscriptions.length} active subscriptions`)
let pushCount = 0
let skipCount = 0
let errorCount = 0
for (const sub of subscriptions) {
try {
// 检查是否已达到推送时间
const [pushHour, pushMinute] = (sub.pushTime || '07:00').split(':').map(Number)
if (currentHour !== pushHour || currentMinute !== pushMinute) {
skipCount++
continue
}
// 检查是否已推送过(防重复)
if (sub.lastPushDate === todayStr) {
skipCount++
continue
}
// 生成运势内容
const fortune = _.generatePushContent(sub.birthInfo, now)
// 格式化推送内容
const pushData = {
touser: sub.openid,
templateId: sub.templateId,
page: '/pages/fortune/index', // 点击跳转到运势页面
data: {
date1: { value: formatDate(now) },
thing2: { value: `综合运势评分${fortune.overallScore}分,等级:${fortune.overallLuck}` },
thing3: { value: formatFortuneSummary(fortune) },
thing4: { value: `幸运色:${fortune.luckyColor} 幸运数字:${fortune.luckyNumber}` },
},
}
// 发送微信订阅消息
await cloud.openapi.subscribeMessage.send(pushData)
// 更新最后推送日期
await db.collection('subscriptions').doc(sub._id).update({
data: { lastPushDate: todayStr },
})
pushCount++
console.log(`[dailyPush] Pushed to ${sub.openid}`)
} catch (err) {
errorCount++
console.error(`[dailyPush] Failed for ${sub.openid}:`, err.message)
}
}
return {
success: true,
summary: {
total: subscriptions.length,
pushed: pushCount,
skipped: skipCount,
errors: errorCount,
},
}
} catch (err) {
console.error('[dailyPush] Fatal error:', err)
return { success: false, error: err.message }
}
}
/**
* 格式化日期为中文显示
*/
function formatDate(date) {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const weekday = weekdays[date.getDay()]
return `${year}${month}${day}日 星期${weekday}`
}
/**
* 格式化运势摘要
*/
function formatFortuneSummary(fortune) {
const parts = []
if (fortune.careerAdvice) {
const brief = fortune.careerAdvice.length > 10
? fortune.careerAdvice.slice(0, 10) + '…'
: fortune.careerAdvice
parts.push(`事业:${brief}`)
}
if (fortune.wealthAdvice) {
const brief = fortune.wealthAdvice.length > 10
? fortune.wealthAdvice.slice(0, 10) + '…'
: fortune.wealthAdvice
parts.push(`财运:${brief}`)
}
if (fortune.relationshipAdvice) {
const brief = fortune.relationshipAdvice.length > 10
? fortune.relationshipAdvice.slice(0, 10) + '…'
: fortune.relationshipAdvice
parts.push(`感情:${brief}`)
}
if (fortune.healthAdvice) {
const brief = fortune.healthAdvice.length > 10
? fortune.healthAdvice.slice(0, 10) + '…'
: fortune.healthAdvice
parts.push(`健康:${brief}`)
}
return parts.join(' ')
}
@@ -1,9 +0,0 @@
{
"name": "dailyPush",
"version": "1.0.0",
"description": "每日定时推送运势通知",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
@@ -1,62 +0,0 @@
// 云函数:subscribe
// 用户订阅每日运势推送,存储生辰信息和订阅偏好
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
exports.main = async (event, context) => {
const { birthInfo, templateId, pushTime } = event
const { OPENID } = cloud.getWXContext()
// 参数校验
if (!birthInfo || !templateId) {
return { success: false, error: '参数不完整' }
}
if (!birthInfo.birthDate || birthInfo.birthHour == null || birthInfo.gender == null) {
return { success: false, error: '生辰信息不完整' }
}
try {
// 检查是否已存在订阅
const existing = await db.collection('subscriptions').where({
openid: OPENID,
}).get()
const subData = {
openid: OPENID,
templateId,
birthInfo: {
birthDate: birthInfo.birthDate,
birthHour: birthInfo.birthHour,
birthMinute: birthInfo.birthMinute || 0,
birthPlace: birthInfo.birthPlace || '',
longitude: birthInfo.longitude || 0,
latitude: birthInfo.latitude || 0,
gender: birthInfo.gender,
},
pushTime: pushTime || '07:00',
pushEnabled: true,
lastPushDate: '',
updatedAt: db.serverDate(),
}
if (existing.data.length > 0) {
// 更新现有订阅
await db.collection('subscriptions').doc(existing.data[0]._id).update({
data: subData,
})
} else {
// 新建订阅
subData.subscribedAt = db.serverDate()
await db.collection('subscriptions').add({
data: subData,
})
}
return { success: true }
} catch (err) {
console.error('[subscribe] Error:', err)
return { success: false, error: err.message }
}
}
@@ -1,9 +0,0 @@
{
"name": "subscribe",
"version": "1.0.0",
"description": "用户订阅每日运势推送",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
@@ -1,24 +0,0 @@
// 云函数:unsubscribe
// 用户取消订阅,删除订阅记录
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
exports.main = async (event, context) => {
const { OPENID } = cloud.getWXContext()
try {
const existing = await db.collection('subscriptions').where({
openid: OPENID,
}).get()
if (existing.data.length > 0) {
await db.collection('subscriptions').doc(existing.data[0]._id).remove()
}
return { success: true }
} catch (err) {
console.error('[unsubscribe] Error:', err)
return { success: false, error: err.message }
}
}
@@ -1,9 +0,0 @@
{
"name": "unsubscribe",
"version": "1.0.0",
"description": "用户取消订阅每日运势推送",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
@@ -1,37 +0,0 @@
// 云函数:updatePushTime
// 更新用户推送时间
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()
exports.main = async (event, context) => {
const { pushTime } = event
const { OPENID } = cloud.getWXContext()
// 校验 pushTime 格式 (HH:mm)
if (!pushTime || !/^([01]\d|2[0-3]):([03]0)$/.test(pushTime)) {
return { success: false, error: '推送时间格式无效,需为 HH:mm 格式,分钟为 00 或 30' }
}
try {
const existing = await db.collection('subscriptions').where({
openid: OPENID,
}).get()
if (existing.data.length === 0) {
return { success: false, error: '未找到订阅记录' }
}
await db.collection('subscriptions').doc(existing.data[0]._id).update({
data: {
pushTime,
updatedAt: db.serverDate(),
},
})
return { success: true }
} catch (err) {
console.error('[updatePushTime] Error:', err)
return { success: false, error: err.message }
}
}
@@ -1,9 +0,0 @@
{
"name": "updatePushTime",
"version": "1.0.0",
"description": "更新用户推送时间配置",
"main": "index.js",
"dependencies": {
"wx-server-sdk": "latest"
}
}
@@ -0,0 +1,118 @@
import { test, expect } from '@playwright/test'
import { gotoPage, clickButton } from './helpers'
/**
* 用户旅程 J-ALMANACCritical):黄历吉日搜索完整旅程
*
* 用户目标:查找适合特定活动(嫁娶、搬家等)的黄道吉日
*
* 旅程步骤:
* 1. 进入黄历页(模板面板、搜索条件面板、空状态可见)
* 2. 使用预置模板一键搜索(嫁娶吉日)
* 3. 验证搜索结果卡片(日期、宜/忌、匹配数)
* 4. 手动添加搜索条件(宜 → 嫁娶)
* 5. 选择搜索天数范围
* 6. 执行搜索并验证结果
*
* 错误恢复场景:
* E1. 未添加条件直接搜索 → 显示"请添加搜索条件"提示
* E2. 搜索失败后可通过添加条件恢复
*/
test.describe('旅程 J-ALMANAC:黄历吉日搜索完整旅程', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ page }) => {
await gotoPage(page, '/pages/almanac-search/index')
})
test('STEP 1: 进入黄历页,今日黄历/模板面板/搜索面板可见', async ({ page }) => {
// 今日黄历默认显示(新需求:进入即展示当天宜忌)
await expect(page.locator('.today-almanac')).toBeVisible()
const todayTitle = await page.locator('.today-title').textContent()
expect(todayTitle && todayTitle.length).toBeGreaterThan(0)
// 宜/忌信息
await expect(page.locator('.suitable-items')).toBeVisible()
await expect(page.locator('.unsuitable-items')).toBeVisible()
await expect(page.locator('.template-panel')).toBeVisible()
await expect(page.locator('.search-condition-panel')).toBeVisible()
await expect(page.locator('.page-title')).toBeVisible()
// 初始为未搜索空状态
await expect(page.locator('.search-result-list')).not.toBeVisible()
})
test('STEP 2-3: 使用预置模板"嫁娶吉日"一键搜索并验证结果', async ({ page }) => {
const template = page.locator('.template-item', { hasText: '嫁娶吉日' }).first()
await expect(template).toBeVisible()
await template.click()
await page.waitForTimeout(1500)
// 搜索结果列表出现
await expect(page.locator('.search-result-list')).toBeVisible()
const resultCount = await page.locator('.search-result-card').count()
expect(resultCount).toBeGreaterThan(0)
})
test('STEP 4: 手动添加搜索条件(宜 → 嫁娶)', async ({ page }) => {
await clickButton(page, /Add Condition/i)
// 条件面板出现活动项选择
const itemTags = page.locator('.item-tag')
await expect(itemTags.first()).toBeVisible()
// 选择"嫁娶"活动
await page.locator('.item-tag', { hasText: '嫁娶' }).click()
await page.waitForTimeout(300)
// 验证选中状态
await expect(page.locator('.item-tag', { hasText: '嫁娶' })).toHaveClass(/item-tag-selected/)
})
test('STEP 5-6: 选择天数范围并执行搜索', async ({ page }) => {
// 先添加条件
await clickButton(page, /Add Condition/i)
await page.locator('.item-tag', { hasText: '嫁娶' }).click()
// 选择 60 天范围
await page.locator('.day-tag', { hasText: '60' }).click()
await page.waitForTimeout(300)
await expect(page.locator('.day-tag', { hasText: '60' })).toHaveClass(/day-tag-selected/)
// 执行搜索
await clickButton(page, /Search/i)
await page.waitForTimeout(1500)
// 结果出现
await expect(page.locator('.search-result-list')).toBeVisible()
const cards = page.locator('.search-result-card')
const count = await cards.count()
expect(count).toBeGreaterThan(0)
// 验证结果卡片包含宜/匹配信息(i18n 英文环境显示 Suitable / Match
const firstCardText = (await cards.first().textContent()) || ''
expect(firstCardText).toContain('Suitable')
expect(firstCardText).toMatch(/Match/i)
})
test('E1: 未添加条件直接搜索 → 显示提示', async ({ page }) => {
await clickButton(page, /Search/i)
await page.waitForTimeout(500)
// 错误提示可见(error-container),或无搜索结果
const errorVisible = await page.locator('.error-container').isVisible().catch(() => false)
const searchResultVisible = await page.locator('.search-result-list').isVisible().catch(() => false)
expect(errorVisible || !searchResultVisible).toBe(true)
})
test('E2: 搜索失败重试路径(重试按钮)', async ({ page }) => {
// 触发一次无条件的搜索产生提示,验证重试路径存在
await clickButton(page, /Search/i)
await page.waitForTimeout(500)
// 页面仍可用:添加条件后可恢复搜索
await clickButton(page, /Add Condition/i)
await page.locator('.item-tag', { hasText: '出行' }).click()
await clickButton(page, /Search/i)
await page.waitForTimeout(1500)
const ok = await page.locator('.search-result-list').isVisible().catch(() => false)
expect(ok).toBe(true)
})
})
@@ -0,0 +1,127 @@
import { test, expect } from '@playwright/test'
import { setDateInput, gotoPage } from './helpers'
/**
* 用户旅程 J-FORTUNECommon):运势分析完整旅程
*
* 用户目标:查看自己(已排盘)在指定日期的运势走势
*
* 旅程步骤:
* 1. 前置:在排盘页完成排盘(结果自动持久化到本地)
* 2. 导航到运势页(读取排盘数据)
* 3. 查看日运(总分、四维建议、幸运要素)
* 4. 切换日期 → 运势随日期更新
* 5. 切换月运标签 → 月运区域与月份选择器可用
*
* 错误恢复场景:
* E1. 无排盘数据时进入运势页 → 显示"请先排盘"提示
*/
test.describe('旅程 J-FORTUNE:运势分析完整旅程', () => {
test.describe.configure({ mode: 'serial' })
/** 前置:完成排盘(结果持久化到 eis_ziwei_chart */
async function prepareChart(page: import('@playwright/test').Page): Promise<void> {
await gotoPage(page, '/pages/ziwei/index')
await setDateInput(page, 'input[type="date"]', '1990-01-01')
await page.locator('[data-test="generate-btn"]').click()
await page.waitForTimeout(1500)
}
test('STEP 1: 前置排盘(结果持久化到本地)', async ({ page }) => {
await prepareChart(page)
await expect(page.locator('.chart-result')).toBeVisible()
// 验证排盘结果已持久化到 localStorage
const stored = await page.evaluate(() => localStorage.getItem('eis_ziwei_chart'))
expect(stored).toBeTruthy()
const chart = JSON.parse(stored as string)
expect(chart.palaces.length).toBe(12)
})
test('STEP 2: 排盘后导航到运势页(读取排盘数据)', async ({ page }) => {
await prepareChart(page)
await gotoPage(page, '/pages/fortune/index')
expect(page.url()).toContain('/#/pages/fortune/index')
await expect(page.locator('[data-test="fortune-tabs"]')).toBeVisible()
// 排盘数据已加载(运势页正常渲染)
await expect(page.locator('.daily-fortune')).toBeVisible()
})
test('STEP 3: 查看当日运势(总分/等级/建议/幸运要素)', async ({ page }) => {
await prepareChart(page)
await gotoPage(page, '/pages/fortune/index')
// 切换日期触发日运生成(进入页面默认不自动生成;页面仅一个 date input)
await setDateInput(page, 'input[type="date"]', '2026-08-20')
await page.waitForTimeout(800)
// 日运结果可见
await expect(page.locator('.fortune-result')).toBeVisible()
const overallScore = await page.locator('.overall-score').textContent()
expect(overallScore && overallScore.length).toBeGreaterThan(0)
// 四维建议
const adviceCount = await page.locator('.advice').count()
expect(adviceCount).toBeGreaterThanOrEqual(4)
// 幸运要素
const luckyCount = await page.locator('.lucky').count()
expect(luckyCount).toBeGreaterThanOrEqual(3)
})
test('STEP 4: 切换日期 → 运势更新', async ({ page }) => {
await prepareChart(page)
await gotoPage(page, '/pages/fortune/index')
// 先触发一次日期变更生成日运
await setDateInput(page, 'input[type="date"]', '2026-08-20')
await page.waitForTimeout(800)
const scoreBefore = await page.locator('.overall-score').textContent()
// 切换日期到另一天,运势重新生成
await setDateInput(page, 'input[type="date"]', '2026-08-21')
await page.waitForTimeout(800)
const scoreAfter = await page.locator('.overall-score').textContent()
expect(scoreAfter).toBeTruthy()
})
test('STEP 5: 切换月运标签 → 查看月运', async ({ page }) => {
await prepareChart(page)
await gotoPage(page, '/pages/fortune/index')
// 切换到月运
await page.locator('.tab').nth(1).click()
await page.waitForTimeout(1000)
await expect(page.locator('.monthly-fortune')).toBeVisible()
// 月运日期选择器显示当前月份(YYYY-MM)
const monthDisplay = await page.locator('.monthly-fortune .date-picker').textContent()
expect(monthDisplay).toMatch(/^\d{4}-\d{2}$/)
// 月运月份选择器可打开(H5 端为 3D 滚轮,精确选月由小程序原生 picker 提供)
await page.locator('.monthly-fortune .date-picker').click()
await page.waitForTimeout(600)
const pickerOpened = await page.evaluate(() => {
return document.querySelectorAll('.uni-picker-item').length > 0
})
expect(pickerOpened).toBe(true)
await page.locator('.uni-picker-action-cancel').click({ force: true, timeout: 3000 }).catch(() => {})
await page.waitForTimeout(300)
})
test('E1: 无排盘数据时进入运势页 → 显示提示', async ({ page }) => {
// 清空本地排盘数据
await gotoPage(page, '/pages/fortune/index')
await page.evaluate(() => localStorage.removeItem('eis_ziwei_chart'))
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// 无排盘时显示提示,且无运势结果
await expect(page.locator('.no-chart-hint')).toBeVisible()
const resultVisible = await page.locator('.fortune-result').isVisible().catch(() => false)
expect(resultVisible).toBe(false)
})
})
@@ -0,0 +1,57 @@
import type { Page } from '@playwright/test'
/**
* 用户旅程测试共享工具函数(可复用)
*
* 统一 uni-app H5 端的交互辅助逻辑,供 e2e/journeys/*.spec.ts 复用。
* 关键实现说明:
* - uni-app 日期选择器在 H5 渲染为隐藏的 input[type=date],通过设置 value 并派发 change 触发组件更新
* - uni-app 输入框渲染为 <uni-input> 包装,需对其内部 <input> 真实输入(pressSequentially)才能触发 @input
*/
/** 设置日期输入(隐藏的 input[type=date],派发 change 触发组件更新) */
export async function setDateInput(page: Page, selector: string, value: string): Promise<void> {
await page.evaluate(([sel, v]) => {
const input = document.querySelector(sel) as HTMLInputElement | null
if (!input) return
input.value = v
input.dispatchEvent(new Event('change', { bubbles: true }))
}, [selector, value] as const)
await page.waitForTimeout(400)
}
/** 输入文本到 uni-input 包装的输入框(真实输入以触发 @input) */
export async function typeIntoInput(page: Page, selector: string, value: string, waitMs = 900): Promise<void> {
await page.locator(selector).pressSequentially(value, { delay: 30 })
await page.waitForTimeout(waitMs)
}
/** 输入出生地(uni-input 包装,触发坐标自动识别) */
export async function setBirthPlace(page: Page, value: string): Promise<void> {
await typeIntoInput(page, 'uni-input.text-input input', value)
}
/** 点击指定文本的按钮(.button-text 内容匹配) */
export async function clickButton(page: Page, text: string | RegExp): Promise<void> {
await page.locator('.button-text', { hasText: text }).first().click()
await page.waitForTimeout(500)
}
/** 打开 uni-app 自定义选择器并验证其弹出(返回是否打开) */
export async function openCustomPicker(page: Page): Promise<boolean> {
const opened = await page.evaluate(() => document.querySelectorAll('.uni-picker-item').length > 0)
return opened
}
/** 关闭 uni-app 自定义选择器(Cancel,容错) */
export async function closeCustomPicker(page: Page): Promise<void> {
await page.locator('.uni-picker-action-cancel').click({ force: true, timeout: 3000 }).catch(() => {})
await page.waitForTimeout(300)
}
/** 直接导航到指定页面 */
export async function gotoPage(page: Page, path: string): Promise<void> {
await page.goto(`/#${path}`)
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
}
@@ -0,0 +1,91 @@
import { test, expect } from '@playwright/test'
import { setDateInput, gotoPage } from './helpers'
/**
* 用户旅程 J-NAVEdge cases):跨页导航与状态保持旅程
*
* 用户目标:在三大功能页(黄历/排盘/运势)之间顺畅切换,且排盘数据跨页保持
*
* 旅程步骤:
* 1. 进入黄历页(首页),底部导航可见
* 2. 通过底部导航切换到排盘页
* 3. 通过底部导航切换到运势页
* 4. 导航往返(排盘 → 运势 → 黄历)
* 5. 排盘数据跨页保持(排盘后运势页可读取)
*
* 异常恢复场景:
* E1. 直接访问不存在的路由 → 应用可正常渲染(hash 路由容错)
*/
test.describe('旅程 J-NAV:跨页导航与状态保持旅程', () => {
test.describe.configure({ mode: 'serial' })
test('STEP 1: 进入黄历页,底部导航三入口可见', async ({ page }) => {
await gotoPage(page, '/pages/almanac-search/index')
await expect(page.locator('.bottom-navigation')).toBeVisible()
await expect(page.locator('.nav-item')).toHaveCount(3)
await expect(page.locator('.page-title')).toBeVisible()
})
test('STEP 2: 通过底部导航切换到排盘页', async ({ page }) => {
await gotoPage(page, '/pages/almanac-search/index')
await page.locator('.nav-item').nth(1).click()
await page.waitForTimeout(1500)
expect(page.url()).toContain('/#/pages/ziwei/index')
await expect(page.locator('[data-test="birth-form"]')).toBeVisible()
// 目标页必须有底部导航(回归防护:三 tab 页均需底部导航)
await expect(page.locator('.bottom-navigation')).toBeVisible()
await expect(page.locator('.nav-item')).toHaveCount(3)
})
test('STEP 3: 通过底部导航切换到运势页', async ({ page }) => {
await gotoPage(page, '/pages/almanac-search/index')
await page.locator('.nav-item').nth(2).click()
await page.waitForTimeout(1500)
expect(page.url()).toContain('/#/pages/fortune/index')
await expect(page.locator('[data-test="fortune-tabs"]')).toBeVisible()
// 目标页必须有底部导航
await expect(page.locator('.bottom-navigation')).toBeVisible()
await expect(page.locator('.nav-item')).toHaveCount(3)
})
test('STEP 4: 导航往返(排盘 → 运势 → 黄历)', async ({ page }) => {
await gotoPage(page, '/pages/ziwei/index')
await gotoPage(page, '/pages/fortune/index')
await expect(page.locator('[data-test="fortune-tabs"]')).toBeVisible()
await gotoPage(page, '/pages/almanac-search/index')
await expect(page.locator('.page-title')).toBeVisible()
await expect(page.locator('.bottom-navigation')).toBeVisible()
})
test('STEP 5: 排盘数据跨页保持', async ({ page }) => {
// 排盘页生成排盘(写入 localStorage
await gotoPage(page, '/pages/ziwei/index')
await setDateInput(page, 'input[type="date"]', '1990-01-01')
await page.locator('[data-test="generate-btn"]').click()
await page.waitForTimeout(1500)
await expect(page.locator('.chart-result')).toBeVisible()
// 跳转到运势页,验证能读取排盘数据
await gotoPage(page, '/pages/fortune/index')
// 运势页能读取已持久化的排盘数据:不应显示"请先排盘"提示,且运势交互区正常渲染
await expect(page.locator('.no-chart-hint')).toHaveCount(0)
await expect(page.locator('.daily-fortune')).toBeVisible()
})
test('E1: 直接访问不存在的路由 → 应用正常渲染(hash 容错)', async ({ page }) => {
await page.goto('/#/pages/nonexistent/index')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(1000)
// 应用不白屏,可正常导航回黄历页
await gotoPage(page, '/pages/almanac-search/index')
await expect(page.locator('.page-title')).toBeVisible()
})
})
@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test'
import { setDateInput, setBirthPlace, gotoPage, openCustomPicker, closeCustomPicker } from './helpers'
/**
* 用户旅程 J-ZIWEI(Critical):紫微斗数排盘完整旅程
*
* 用户目标:输入出生信息,生成完整命盘(十二宫位、三方四正分析、综合总结)
*
* 旅程步骤:
* 1. 进入排盘页(初始状态:表单可见、无结果)
* 2. 选择出生日期
* 3. 确认时辰选择器可用(H5 端为 3D 滚轮,精确选择由小程序原生 picker 提供)
* 4. 切换性别(女性)
* 5. 输入出生地(触发城市坐标自动识别)
* 6. 点击排盘
* 7. 验证十二宫位卡片
* 8. 验证三方四正分析
* 9. 验证综合总结
*
* 错误恢复场景:
* E1. 未选择日期直接排盘 → 不生成结果(守护逻辑)
* E2. 输入未知出生地 → 显示"未找到"提示
*/
test.describe('旅程 J-ZIWEI:紫微排盘完整旅程', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ page }) => {
await gotoPage(page, '/pages/ziwei/index')
})
test('STEP 1: 进入排盘页,表单可见且初始无排盘结果', async ({ page }) => {
await expect(page.locator('[data-test="birth-form"]')).toBeVisible()
await expect(page.locator('[data-test="generate-btn"]')).toBeVisible()
await expect(page.locator('.chart-result')).not.toBeVisible()
})
test('STEP 2-3: 选择出生日期并确认时辰选择器可用', async ({ page }) => {
await setDateInput(page, 'input[type="date"]', '1990-01-01')
const dateDisplay = await page.locator('.picker-value').first().textContent()
expect(dateDisplay).toContain('1990-01-01')
// 时辰选择器存在且默认值为子时
const hourDisplay = await page.locator('.picker-value').nth(1).textContent()
expect(hourDisplay).toContain('子时')
// 时辰选择器可打开(验证进入修改流程)
await page.locator('.picker-value').nth(1).click()
await page.waitForTimeout(600)
expect(await openCustomPicker(page)).toBe(true)
await closeCustomPicker(page)
})
test('STEP 4: 切换性别为女性', async ({ page }) => {
const options = page.locator('.gender-option')
await expect(options).toHaveCount(2)
await options.nth(1).click()
await expect(options.nth(1)).toHaveClass(/active/)
})
test('STEP 5: 输入出生地并触发坐标识别', async ({ page }) => {
await setBirthPlace(page, '北京')
const detected = page.locator('.location-hint.detected')
await expect(detected).toBeVisible()
})
test('STEP 6-9: 点击排盘,生成完整命盘', async ({ page }) => {
await setDateInput(page, 'input[type="date"]', '1990-01-01')
await page.locator('[data-test="generate-btn"]').click()
await page.waitForTimeout(1500)
// 十二宫位
await expect(page.locator('.chart-result')).toBeVisible()
await expect(page.locator('.palace-card')).toHaveCount(12)
// 三方四正分析
await expect(page.locator('.analysis-section')).toBeVisible()
const sanFang = await page.locator('.analysis-value').first().textContent()
expect(sanFang).toBeTruthy()
// 综合总结
await expect(page.locator('.summary-section')).toBeVisible()
const summary = await page.locator('.summary-text').textContent()
expect(summary && summary.trim().length).toBeGreaterThan(0)
})
test('E1: 未选择日期直接排盘 → 不生成结果(守护逻辑)', async ({ page }) => {
await page.locator('[data-test="generate-btn"]').click()
await page.waitForTimeout(1000)
await expect(page.locator('.chart-result')).not.toBeVisible()
})
test('E2: 输入未知出生地 → 显示未找到提示', async ({ page }) => {
await setBirthPlace(page, '不存在的城市XYZ')
await expect(page.locator('.location-hint.not-found')).toBeVisible()
})
})
+161 -8
View File
@@ -19,8 +19,8 @@
}, },
"devDependencies": { "devDependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001",
"@dcloudio/uni-ui": "1.5.12",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000820260420001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000820260420001",
"@fortawesome/fontawesome-free": "^7.3.1",
"@playwright/test": "1.57.0", "@playwright/test": "1.57.0",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "4.0.18", "@vitest/coverage-v8": "4.0.18",
@@ -28,6 +28,7 @@
"@vue/test-utils": "^2.4.9", "@vue/test-utils": "^2.4.9",
"jsdom": "^29.1.0", "jsdom": "^29.1.0",
"playwright": "1.57.0", "playwright": "1.57.0",
"postcss-px2rpx": "^0.0.4",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.99.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "5.2.8", "vite": "5.2.8",
@@ -2475,13 +2476,6 @@
"debug": "4.3.7" "debug": "4.3.7"
} }
}, },
"node_modules/@dcloudio/uni-ui": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/@dcloudio/uni-ui/-/uni-ui-1.5.12.tgz",
"integrity": "sha512-mGDl2OZSz7D8xcUAzJegWDHOqB4MEFBSW9Esb/oJiu2/3Gk9+P/Z4bA4JZ9jv9VWBYbMrYwaTfK1Z728kABdYg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@dcloudio/vite-plugin-uni": { "node_modules/@dcloudio/vite-plugin-uni": {
"version": "3.0.0-alpha-5000820260420001", "version": "3.0.0-alpha-5000820260420001",
"resolved": "https://registry.npmjs.org/@dcloudio/vite-plugin-uni/-/vite-plugin-uni-3.0.0-alpha-5000820260420001.tgz", "resolved": "https://registry.npmjs.org/@dcloudio/vite-plugin-uni/-/vite-plugin-uni-3.0.0-alpha-5000820260420001.tgz",
@@ -2967,6 +2961,16 @@
} }
} }
}, },
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
"dev": true,
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@intlify/core-base": { "node_modules/@intlify/core-base": {
"version": "9.1.9", "version": "9.1.9",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz", "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz",
@@ -6521,6 +6525,29 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
"integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/has-ansi/node_modules/ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@@ -6895,6 +6922,13 @@
"integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==", "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/js-base64": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz",
"integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/js-beautify": { "node_modules/js-beautify": {
"version": "1.15.4", "version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
@@ -8043,6 +8077,125 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/postcss-px2rpx": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/postcss-px2rpx/-/postcss-px2rpx-0.0.4.tgz",
"integrity": "sha512-NEPnQOhIOmK+R0nY+oGJLoZSyvBrSAV2O7LES/WWAhFP04siOUX6zE4vA97xbJdy8ahpR1H9XVRy4nfH/gX71Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss": "^5.2.6"
}
},
"node_modules/postcss-px2rpx/node_modules/ansi-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
"integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/ansi-styles": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
"integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
"integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^2.2.1",
"escape-string-regexp": "^1.0.2",
"has-ansi": "^2.0.0",
"strip-ansi": "^3.0.0",
"supports-color": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/chalk/node_modules/supports-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
"integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/postcss-px2rpx/node_modules/has-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
"integrity": "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/postcss": {
"version": "5.2.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
"integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^1.1.3",
"js-base64": "^2.1.9",
"source-map": "^0.5.6",
"supports-color": "^3.2.3"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/postcss-px2rpx/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/strip-ansi": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
"integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postcss-px2rpx/node_modules/supports-color": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
"integrity": "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^1.0.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/postcss-selector-parser": { "node_modules/postcss-selector-parser": {
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+6 -1
View File
@@ -12,7 +12,10 @@
"test:coverage": "vitest --run --coverage", "test:coverage": "vitest --run --coverage",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui",
"test:journeys": "playwright test e2e/journeys/ --project=chromium",
"test:journeys:critical": "playwright test e2e/journeys/ziwei-journey.spec.ts e2e/journeys/almanac-journey.spec.ts --project=chromium",
"test:journeys:report": "playwright show-report"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "^3.0.0-alpha-5000820260420001", "@dcloudio/uni-app": "^3.0.0-alpha-5000820260420001",
@@ -27,6 +30,7 @@
"devDependencies": { "devDependencies": {
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-5000820260420001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000820260420001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000820260420001",
"@fortawesome/fontawesome-free": "^7.3.1",
"@playwright/test": "1.57.0", "@playwright/test": "1.57.0",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vitest/coverage-v8": "4.0.18", "@vitest/coverage-v8": "4.0.18",
@@ -34,6 +38,7 @@
"@vue/test-utils": "^2.4.9", "@vue/test-utils": "^2.4.9",
"jsdom": "^29.1.0", "jsdom": "^29.1.0",
"playwright": "1.57.0", "playwright": "1.57.0",
"postcss-px2rpx": "^0.0.4",
"sass-embedded": "^1.99.0", "sass-embedded": "^1.99.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "5.2.8", "vite": "5.2.8",
@@ -0,0 +1,25 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxfd35f544ed1db523",
"editorSetting": {}
}
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
小程序 iconfont 生成工具(FontAwesome 子集化 + base64 输出)
用法: python3 scripts/iconfont-build.py <woff2路径> <输出css路径> [--names name1,name2,...]
示例: python3 scripts/iconfont-build.py node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2 src/styles/iconfont.css --names calendar,star,heart
功能:
1. 将 woff2 转为 ttf
2. 按 --names 指定的图标名子集化(保留最小字形,减小包体)
3. 生成 base64 内联 @font-face CSSfont-family: 'fa-icons'
4. 输出 unicode 映射 JSON(供 Icon 组件使用)
图标名 -> unicode 映射(FontAwesome v6 fa-solid):
"""
import sys
import json
import base64
import argparse
from pathlib import Path
from fontTools.ttLib import TTFont
from fontTools import subset
# FontAwesome v6 fa-solid 图标名 -> unicode 码点
ICON_NAMES = {
'arrow-up': 0xF062,
'arrow-down': 0xF063,
'arrow-left': 0xF060,
'arrow-right': 0xF061,
'check': 0xF00C,
'close': 0xF00D,
'right': 0xF054,
'checkbox-checked': 0xF14A,
'checkbox-unchecked': 0xF0C8,
'plus': 0xF067,
'minus': 0xF068,
'search': 0xF002,
'calendar': 0xF073,
'star': 0xF005,
'heart': 0xF004,
'settings': 0xF013,
'home': 0xF015,
'user': 0xF007,
'info': 0xF129,
'warning': 0xF071,
'refresh': 0xF021,
'download': 0xF019,
'upload': 0xF093,
'share': 0xF064,
'copy': 0xF0C5,
'delete': 0xF1F8,
'edit': 0xF044,
'filter': 0xF0B0,
'sort': 0xF0DC,
'export': 0xF14D,
'history': 0xF1DA,
'bookmark': 0xF02E,
'chevron-up': 0xF077,
'chevron-down': 0xF078,
'chevron-left': 0xF053,
'chevron-right': 0xF054,
}
def main():
parser = argparse.ArgumentParser(description='FontAwesome iconfont 子集化构建')
parser.add_argument('woff2', help='fa-solid woff2 字体路径')
parser.add_argument('output_css', help='输出 CSS 路径')
parser.add_argument('--names', help='逗号分隔的图标名(默认全部)')
parser.add_argument('--font-family', default='fa-icons', help='字体族名')
args = parser.parse_args()
# 解析图标名
if args.names:
names = [n.strip() for n in args.names.split(',') if n.strip()]
else:
names = list(ICON_NAMES.keys())
# 收集 unicode
unicodes = []
mapping = {}
missing = []
for n in names:
if n not in ICON_NAMES:
print(f'⚠️ 未知图标名: {n}'); continue
unicodes.append(ICON_NAMES[n])
mapping[n] = ICON_NAMES[n]
# 加载并验证 cmap
tmp_ttf = Path('/tmp/fa-icons-subset.ttf')
font = TTFont(args.woff2)
cmap = font.getBestCmap()
for n, cp in mapping.items():
if cp not in cmap:
missing.append(f'{n}(U+{cp:04X})')
if missing:
print(f'⚠️ 缺失字形: {", ".join(missing)}')
print(f'图标数: {len(mapping)}')
# 子集化
options = subset.Options()
options.desubroutinize = True
options.ignore_missing_glyphs = True
options.drop_tables += ['GSUB', 'GPOS', 'GDEF', 'kern', 'cmap'] if False else []
sub = subset.Subsetter(options=options)
sub.populate(unicodes=unicodes)
sub.subset(font)
font.save(str(tmp_ttf))
# base64
b64 = base64.b64encode(tmp_ttf.read_bytes()).decode()
print(f'子集 ttf: {(tmp_ttf.stat().st_size/1024):.0f}KB | base64: {(len(b64)/1024):.0f}KB')
# 生成 CSS
css = f"""/* 自动生成: scripts/iconfont-build.py —— FontAwesome 子集化图标字体(仅含所需字形) */
@font-face {{
font-family: '{args.font_family}';
src: url(data:font/ttf;charset=utf-8;base64,{b64}) format('truetype');
font-weight: normal;
font-style: normal;
}}
"""
out = Path(args.output_css)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(css)
print(f'CSS 已生成: {out} ({len(css)/1024:.0f}KB)')
# 输出映射 JSON(供 Icon 组件)
json_path = Path(args.output_css).with_suffix('.json')
json_path.write_text(json.dumps(mapping, ensure_ascii=False, indent=2))
print(f'映射已生成: {json_path}')
if __name__ == '__main__':
main()
@@ -0,0 +1,144 @@
#!/usr/bin/env node
/**
* 小程序构建产物 UI 层级审计工具
* 用法: node scripts/ui-audit-mp-weixin.mjs [--dist <path>] [--report <path>]
* 默认 dist: dist/build/mp-weixin
*
* 审计维度:
* 1. 页面结构 (wxml): 根元素、关键 UI 区块、自定义组件引用
* 2. 样式 (wxss): 关键样式类存在性、rpx 单位使用
* 3. 配置 (json): navigationBarTitleText、usingComponents、tabBar
* 4. i18n: 模板中 $t() 键的引用一致性(渲染不依赖 missing key
* 5. 组件引用完整性: wxml 引用的自定义组件在 components/ 存在
*/
import { readdirSync, readFileSync, existsSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
import { join, resolve, relative, extname } from 'node:path'
const args = process.argv.slice(2)
const dist = resolve(args.includes('--dist') ? args[args.indexOf('--dist') + 1] : 'dist/build/mp-weixin')
const reportPath = args.includes('--report')
? resolve(args[args.indexOf('--report') + 1])
: join(process.cwd(), '../docs/reports/UI_AUDIT_mp-weixin.md')
function walk(dir, ext, out = []) {
if (!existsSync(dir)) return out
for (const f of readdirSync(dir)) {
const p = join(dir, f)
if (statSync(p).isDirectory()) walk(p, ext, out)
else if (f.endsWith(ext)) out.push(p)
}
return out
}
const issues = []
const passes = []
const seen = new Set()
function pass(msg) {
if (!seen.has(msg)) { seen.add(msg); passes.push(msg) }
}
function issue(sev, msg) {
issues.push(`[${sev}] ${msg}`)
}
/* ---------- 1. 页面结构审计 ---------- */
const wxmlFiles = walk(dist, '.wxml')
const pageWxml = wxmlFiles.filter((f) => f.includes('/pages/'))
const componentWxml = wxmlFiles.filter((f) => f.includes('/components/'))
for (const f of pageWxml) {
const rel = relative(dist, f)
const content = readFileSync(f, 'utf8')
// 根元素
if (!/<view|root|template/.test(content)) issue('P1', `${rel}: 无根元素`)
else pass(`${rel}: 有根元素`)
// 关键区块
for (const block of ['page-header', 'page-content']) {
if (content.includes(block)) pass(`${rel}: 包含 ${block}`)
}
// 自定义组件引用
const compTags = [...content.matchAll(/<([a-z][a-z0-9-]*)\b/g)].map((m) => m[1])
const compDirs = existsSync(join(dist, 'components')) ? readdirSync(join(dist, 'components')) : []
for (const tag of new Set(compTags)) {
const builtin = ['view', 'text', 'button', 'input', 'image', 'scroll-view', 'picker', 'checkbox', 'radio', 'swiper', 'swiper-item', 'form', 'label', 'navigator', 'canvas', 'map', 'video', 'rich-text', 'progress', 'slider', 'switch', 'textarea', 'icon']
if (builtin.includes(tag) || tag === 'template') continue
// kebab-case -> PascalCase(组件目录为驼峰),并忽略大小写匹配
const tagPascal = tag.replace(/(^|-)([a-z])/g, (_m, _p, c) => c.toUpperCase())
const matched = compDirs.some((d) => d === tagPascal || d.toLowerCase() === tag.toLowerCase())
if (matched) pass(`${rel}: 组件 ${tag} 存在`)
else issue('P1', `${rel}: 引用未知组件 <${tag}> (components/ 无匹配目录)`)
}
}
/* ---------- 2. 样式审计 ---------- */
const wxssFiles = walk(dist, '.wxss')
for (const f of pageWxml) {
const rel = relative(dist, f)
const wxssPath = f.replace(/\.wxml$/, '.wxss')
if (existsSync(wxssPath)) pass(`${rel}: wxss 存在`)
else issue('P1', `${rel}: wxss 缺失`)
}
/* ---------- 3. 配置审计 ---------- */
const pageJson = pageWxml.map((f) => f.replace(/\.wxml$/, '.json'))
for (const f of pageJson) {
const rel = relative(dist, f)
if (!existsSync(f)) { issue('P1', `${rel}: json 缺失`); continue }
const cfg = JSON.parse(readFileSync(f, 'utf8'))
if (cfg.navigationBarTitleText) pass(`${rel}: 导航栏标题=${cfg.navigationBarTitleText}`)
if (cfg.usingComponents && Object.keys(cfg.usingComponents).length > 0) {
pass(`${rel}: usingComponents ${Object.keys(cfg.usingComponents).length}`)
}
}
const appJsonPath = join(dist, 'app.json')
if (existsSync(appJsonPath)) {
const app = JSON.parse(readFileSync(appJsonPath, 'utf8'))
pass(`app.json: pages ${app.pages.length}`)
if (app.tabBar && app.tabBar.list) pass(`app.json: tabBar ${app.tabBar.list.length}`)
if (app.window) pass(`app.json: window 配置存在`)
else issue('P2', 'app.json: window 配置缺失')
} else {
issue('P0', 'app.json 缺失')
}
/* ---------- 4. i18n 键审计 ---------- */
const zhCN = readFileSync(join(dist, 'locales/zh-CN.js'), 'utf8')
for (const f of pageWxml) {
const rel = relative(dist, f)
const content = readFileSync(f, 'utf8')
// 提取模板中 $t('key') 或 {{a}} 等(小程序端 $t 被编译进 render,此处检查 wxml 是否含已编译变量标记)
const tKeys = [...content.matchAll(/[\$]t\(['"]([^'"]+)['"]\)/g)].map((m) => m[1])
for (const k of tKeys) {
if (zhCN.includes(k.replace(/\./g, '/'))) pass(`${rel}: i18n key ${k} 存在于语言包`)
else issue('P2', `${rel}: 模板 i18n key ${k} 可能缺失`)
}
}
/* ---------- 输出 ---------- */
const now = new Date()
const lines = [
`# 小程序构建产物 UI 审计报告`,
``,
`- **审计时间**: ${now.toISOString()}`,
`- **构建产物**: ${dist}`,
`- **结果**: ${issues.length === 0 ? '✅ 全部通过' : '❌ 发现 ' + issues.length + ' 个问题'}`,
``,
`## 统计`,
`- 页面 wxml: ${pageWxml.length}`,
`- 组件 wxml: ${componentWxml.length}`,
`- wxss: ${wxssFiles.length}`,
`- 通过项: ${passes.length}`,
`- 问题项: ${issues.length}`,
``,
`## 通过项`,
...passes.map((p) => `- ✅ ${p}`),
``,
`## 问题项`,
...(issues.length ? issues.map((i) => `- ⚠️ ${i}`) : ['- 无']),
]
mkdirSync(join(reportPath, '..'), { recursive: true })
writeFileSync(reportPath, lines.join('\n'))
console.log(lines.join('\n'))
console.log(`\n报告已保存: ${reportPath}`)
process.exit(issues.some((i) => i.startsWith('[P0]') || i.startsWith('[P1]')) ? 1 : 0)
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* UI 视觉验收截图工具(H5 端渲染,uni-app 同源码同样式)
* 用法: node scripts/ui-screenshots.mjs [--base <url>] [--out <dir>]
* 默认 base: http://localhost:5175 out: ../docs/reports/screenshots
*
* 对每个核心页面截图并检查关键 UI 元素,输出 UI 验收结果(JSON + 摘要)。
*/
import { chromium } from 'playwright'
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
const args = process.argv.slice(2)
const base = args.includes('--base') ? args[args.indexOf('--base') + 1] : 'http://localhost:5175'
const outDir = resolve(args.includes('--out') ? args[args.indexOf('--out') + 1] : '../docs/reports/screenshots')
// 页面定义: 路径 + 关键 UI 元素检查(body 关键词,语言兼容)+ class 检查
const pages = [
{
name: '01-almanac-search',
path: '/pages/almanac-search/index',
keywords: [/今日黄历|Today/, /宜|Suitable/, /忌|Unsuitable/, /搜索天数|Search Days/, /搜索模板|Template/],
classes: ['.today-almanac', '.template-panel', '.search-condition-panel', '.bottom-navigation'],
},
{
name: '02-ziwei',
path: '/pages/ziwei/index',
keywords: [/紫微斗数|Zi Wei Dou Shu/, /出生信息|Birth Info/, /排盘|Chart/],
classes: ['.bottom-navigation'],
},
{
name: '03-fortune',
path: '/pages/fortune/index',
keywords: [/运势|Fortune/],
classes: ['.bottom-navigation'],
},
]
mkdirSync(outDir, { recursive: true })
const browser = await chromium.launch()
const report = []
let passAll = true
for (const p of pages) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 })
const shotPath = join(outDir, `${p.name}.png`)
const checks = []
try {
await page.goto(`${base}/#${p.path}`, { waitUntil: 'networkidle', timeout: 30000 })
await page.waitForTimeout(1500)
// 关键词检查(body 内容,语言兼容)
const bodyText = (await page.textContent('body')) || ''
for (const kw of p.keywords) {
const pass = kw.test(bodyText)
checks.push({ label: `关键词 ${kw}`, pass })
if (!pass) passAll = false
}
// class 检查
for (const sel of p.classes || []) {
const cnt = await page.locator(sel).count()
const pass = cnt > 0
checks.push({ label: `class ${sel}`, pass })
if (!pass) passAll = false
}
await page.screenshot({ path: shotPath, fullPage: false })
report.push({ page: p.name, path: p.path, checks, screenshot: shotPath })
console.log(`${p.name}: 截图成功,${checks.filter((c) => c.pass).length}/${checks.length} 元素通过`)
} catch (e) {
passAll = false
report.push({ page: p.name, path: p.path, checks, error: String(e).slice(0, 120) })
console.log(`${p.name}: 加载失败 ${String(e).slice(0, 100)}`)
} finally {
await page.close()
}
}
await browser.close()
// 输出 JSON 报告
const jsonPath = join(outDir, 'ui-verification.json')
writeFileSync(jsonPath, JSON.stringify({ base, generatedAt: new Date().toISOString(), passAll, report }, null, 2))
console.log(`\n${passAll ? '✅ 全部页面 UI 元素通过' : '❌ 存在未通过元素'}`)
console.log(`截图目录: ${outDir}`)
process.exit(passAll ? 0 : 1)
+36
View File
@@ -13,6 +13,42 @@ export default {
</script> </script>
<style> <style>
/* 设计令牌:统一品牌色(深褐主色 + 印红强调色)+ 圆角/阴影/过渡 */
page,
:root,
page[data-theme] {
/* 颜色 */
--color-primary: #2c1810; /* 深褐主品牌色 rgb(44,24,16) */
--color-primary-light: rgba(44, 24, 16, 0.08); /* 主色浅衬底 */
--color-accent: #c41e3a; /* 印红强调色 rgb(196,30,58) */
--color-accent-light: rgba(196, 30, 58, 0.08); /* 强调色浅衬底 */
--color-success: #52c41a; /* 成功/宜语义绿 rgb(82,196,26) */
--color-success-bg: rgba(220, 252, 231, 1); /* 选中态浅绿衬底 */
--color-success-border: rgba(185, 248, 207, 1); /* 选中态浅绿描边 */
--color-danger: #dc2626; /* 危险操作红 rgb(220,38,38) */
--color-danger-active: #b91c1c; /* 危险操作按压加深 rgb(185,28,28) */
--color-background: #ffffff;
--color-surface: rgba(244, 244, 245, 255); /* 灰面 #f4f4f5 */
--color-border: rgba(230, 225, 220, 255); /* 边框 #e6e1dc */
--color-text: #333333;
--color-text-strong: #2c1810;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-disabled: rgba(244, 244, 245, 255);
--color-hover: rgba(230, 225, 220, 255);
/* 圆角 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
/* 阴影 */
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
/* 过渡 */
--transition-fast: 0.2s ease;
}
page { page {
background-color: #f4f4f5; background-color: #f4f4f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
@@ -8,6 +8,7 @@ import de from '../../locales/de'
import fr from '../../locales/fr' import fr from '../../locales/fr'
import es from '../../locales/es' import es from '../../locales/es'
import pt from '../../locales/pt' import pt from '../../locales/pt'
import { i18n, I18N_OPTIONS } from '../../locales'
// 递归收集所有键路径 // 递归收集所有键路径
function collectKeys(obj: Record<string, any>, prefix = ''): string[] { function collectKeys(obj: Record<string, any>, prefix = ''): string[] {
@@ -98,3 +99,37 @@ describe('TC-I18N-001~009: 国际化完整性验证', () => {
}) })
} }
}) })
describe('TC-I18N-010: i18n 配置验证(防 P0 回归)', () => {
it('createI18n 应启用 globalInjection,确保小程序端模板 $t 可用', () => {
// legacy:false 模式下 globalInjection 必须为 true
// 否则小程序端模板渲染报 "TypeError: a.$t is not a function",全部页面异常
expect(I18N_OPTIONS.globalInjection).toBe(true)
})
it('createI18n 应使用 Composition APIlegacy: false', () => {
expect(I18N_OPTIONS.legacy).toBe(false)
expect((i18n as any).mode).toBe('composition')
})
it('app.use(i18n) 后应在 globalProperties 注入 $t(小程序端模板可用)', async () => {
const { createSSRApp } = await import('vue')
const app = createSSRApp({ render: () => null })
app.use(i18n)
// globalInjection:true 会将 $t/$rt 注入 Vue 全局属性,供模板/render 使用
const globalProperties = (app as any).config.globalProperties
expect(globalProperties).toBeDefined()
expect(typeof globalProperties.$t).toBe('function')
expect(typeof globalProperties.$rt).toBe('function')
})
it('Messages Functions 应正确插值(uni-app 小程序端不支持 {var} 字符串插值,必须用函数)', () => {
i18n.global.locale.value = 'zh-CN'
// 小程序端 {count} 字符串插值失效,函数形式是官方兼容方案
expect(i18n.global.t('search.daysUnit', { count: 30 })).toBe('30天')
expect(i18n.global.t('search.resultCount', { count: 20 })).toBe('共20条结果')
expect(i18n.global.t('fortune.overallScore', { score: 55 })).toBe('综合运势:55分')
expect(i18n.global.t('search.dateFormat', { year: 2026, month: 8, day: 18 })).toBe('2026年8月18日')
expect(i18n.global.t('search.historyConditionDays', { count: 2, days: 30 })).toBe('2个条件,30天范围')
})
})
@@ -47,9 +47,11 @@ const tabs = computed<Tab[]>(() => [
]) ])
const switchTab = (key: string) => { const switchTab = (key: string) => {
const tab = tabs.find(t => t.key === key) const tab = tabs.value.find(t => t.key === key)
if (tab) { if (tab) {
uni.navigateTo({ url: tab.path }) // 使用 redirectTo 替换当前页:tab 切换不应累积页面栈,
// 返回键可直接退出而非在三个 tab 页之间层层回退
uni.redirectTo({ url: tab.path })
} }
} }
</script> </script>
@@ -150,11 +150,11 @@ const handleClick = () => {
} }
.button--danger { .button--danger {
background-color: rgba(220, 38, 38, 255); background-color: var(--color-danger);
color: #ffffff; color: #ffffff;
&:active:not(.button--disabled):not(.button--loading) { &:active:not(.button--disabled):not(.button--loading) {
background-color: rgba(185, 28, 28, 255); background-color: var(--color-danger-active);
} }
} }
@@ -1,3 +1,7 @@
<!--
已实现但当前未接入任何页面预留组件
若后续要对搜索结果做导出在页面中引入并监听 export 事件即可
-->
<template> <template>
<view class="export-panel"> <view class="export-panel">
<Button <Button
@@ -208,8 +212,8 @@ function confirmExport() {
} }
.format-option--selected { .format-option--selected {
background: rgba(220, 252, 231, 1); background: var(--color-success-bg);
border: 1px solid rgba(185, 248, 207, 1); border: 1px solid var(--color-success-border);
} }
.format-label { .format-label {
@@ -238,8 +242,8 @@ function confirmExport() {
} }
.content-option--selected { .content-option--selected {
background: rgba(220, 252, 231, 1); background: var(--color-success-bg);
border: 1px solid rgba(185, 248, 207, 1); border: 1px solid var(--color-success-border);
} }
.content-label { .content-label {
@@ -22,48 +22,52 @@ defineEmits<{
click: [] click: []
}>() }>()
const iconMap: Record<string, string> = { const iconMap: Record<string, number> = {
'arrow-up': '↑', 'arrow-up': 0xf062,
'arrow-down': '↓', 'arrow-down': 0xf063,
'arrow-left': '←', 'arrow-left': 0xf060,
'arrow-right': '→', 'arrow-right': 0xf061,
'check': '✓', 'check': 0xf00c,
'close': '✕', 'close': 0xf00d,
'plus': '+', 'right': 0xf054,
'minus': '', 'checkbox-checked': 0xf14a,
'search': '🔍', 'checkbox-unchecked': 0xf0c8,
'empty': '📭', 'plus': 0xf067,
'calendar': '📅', 'minus': 0xf068,
'star': '★', 'search': 0xf002,
'heart': '♥', 'empty': 0xf05e,
'settings': '⚙', 'calendar': 0xf073,
'home': '🏠', 'star': 0xf005,
'user': '👤', 'heart': 0xf004,
'info': '', 'settings': 0xf013,
'warning': '⚠', 'home': 0xf015,
'error': '✕', 'user': 0xf007,
'success': '✓', 'info': 0xf129,
'refresh': '↻', 'warning': 0xf071,
'download': '⬇', 'error': 0xf00d,
'upload': '⬆', 'success': 0xf00c,
'share': '↗', 'refresh': 0xf021,
'copy': '📋', 'download': 0xf019,
'delete': '🗑', 'upload': 0xf093,
'edit': '✎', 'share': 0xf064,
'filter': '⊞', 'copy': 0xf0c5,
'sort': '⇅', 'delete': 0xf1f8,
'export': '📤', 'edit': 0xf044,
'import': '📥', 'filter': 0xf0b0,
'history': '🕐', 'sort': 0xf0dc,
'bookmark': '🔖', 'export': 0xf14d,
'chevron-up': '', 'import': 0xf093,
'chevron-down': '', 'history': 0xf1da,
'chevron-left': '', 'bookmark': 0xf02e,
'chevron-right': '' 'chevron-up': 0xf077,
'chevron-down': 0xf078,
'chevron-left': 0xf053,
'chevron-right': 0xf054
} }
const iconChar = computed(() => { const iconChar = computed(() => {
return iconMap[props.name] || props.name.charAt(0).toUpperCase() const cp = iconMap[props.name]
return cp ? String.fromCodePoint(cp) : props.name.charAt(0).toUpperCase()
}) })
const iconStyle = computed(() => ({ const iconStyle = computed(() => ({
@@ -92,5 +96,7 @@ const textStyle = computed(() => ({
.icon-text { .icon-text {
text-align: center; text-align: center;
line-height: 1; line-height: 1;
/* FontAwesome 子集化图标字体(跨端可靠渲染,含微信小程序) */
font-family: 'fa-icons', sans-serif;
} }
</style> </style>
@@ -189,8 +189,8 @@ watch(() => props.condition, (newCondition) => {
} }
.item-tag-selected { .item-tag-selected {
background-color: rgba(220, 252, 231, 1); background-color: var(--color-success-bg);
border-color: rgba(185, 248, 207, 1); border-color: var(--color-success-border);
} }
.item-text { .item-text {
@@ -168,8 +168,8 @@ const handleSearch = () => {
} }
.day-tag-selected { .day-tag-selected {
background-color: rgba(220, 252, 231, 1); background-color: var(--color-success-bg);
border-color: rgba(185, 248, 207, 1); border-color: var(--color-success-border);
} }
.day-text { .day-text {
@@ -1,3 +1,7 @@
<!--
已实现但当前未接入任何页面预留组件
若后续要在搜索结果区展示"搜索历史"在页面中引入并监听 select 事件即可
-->
<template> <template>
<Card class="search-history-panel"> <Card class="search-history-panel">
<view class="panel-header"> <view class="panel-header">
@@ -108,7 +108,7 @@ const formatDate = (dateStr: string) => {
} }
.suitable-label { .suitable-label {
color: rgba(0, 130, 54, 1); color: var(--color-success);
} }
.unsuitable-label { .unsuitable-label {
@@ -122,7 +122,7 @@ const formatDate = (dateStr: string) => {
} }
.suitable-items { .suitable-items {
color: rgba(1, 102, 48, 1); color: #33941a;
} }
.unsuitable-items { .unsuitable-items {
@@ -14,27 +14,20 @@
<scroll-view <scroll-view
class="list-scroll" class="list-scroll"
scroll-y scroll-y
@scrolltolower="handleScrollToLower"
> >
<SearchResultCard <SearchResultCard
v-for="result in results" v-for="result in results"
:key="result.date" :key="result.date"
:result="result" :result="result"
/> />
<view v-if="loadingMore" class="loading-more">
<LoadingIndicator :text="$t('common.loading')" />
</view>
</scroll-view> </scroll-view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import type { SearchResult } from '../../types/search' import type { SearchResult } from '../../types/search'
import SearchResultCard from '../SearchResultCard/index.vue' import SearchResultCard from '../SearchResultCard/index.vue'
import SortSwitcher from '../SortSwitcher/index.vue' import SortSwitcher from '../SortSwitcher/index.vue'
import LoadingIndicator from '../LoadingIndicator/index.vue'
import Typography from '../Typography/Typography.vue' import Typography from '../Typography/Typography.vue'
interface Props { interface Props {
@@ -48,11 +41,8 @@ const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:sortBy': [value: 'date' | 'matchCount'] 'update:sortBy': [value: 'date' | 'matchCount']
'update:sortOrder': [value: 'asc' | 'desc'] 'update:sortOrder': [value: 'asc' | 'desc']
loadMore: []
}>() }>()
const loadingMore = ref(false)
const handleSortByChange = (value: 'date' | 'matchCount') => { const handleSortByChange = (value: 'date' | 'matchCount') => {
emit('update:sortBy', value) emit('update:sortBy', value)
} }
@@ -60,16 +50,6 @@ const handleSortByChange = (value: 'date' | 'matchCount') => {
const handleSortOrderChange = (value: 'asc' | 'desc') => { const handleSortOrderChange = (value: 'asc' | 'desc') => {
emit('update:sortOrder', value) emit('update:sortOrder', value)
} }
const handleScrollToLower = () => {
if (!loadingMore.value) {
loadingMore.value = true
setTimeout(() => {
emit('loadMore')
loadingMore.value = false
}, 300)
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -100,13 +80,6 @@ const handleScrollToLower = () => {
overflow-y: auto; overflow-y: auto;
} }
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
@media screen and (max-width: 375px) { @media screen and (max-width: 375px) {
.search-result-list { .search-result-list {
gap: 12px; gap: 12px;
@@ -125,9 +98,5 @@ const handleScrollToLower = () => {
.list-scroll { .list-scroll {
max-height: 500px; max-height: 500px;
} }
.loading-more {
padding: 16px;
}
} }
</style> </style>
@@ -96,8 +96,8 @@ const handleSortByChange = (value: 'date' | 'matchCount') => {
} }
.sort-option-active { .sort-option-active {
background-color: rgba(220, 252, 231, 1); background-color: var(--color-success-bg);
border-color: rgba(185, 248, 207, 1); border-color: var(--color-success-border);
} }
.sort-option-text { .sort-option-text {
@@ -0,0 +1,183 @@
<template>
<view v-if="almanac" class="today-almanac">
<!-- 标题 -->
<view class="today-header">
<text class="today-title">{{ $t('almanac.todayTitle') }}</text>
<text v-if="almanac.lunarDate.solarTerm" class="today-term">{{ almanac.lunarDate.solarTerm }}</text>
</view>
<!-- 公历/农历/生肖/干支 -->
<text class="today-solar">{{ almanac.solarDate }}</text>
<text class="today-lunar">
{{ $t('almanac.lunarDate') }}{{ almanac.lunarDate.lunarYear }} {{ almanac.lunarDate.lunarMonth }}{{ almanac.lunarDate.lunarDay }}
<text v-if="almanac.lunarDate.zodiac"> · {{ almanac.lunarDate.zodiac }}</text>
</text>
<!-- 值神/吉神 -->
<view class="god-row">
<text class="god-badge">{{ $t('almanac.jianChu') }}{{ almanac.jianChu }}</text>
<text class="god-badge">{{ $t('almanac.starGod') }}{{ almanac.starGod }}</text>
</view>
<!-- / -->
<view class="suitable-row">
<text class="label suitable">{{ $t('search.suitable') }}</text>
<text class="items suitable-items">{{ almanac.suitable.join('、') }}</text>
</view>
<view class="unsuitable-row">
<text class="label unsuitable">{{ $t('search.unsuitable') }}</text>
<text class="items unsuitable-items">{{ almanac.unsuitable.join('、') }}</text>
</view>
<!-- 吉神方位 / 冲煞 -->
<view class="direction-row">
<text class="info-item">{{ $t('almanac.godDirection') }}{{ almanac.godDirection }}</text>
<text class="info-item">{{ $t('almanac.clash') }}{{ almanac.clash }}{{ $t('almanac.evil') }} {{ almanac.evil }}</text>
</view>
<!-- 纳音 / 胎神 / 彭祖百忌 -->
<view class="detail-list">
<text class="detail-item">{{ $t('almanac.naYin') }}{{ almanac.naYin }}</text>
<text class="detail-item">{{ $t('almanac.fetusGod') }}{{ almanac.fetusGod }}</text>
<text class="detail-item">{{ $t('almanac.pengzuTaboo') }}{{ almanac.pengzuTaboo }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import type { Almanac } from '../../algorithms/types'
defineProps<{
almanac: Almanac | null
}>()
</script>
<style scoped lang="scss">
.today-almanac {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.today-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.today-title {
font-size: 18px;
font-weight: 700;
color: rgba(44, 24, 16, 255);
}
.today-term {
font-size: 12px;
color: #c41e3a;
background-color: rgba(196, 30, 58, 0.08);
padding: 2px 8px;
border-radius: 10px;
}
.today-solar {
font-size: 14px;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 2px;
}
.today-lunar {
font-size: 13px;
color: #666;
display: block;
margin-bottom: 10px;
}
.god-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.god-badge {
font-size: 12px;
color: rgba(44, 24, 16, 255);
background-color: #f5f5f5;
padding: 3px 10px;
border-radius: 12px;
}
.suitable-row,
.unsuitable-row {
display: flex;
gap: 10px;
padding: 8px;
border-radius: 8px;
margin-bottom: 8px;
}
.suitable-row {
background-color: var(--color-success-bg);
}
.unsuitable-row {
background-color: rgba(254, 226, 226, 1);
}
.label {
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.label.suitable {
color: var(--color-success);
}
.label.unsuitable {
color: rgba(196, 30, 58, 255);
}
.items {
font-size: 13px;
line-height: 20px;
flex: 1;
}
.suitable-items {
color: #33941a;
}
.unsuitable-items {
color: rgba(196, 30, 58, 255);
}
.direction-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 0;
border-top: 1px solid #f0f0f0;
}
.info-item {
font-size: 13px;
color: #666;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.detail-item {
font-size: 12px;
line-height: 18px;
color: #666;
}
</style>
@@ -1,3 +1,7 @@
<!--
已实现但当前未接入任何页面预留组件提供触觉反馈/长按/涟漪能力
若后续需要可点击区域的高级触控行为在页面中引入即可
-->
<template> <template>
<view <view
class="touchable-container" class="touchable-container"
@@ -1,5 +1,8 @@
<template> <template>
<view :class="['typography', `typography--${variant}`, `typography--${weight}`, { 'typography--ellipsis': ellipsis }]"> <view
:class="['typography', `typography--${variant}`, `typography--${weight}`, { 'typography--ellipsis': ellipsis }]"
:style="{ color: color ? color : undefined }"
>
<slot /> <slot />
</view> </view>
</template> </template>
@@ -9,12 +12,15 @@ interface Props {
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'overline' variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'overline'
weight?: 'regular' | 'medium' | 'semibold' | 'bold' weight?: 'regular' | 'medium' | 'semibold' | 'bold'
ellipsis?: boolean ellipsis?: boolean
/** 覆盖默认文本色,如组件插在品牌色背景上时需传 #FFFFFF */
color?: string
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
variant: 'body', variant: 'body',
weight: 'regular', weight: 'regular',
ellipsis: false ellipsis: false,
color: ''
}) })
</script> </script>
@@ -21,7 +21,21 @@ export default {
searching: 'Suche...', searching: 'Suche...',
searchFailed: 'Suche fehlgeschlagen, bitte versuchen Sie es später erneut', searchFailed: 'Suche fehlgeschlagen, bitte versuchen Sie es später erneut',
noResult: 'Keine Ergebnisse entsprechen den Kriterien', noResult: 'Keine Ergebnisse entsprechen den Kriterien',
noResultHelp: 'Passen Sie Ihre Suchkriterien an und versuchen Sie es erneut',
noSearchYetTitle: 'Willkommen bei Everything Suitable',
noSearchYetDesc: 'Wählen Sie oben eine Vorlage aus oder fügen Sie Suchbedingungen hinzu',
startSearch: 'Suche starten',
keywordHint: 'Suchbegriffe eingeben', keywordHint: 'Suchbegriffe eingeben',
todayTitle: 'Heutiger Almanach',
lunarDate: 'Mondkalender',
jianChu: 'Jianchu',
starGod: 'Sternengott',
godDirection: 'Götterrichtung',
clash: 'Kollision',
evil: 'Übelrichtung',
naYin: 'Na Yin',
fetusGod: 'Fötusgott',
pengzuTaboo: 'Pengzu-Tabu',
}, },
ziwei: { ziwei: {
pageTitle: 'Zi Wei Dou Shu', pageTitle: 'Zi Wei Dou Shu',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: 'Bitte auswählen', pleaseSelect: 'Bitte auswählen',
generating: 'Diagramm wird erstellt...', generating: 'Diagramm wird erstellt...',
generate: 'Diagramm erstellen', generate: 'Diagramm erstellen',
requireBirthDate: 'Bitte wählen Sie zuerst ein Geburtsdatum',
selectBirthDateFirst: 'Wählen Sie zuerst das Geburtsdatum',
generateFailed: 'Diagramm konnte nicht erstellt werden. Bitte versuchen Sie es später erneut',
chartTitle: 'Zi Wei Dou Shu Diagramm', chartTitle: 'Zi Wei Dou Shu Diagramm',
sanFangAnalysis: 'San Fang Si Zheng Analyse', sanFangAnalysis: 'San Fang Si Zheng Analyse',
sanFangScore: 'San Fang Punktzahl', sanFangScore: 'San Fang Punktzahl',
@@ -50,7 +67,7 @@ export default {
monthly: 'Monatlich', monthly: 'Monatlich',
selectDate: 'Datum auswählen', selectDate: 'Datum auswählen',
selectMonth: 'Monat auswählen', selectMonth: 'Monat auswählen',
overallScore: 'Gesamtschicksal: {score}Pkt', overallScore: ({ named }) => `Gesamtschicksal: ${named('score')}Pkt`,
career: 'Karriere: ', career: 'Karriere: ',
wealth: 'Wohlstand: ', wealth: 'Wohlstand: ',
relationship: 'Beziehung: ', relationship: 'Beziehung: ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'Glückszahl: ', luckyNumber: 'Glückszahl: ',
luckyDirection: 'Glücksrichtung: ', luckyDirection: 'Glücksrichtung: ',
noChartHint: 'Bitte erstellen Sie zuerst ein Diagramm auf der Zi Wei Dou Shu Seite', noChartHint: 'Bitte erstellen Sie zuerst ein Diagramm auf der Zi Wei Dou Shu Seite',
goToZiwei: 'Diagramm erstellen',
}, },
search: { search: {
searchConditions: 'Suchbedingungen', searchConditions: 'Suchbedingungen',
addCondition: 'Bedingung hinzufügen', addCondition: 'Bedingung hinzufügen',
addConditionHint: 'Bitte fügen Sie mindestens eine Suchbedingung hinzu', addConditionHint: 'Bitte fügen Sie mindestens eine Suchbedingung hinzu',
searchDays: 'Suchtage', searchDays: 'Suchtage',
daysUnit: '{count} Tage', daysUnit: ({ named }) => `${named('count')} Tage`,
searchResults: 'Suchergebnisse', searchResults: 'Suchergebnisse',
resultCount: '{count} Ergebnisse', resultCount: ({ named }) => `${named('count')} Ergebnisse`,
matchCount: 'Übereinstimmung', matchCount: 'Übereinstimmung',
sort: 'Sortieren', sort: 'Sortieren',
sortByDate: 'Datum', sortByDate: 'Datum',
@@ -79,17 +97,17 @@ export default {
excludeCondition: 'Bedingung ausschließen', excludeCondition: 'Bedingung ausschließen',
activityItems: 'Einträge', activityItems: 'Einträge',
logicalOperator: 'Logischer Operator', logicalOperator: 'Logischer Operator',
dateFormat: '{day}.{month}.{year}', dateFormat: ({ named }) => `${named('day')}.${named('month')}.${named('year')}`,
searchHistory: 'Suchverlauf', searchHistory: 'Suchverlauf',
clearHistory: 'Verlauf löschen', clearHistory: 'Verlauf löschen',
noHistoryTitle: 'Kein Suchverlauf', noHistoryTitle: 'Kein Suchverlauf',
noHistoryDesc: 'Suchbedingungen werden nach der Suche hier angezeigt', noHistoryDesc: 'Suchbedingungen werden nach der Suche hier angezeigt',
confirmClear: 'Löschen bestätigen', confirmClear: 'Löschen bestätigen',
confirmClearContent: 'Möchten Sie den gesamten Suchverlauf löschen?', confirmClearContent: 'Möchten Sie den gesamten Suchverlauf löschen?',
historyConditionDays: '{count} Bedingungen, {days} Tage Bereich', historyConditionDays: ({ named }) => `${named('count')} Bedingungen, ${named('days')} Tage Bereich`,
justNow: 'Gerade eben', justNow: 'Gerade eben',
hoursAgo: 'Vor {count} Std.', hoursAgo: ({ named }) => `Vor ${named('count')} Std.`,
daysAgo: 'Vor {count} Tag(en)', daysAgo: ({ named }) => `Vor ${named('count')} Tag(en)`,
searchTemplates: 'Suchvorlagen', searchTemplates: 'Suchvorlagen',
noTemplatesTitle: 'Keine Vorlagen', noTemplatesTitle: 'Keine Vorlagen',
noTemplatesDesc: 'Keine Vorlagen entsprechen den Kriterien', noTemplatesDesc: 'Keine Vorlagen entsprechen den Kriterien',
@@ -21,7 +21,21 @@ export default {
searching: 'Searching...', searching: 'Searching...',
searchFailed: 'Search failed, please try again later', searchFailed: 'Search failed, please try again later',
noResult: 'No results match the criteria', noResult: 'No results match the criteria',
noResultHelp: 'Try adjusting your search criteria',
noSearchYetTitle: 'Welcome to Everything Suitable',
noSearchYetDesc: 'Select a template or add search conditions above',
startSearch: 'Start Search',
keywordHint: 'Enter search keywords', keywordHint: 'Enter search keywords',
todayTitle: "Today's Almanac",
lunarDate: 'Lunar Date',
jianChu: 'Jianchu',
starGod: 'Star God',
godDirection: 'God Direction',
clash: 'Clash',
evil: 'Evil Direction',
naYin: 'Na Yin',
fetusGod: 'Fetus God',
pengzuTaboo: 'Pengzu Taboo',
}, },
ziwei: { ziwei: {
pageTitle: 'Zi Wei Dou Shu', pageTitle: 'Zi Wei Dou Shu',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: 'Please select', pleaseSelect: 'Please select',
generating: 'Generating chart...', generating: 'Generating chart...',
generate: 'Generate Chart', generate: 'Generate Chart',
requireBirthDate: 'Please select a birth date first',
selectBirthDateFirst: 'Select your birth date first',
generateFailed: 'Failed to generate the chart, please try again later',
chartTitle: 'Zi Wei Dou Shu Chart', chartTitle: 'Zi Wei Dou Shu Chart',
sanFangAnalysis: 'San Fang Si Zheng Analysis', sanFangAnalysis: 'San Fang Si Zheng Analysis',
sanFangScore: 'San Fang Score', sanFangScore: 'San Fang Score',
@@ -50,7 +67,7 @@ export default {
monthly: 'Monthly', monthly: 'Monthly',
selectDate: 'Select Date', selectDate: 'Select Date',
selectMonth: 'Select Month', selectMonth: 'Select Month',
overallScore: 'Overall Fortune: {score}pts', overallScore: ({ named }) => `Overall Fortune: ${named('score')}pts`,
career: 'Career: ', career: 'Career: ',
wealth: 'Wealth: ', wealth: 'Wealth: ',
relationship: 'Relationship: ', relationship: 'Relationship: ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'Lucky Number: ', luckyNumber: 'Lucky Number: ',
luckyDirection: 'Lucky Direction: ', luckyDirection: 'Lucky Direction: ',
noChartHint: 'Please generate a chart on the Zi Wei Dou Shu page first', noChartHint: 'Please generate a chart on the Zi Wei Dou Shu page first',
goToZiwei: 'Generate Chart',
}, },
search: { search: {
searchConditions: 'Search Conditions', searchConditions: 'Search Conditions',
addCondition: 'Add Condition', addCondition: 'Add Condition',
addConditionHint: 'Please add at least one search condition', addConditionHint: 'Please add at least one search condition',
searchDays: 'Search Days', searchDays: 'Search Days',
daysUnit: '{count} days', daysUnit: ({ named }) => `${named('count')} days`,
searchResults: 'Search Results', searchResults: 'Search Results',
resultCount: '{count} results', resultCount: ({ named }) => `${named('count')} results`,
matchCount: 'Match', matchCount: 'Match',
sort: 'Sort', sort: 'Sort',
sortByDate: 'Date', sortByDate: 'Date',
@@ -79,17 +97,17 @@ export default {
excludeCondition: 'Exclude Condition', excludeCondition: 'Exclude Condition',
activityItems: 'Items', activityItems: 'Items',
logicalOperator: 'Logical Operator', logicalOperator: 'Logical Operator',
dateFormat: '{month}/{day}/{year}', dateFormat: ({ named }) => `${named('month')}/${named('day')}/${named('year')}`,
searchHistory: 'Search History', searchHistory: 'Search History',
clearHistory: 'Clear History', clearHistory: 'Clear History',
noHistoryTitle: 'No Search History', noHistoryTitle: 'No Search History',
noHistoryDesc: 'Search conditions will appear here after searching', noHistoryDesc: 'Search conditions will appear here after searching',
confirmClear: 'Confirm Clear', confirmClear: 'Confirm Clear',
confirmClearContent: 'Are you sure you want to clear all search history?', confirmClearContent: 'Are you sure you want to clear all search history?',
historyConditionDays: '{count} conditions, {days} days range', historyConditionDays: ({ named }) => `${named('count')} conditions, ${named('days')} days range`,
justNow: 'Just now', justNow: 'Just now',
hoursAgo: '{count}h ago', hoursAgo: ({ named }) => `${named('count')}h ago`,
daysAgo: '{count}d ago', daysAgo: ({ named }) => `${named('count')}d ago`,
searchTemplates: 'Search Templates', searchTemplates: 'Search Templates',
noTemplatesTitle: 'No Templates', noTemplatesTitle: 'No Templates',
noTemplatesDesc: 'No templates match the criteria', noTemplatesDesc: 'No templates match the criteria',
@@ -21,7 +21,21 @@ export default {
searching: 'Buscando...', searching: 'Buscando...',
searchFailed: 'Búsqueda fallida, intente de nuevo más tarde', searchFailed: 'Búsqueda fallida, intente de nuevo más tarde',
noResult: 'No hay resultados que coincidan con los criterios', noResult: 'No hay resultados que coincidan con los criterios',
noResultHelp: 'Ajuste sus criterios de búsqueda e intente de nuevo',
noSearchYetTitle: 'Bienvenido a Everything Suitable',
noSearchYetDesc: 'Seleccione una plantilla o agregue condiciones de búsqueda arriba',
startSearch: 'Iniciar búsqueda',
keywordHint: 'Ingrese palabras clave de búsqueda', keywordHint: 'Ingrese palabras clave de búsqueda',
todayTitle: 'Almanaque de hoy',
lunarDate: 'Calendario lunar',
jianChu: 'Jianchu',
starGod: 'Dios de las estrellas',
godDirection: 'Dirección divina',
clash: 'Choque',
evil: 'Dirección nefasta',
naYin: 'Na Yin',
fetusGod: 'Dios del feto',
pengzuTaboo: 'Tabú de Pengzu',
}, },
ziwei: { ziwei: {
pageTitle: 'Zi Wei Dou Shu', pageTitle: 'Zi Wei Dou Shu',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: 'Seleccione', pleaseSelect: 'Seleccione',
generating: 'Generando carta...', generating: 'Generando carta...',
generate: 'Generar carta', generate: 'Generar carta',
requireBirthDate: 'Primero seleccione la fecha de nacimiento',
selectBirthDateFirst: 'Seleccione primero la fecha de nacimiento',
generateFailed: 'No se pudo generar la carta. Inténtelo nuevamente más tarde',
chartTitle: 'Carta Zi Wei Dou Shu', chartTitle: 'Carta Zi Wei Dou Shu',
sanFangAnalysis: 'Análisis San Fang Si Zheng', sanFangAnalysis: 'Análisis San Fang Si Zheng',
sanFangScore: 'Puntuación San Fang', sanFangScore: 'Puntuación San Fang',
@@ -50,7 +67,7 @@ export default {
monthly: 'Mensual', monthly: 'Mensual',
selectDate: 'Seleccionar fecha', selectDate: 'Seleccionar fecha',
selectMonth: 'Seleccionar mes', selectMonth: 'Seleccionar mes',
overallScore: 'Fortuna general: {score}pts', overallScore: ({ named }) => `Fortuna general: ${named('score')}pts`,
career: 'Carrera: ', career: 'Carrera: ',
wealth: 'Riqueza: ', wealth: 'Riqueza: ',
relationship: 'Relación: ', relationship: 'Relación: ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'Número de la suerte: ', luckyNumber: 'Número de la suerte: ',
luckyDirection: 'Dirección de la suerte: ', luckyDirection: 'Dirección de la suerte: ',
noChartHint: 'Primero genere una carta en la página de Zi Wei Dou Shu', noChartHint: 'Primero genere una carta en la página de Zi Wei Dou Shu',
goToZiwei: 'Generar carta',
}, },
search: { search: {
searchConditions: 'Condiciones de búsqueda', searchConditions: 'Condiciones de búsqueda',
addCondition: 'Añadir condición', addCondition: 'Añadir condición',
addConditionHint: 'Añada al menos una condición de búsqueda', addConditionHint: 'Añada al menos una condición de búsqueda',
searchDays: 'Días de búsqueda', searchDays: 'Días de búsqueda',
daysUnit: '{count} días', daysUnit: ({ named }) => `${named('count')} días`,
searchResults: 'Resultados de búsqueda', searchResults: 'Resultados de búsqueda',
resultCount: '{count} resultados', resultCount: ({ named }) => `${named('count')} resultados`,
matchCount: 'Coincidencia', matchCount: 'Coincidencia',
sort: 'Ordenar', sort: 'Ordenar',
sortByDate: 'Fecha', sortByDate: 'Fecha',
@@ -79,17 +97,17 @@ export default {
excludeCondition: 'Excluir condición', excludeCondition: 'Excluir condición',
activityItems: 'Elementos', activityItems: 'Elementos',
logicalOperator: 'Operador lógico', logicalOperator: 'Operador lógico',
dateFormat: '{day}/{month}/{year}', dateFormat: ({ named }) => `${named('day')}/${named('month')}/${named('year')}`,
searchHistory: 'Historial de búsqueda', searchHistory: 'Historial de búsqueda',
clearHistory: 'Borrar historial', clearHistory: 'Borrar historial',
noHistoryTitle: 'Sin historial', noHistoryTitle: 'Sin historial',
noHistoryDesc: 'Las condiciones de búsqueda aparecerán aquí después de buscar', noHistoryDesc: 'Las condiciones de búsqueda aparecerán aquí después de buscar',
confirmClear: 'Confirmar borrado', confirmClear: 'Confirmar borrado',
confirmClearContent: '¿Desea borrar todo el historial de búsqueda?', confirmClearContent: '¿Desea borrar todo el historial de búsqueda?',
historyConditionDays: '{count} condiciones, {days} días de rango', historyConditionDays: ({ named }) => `${named('count')} condiciones, ${named('days')} días de rango`,
justNow: 'Justo ahora', justNow: 'Justo ahora',
hoursAgo: 'Hace {count}h', hoursAgo: ({ named }) => `Hace ${named('count')}h`,
daysAgo: 'Hace {count}d', daysAgo: ({ named }) => `Hace ${named('count')}d`,
searchTemplates: 'Plantillas de búsqueda', searchTemplates: 'Plantillas de búsqueda',
noTemplatesTitle: 'Sin plantillas', noTemplatesTitle: 'Sin plantillas',
noTemplatesDesc: 'No hay plantillas que coincidan con los criterios', noTemplatesDesc: 'No hay plantillas que coincidan con los criterios',
@@ -21,7 +21,21 @@ export default {
searching: 'Recherche...', searching: 'Recherche...',
searchFailed: 'Échec de la recherche, veuillez réessayer plus tard', searchFailed: 'Échec de la recherche, veuillez réessayer plus tard',
noResult: 'Aucun résultat ne correspond aux critères', noResult: 'Aucun résultat ne correspond aux critères',
noResultHelp: 'Ajustez vos critères de recherche et réessayez',
noSearchYetTitle: 'Bienvenue sur Everything Suitable',
noSearchYetDesc: 'Sélectionnez un modèle ou ajoutez des conditions de recherche ci-dessus',
startSearch: 'Commencer la recherche',
keywordHint: 'Entrez des mots-clés de recherche', keywordHint: 'Entrez des mots-clés de recherche',
todayTitle: "Almanach du jour",
lunarDate: 'Calendrier lunaire',
jianChu: 'Jianchu',
starGod: 'Dieu des étoiles',
godDirection: 'Direction divine',
clash: 'Clash',
evil: 'Direction néfaste',
naYin: 'Na Yin',
fetusGod: 'Dieu du fœtus',
pengzuTaboo: 'Tabou Pengzu',
}, },
ziwei: { ziwei: {
pageTitle: 'Zi Wei Dou Shu', pageTitle: 'Zi Wei Dou Shu',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: 'Veuillez sélectionner', pleaseSelect: 'Veuillez sélectionner',
generating: 'Génération du diagramme...', generating: 'Génération du diagramme...',
generate: 'Générer le diagramme', generate: 'Générer le diagramme',
requireBirthDate: 'Veuillez d\'abord choisir une date de naissance',
selectBirthDateFirst: 'Choisissez d\'abord la date de naissance',
generateFailed: 'Échec de la génération du diagramme. Veuillez réessayer plus tard',
chartTitle: 'Diagramme Zi Wei Dou Shu', chartTitle: 'Diagramme Zi Wei Dou Shu',
sanFangAnalysis: 'Analyse San Fang Si Zheng', sanFangAnalysis: 'Analyse San Fang Si Zheng',
sanFangScore: 'Score San Fang', sanFangScore: 'Score San Fang',
@@ -50,7 +67,7 @@ export default {
monthly: 'Mensuel', monthly: 'Mensuel',
selectDate: 'Sélectionner la date', selectDate: 'Sélectionner la date',
selectMonth: 'Sélectionner le mois', selectMonth: 'Sélectionner le mois',
overallScore: 'Destin global : {score}pts', overallScore: ({ named }) => `Destin global : ${named('score')}pts`,
career: 'Carrière : ', career: 'Carrière : ',
wealth: 'Richesse : ', wealth: 'Richesse : ',
relationship: 'Relation : ', relationship: 'Relation : ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'Numéro chance : ', luckyNumber: 'Numéro chance : ',
luckyDirection: 'Direction porte-bonheur : ', luckyDirection: 'Direction porte-bonheur : ',
noChartHint: 'Veuillez d\'abord générer un diagramme sur la page Zi Wei Dou Shu', noChartHint: 'Veuillez d\'abord générer un diagramme sur la page Zi Wei Dou Shu',
goToZiwei: 'Générer le diagramme',
}, },
search: { search: {
searchConditions: 'Conditions de recherche', searchConditions: 'Conditions de recherche',
addCondition: 'Ajouter une condition', addCondition: 'Ajouter une condition',
addConditionHint: 'Veuillez ajouter au moins une condition de recherche', addConditionHint: 'Veuillez ajouter au moins une condition de recherche',
searchDays: 'Jours de recherche', searchDays: 'Jours de recherche',
daysUnit: '{count} jours', daysUnit: ({ named }) => `${named('count')} jours`,
searchResults: 'Résultats de recherche', searchResults: 'Résultats de recherche',
resultCount: '{count} résultats', resultCount: ({ named }) => `${named('count')} résultats`,
matchCount: 'Correspondance', matchCount: 'Correspondance',
sort: 'Trier', sort: 'Trier',
sortByDate: 'Date', sortByDate: 'Date',
@@ -79,17 +97,17 @@ export default {
excludeCondition: 'Exclure la condition', excludeCondition: 'Exclure la condition',
activityItems: 'Éléments', activityItems: 'Éléments',
logicalOperator: 'Opérateur logique', logicalOperator: 'Opérateur logique',
dateFormat: '{day}/{month}/{year}', dateFormat: ({ named }) => `${named('day')}/${named('month')}/${named('year')}`,
searchHistory: 'Historique de recherche', searchHistory: 'Historique de recherche',
clearHistory: 'Effacer l\'historique', clearHistory: 'Effacer l\'historique',
noHistoryTitle: 'Aucun historique', noHistoryTitle: 'Aucun historique',
noHistoryDesc: 'Les conditions de recherche apparaîtront ici après la recherche', noHistoryDesc: 'Les conditions de recherche apparaîtront ici après la recherche',
confirmClear: 'Confirmer la suppression', confirmClear: 'Confirmer la suppression',
confirmClearContent: 'Voulez-vous effacer tout l\'historique de recherche ?', confirmClearContent: 'Voulez-vous effacer tout l\'historique de recherche ?',
historyConditionDays: '{count} conditions, {days} jours de plage', historyConditionDays: ({ named }) => `${named('count')} conditions, ${named('days')} jours de plage`,
justNow: 'À l\'instant', justNow: 'À l\'instant',
hoursAgo: 'Il y a {count}h', hoursAgo: ({ named }) => `Il y a ${named('count')}h`,
daysAgo: 'Il y a {count}j', daysAgo: ({ named }) => `Il y a ${named('count')}j`,
searchTemplates: 'Modèles de recherche', searchTemplates: 'Modèles de recherche',
noTemplatesTitle: 'Aucun modèle', noTemplatesTitle: 'Aucun modèle',
noTemplatesDesc: 'Aucun modèle ne correspond aux critères', noTemplatesDesc: 'Aucun modèle ne correspond aux critères',
@@ -69,8 +69,12 @@ export function getLocale(): SupportedLocale {
return i18n.global.locale.value as SupportedLocale return i18n.global.locale.value as SupportedLocale
} }
export const i18n = createI18n({ /** i18n 实例配置(显式导出以便测试断言,防止关键配置回归) */
export const I18N_OPTIONS = {
legacy: false, legacy: false,
// 关键:启用模板全局 $tvue-i18n v9 在 legacy:false 模式下默认禁用,
// 缺失会导致小程序端模板渲染报 "TypeError: a.$t is not a function"、全部页面异常)
globalInjection: true,
locale: detectLocale(), locale: detectLocale(),
fallbackLocale: 'zh-CN', fallbackLocale: 'zh-CN',
messages: { messages: {
@@ -84,4 +88,6 @@ export const i18n = createI18n({
'es': es, 'es': es,
'pt': pt, 'pt': pt,
}, },
}) } as const
export const i18n = createI18n(I18N_OPTIONS)
@@ -21,7 +21,21 @@ export default {
searching: '検索中...', searching: '検索中...',
searchFailed: '検索に失敗しました。後でもう一度お試しください', searchFailed: '検索に失敗しました。後でもう一度お試しください',
noResult: '条件に一致する結果がありません', noResult: '条件に一致する結果がありません',
noResultHelp: '検索条件を調整して再試行してください',
noSearchYetTitle: '万事宜へようこそ',
noSearchYetDesc: '上記のテンプレートを選択するか、検索条件を追加してください',
startSearch: '検索を開始',
keywordHint: '検索キーワードを入力してください', keywordHint: '検索キーワードを入力してください',
todayTitle: '今日の暦',
lunarDate: '旧暦',
jianChu: '建除',
starGod: '吉神',
godDirection: '吉神方位',
clash: '沖',
evil: '煞方',
naYin: '納音',
fetusGod: '胎神',
pengzuTaboo: '彭祖百忌',
}, },
ziwei: { ziwei: {
pageTitle: '紫微斗数', pageTitle: '紫微斗数',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: '選択してください', pleaseSelect: '選択してください',
generating: '排盤中...', generating: '排盤中...',
generate: '排盤', generate: '排盤',
requireBirthDate: '生年月日を先に選択してください',
selectBirthDateFirst: '先に生年月日を選択してください',
generateFailed: '排盤に失敗しました。後でもう一度お試しください',
chartTitle: '紫微斗数盤', chartTitle: '紫微斗数盤',
sanFangAnalysis: '三方四正分析', sanFangAnalysis: '三方四正分析',
sanFangScore: '三方得点', sanFangScore: '三方得点',
@@ -50,7 +67,7 @@ export default {
monthly: '月運', monthly: '月運',
selectDate: '日付を選択', selectDate: '日付を選択',
selectMonth: '月を選択', selectMonth: '月を選択',
overallScore: '総合運勢:{score}点', overallScore: ({ named }) => `総合運勢:${named('score')}`,
career: '事業:', career: '事業:',
wealth: '財運:', wealth: '財運:',
relationship: '恋愛:', relationship: '恋愛:',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'ラッキーナンバー:', luckyNumber: 'ラッキーナンバー:',
luckyDirection: '吉方位:', luckyDirection: '吉方位:',
noChartHint: '先に紫微斗数ページで排盤を完了してください', noChartHint: '先に紫微斗数ページで排盤を完了してください',
goToZiwei: '排盤へ',
}, },
search: { search: {
searchConditions: '検索条件', searchConditions: '検索条件',
addCondition: '条件を追加', addCondition: '条件を追加',
addConditionHint: '検索条件を少なくとも1つ追加してください', addConditionHint: '検索条件を少なくとも1つ追加してください',
searchDays: '検索日数', searchDays: '検索日数',
daysUnit: '{count}日', daysUnit: ({ named }) => `${named('count')}`,
searchResults: '検索結果', searchResults: '検索結果',
resultCount: '{count}件の結果', resultCount: ({ named }) => `${named('count')}件の結果`,
matchCount: '一致度', matchCount: '一致度',
sort: '並び替え', sort: '並び替え',
sortByDate: '日付', sortByDate: '日付',
@@ -79,17 +97,17 @@ export default {
excludeCondition: '除外条件', excludeCondition: '除外条件',
activityItems: '事項', activityItems: '事項',
logicalOperator: '論理演算子', logicalOperator: '論理演算子',
dateFormat: '{year}年{month}月{day}日', dateFormat: ({ named }) => `${named('year')}${named('month')}${named('day')}`,
searchHistory: '検索履歴', searchHistory: '検索履歴',
clearHistory: '履歴をクリア', clearHistory: '履歴をクリア',
noHistoryTitle: '検索履歴なし', noHistoryTitle: '検索履歴なし',
noHistoryDesc: '検索実行後、条件がここに表示されます', noHistoryDesc: '検索実行後、条件がここに表示されます',
confirmClear: 'クリアの確認', confirmClear: 'クリアの確認',
confirmClearContent: 'すべての検索履歴をクリアしますか?', confirmClearContent: 'すべての検索履歴をクリアしますか?',
historyConditionDays: '{count}つの条件、{days}日範囲', historyConditionDays: ({ named }) => `${named('count')}つの条件、${named('days')}日範囲`,
justNow: 'たった今', justNow: 'たった今',
hoursAgo: '{count}時間前', hoursAgo: ({ named }) => `${named('count')}時間前`,
daysAgo: '{count}日前', daysAgo: ({ named }) => `${named('count')}日前`,
searchTemplates: '検索テンプレート', searchTemplates: '検索テンプレート',
noTemplatesTitle: 'テンプレートなし', noTemplatesTitle: 'テンプレートなし',
noTemplatesDesc: '条件に一致するテンプレートがありません', noTemplatesDesc: '条件に一致するテンプレートがありません',
@@ -21,7 +21,21 @@ export default {
searching: '검색 중...', searching: '검색 중...',
searchFailed: '검색 실패, 나중에 다시 시도해 주세요', searchFailed: '검색 실패, 나중에 다시 시도해 주세요',
noResult: '조건에 맞는 결과가 없습니다', noResult: '조건에 맞는 결과가 없습니다',
noResultHelp: '검색 조건을 조정한 후 다시 시도하세요',
noSearchYetTitle: '만사적에 오신 것을 환영합니다',
noSearchYetDesc: '위의 템플릿에서 선택하거나 검색 조건을 추가하세요',
startSearch: '검색 시작',
keywordHint: '검색어를 입력하세요', keywordHint: '검색어를 입력하세요',
todayTitle: '오늘의 황력',
lunarDate: '음력',
jianChu: '건제',
starGod: '길신',
godDirection: '길신 방위',
clash: '충살',
evil: '살방',
naYin: '납음',
fetusGod: '태신',
pengzuTaboo: '팽조백기',
}, },
ziwei: { ziwei: {
pageTitle: '자미두수', pageTitle: '자미두수',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: '선택하세요', pleaseSelect: '선택하세요',
generating: '명반 생성 중...', generating: '명반 생성 중...',
generate: '명반 생성', generate: '명반 생성',
requireBirthDate: '먼저 출생일을 선택하세요',
selectBirthDateFirst: '먼저 출생일을 선택하세요',
generateFailed: '명반 생성에 실패했습니다. 잠시 후 다시 시도하세요',
chartTitle: '자미두수 명반', chartTitle: '자미두수 명반',
sanFangAnalysis: '삼방사정 분석', sanFangAnalysis: '삼방사정 분석',
sanFangScore: '삼방 점수', sanFangScore: '삼방 점수',
@@ -50,7 +67,7 @@ export default {
monthly: '월운', monthly: '월운',
selectDate: '날짜 선택', selectDate: '날짜 선택',
selectMonth: '월 선택', selectMonth: '월 선택',
overallScore: '종합 운세: {score}점', overallScore: ({ named }) => `종합 운세: ${named('score')}`,
career: '사업: ', career: '사업: ',
wealth: '재운: ', wealth: '재운: ',
relationship: '연애: ', relationship: '연애: ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: '행운의 숫자: ', luckyNumber: '행운의 숫자: ',
luckyDirection: '행운의 방위: ', luckyDirection: '행운의 방위: ',
noChartHint: '먼저 자미두수 페이지에서 명반을 생성해 주세요', noChartHint: '먼저 자미두수 페이지에서 명반을 생성해 주세요',
goToZiwei: '명반 생성',
}, },
search: { search: {
searchConditions: '검색 조건', searchConditions: '검색 조건',
addCondition: '조건 추가', addCondition: '조건 추가',
addConditionHint: '검색 조건을 최소 하나 추가해 주세요', addConditionHint: '검색 조건을 최소 하나 추가해 주세요',
searchDays: '검색 일수', searchDays: '검색 일수',
daysUnit: '{count}일', daysUnit: ({ named }) => `${named('count')}`,
searchResults: '검색 결과', searchResults: '검색 결과',
resultCount: '{count}개 결과', resultCount: ({ named }) => `${named('count')}개 결과`,
matchCount: '일치도', matchCount: '일치도',
sort: '정렬', sort: '정렬',
sortByDate: '날짜', sortByDate: '날짜',
@@ -79,17 +97,17 @@ export default {
excludeCondition: '제외 조건', excludeCondition: '제외 조건',
activityItems: '항목', activityItems: '항목',
logicalOperator: '논리 연산자', logicalOperator: '논리 연산자',
dateFormat: '{year}년 {month}월 {day}일', dateFormat: ({ named }) => `${named('year')}${named('month')}${named('day')}`,
searchHistory: '검색 기록', searchHistory: '검색 기록',
clearHistory: '기록 삭제', clearHistory: '기록 삭제',
noHistoryTitle: '검색 기록 없음', noHistoryTitle: '검색 기록 없음',
noHistoryDesc: '검색 실행 후 조건이 여기에 표시됩니다', noHistoryDesc: '검색 실행 후 조건이 여기에 표시됩니다',
confirmClear: '삭제 확인', confirmClear: '삭제 확인',
confirmClearContent: '모든 검색 기록을 삭제하시겠습니까?', confirmClearContent: '모든 검색 기록을 삭제하시겠습니까?',
historyConditionDays: '{count}개 조건, {days}일 범위', historyConditionDays: ({ named }) => `${named('count')}개 조건, ${named('days')}일 범위`,
justNow: '방금', justNow: '방금',
hoursAgo: '{count}시간 전', hoursAgo: ({ named }) => `${named('count')}시간 전`,
daysAgo: '{count}일 전', daysAgo: ({ named }) => `${named('count')}일 전`,
searchTemplates: '검색 템플릿', searchTemplates: '검색 템플릿',
noTemplatesTitle: '템플릿 없음', noTemplatesTitle: '템플릿 없음',
noTemplatesDesc: '조건에 맞는 템플릿이 없습니다', noTemplatesDesc: '조건에 맞는 템플릿이 없습니다',
@@ -21,7 +21,21 @@ export default {
searching: 'Pesquisando...', searching: 'Pesquisando...',
searchFailed: 'Falha na pesquisa, tente novamente mais tarde', searchFailed: 'Falha na pesquisa, tente novamente mais tarde',
noResult: 'Nenhum resultado corresponde aos critérios', noResult: 'Nenhum resultado corresponde aos critérios',
noResultHelp: 'Ajuste seus critérios de pesquisa e tente novamente',
noSearchYetTitle: 'Bem-vindo ao Everything Suitable',
noSearchYetDesc: 'Selecione um modelo ou adicione condições de pesquisa acima',
startSearch: 'Iniciar pesquisa',
keywordHint: 'Insira palavras-chave de pesquisa', keywordHint: 'Insira palavras-chave de pesquisa',
todayTitle: 'Almanaque de hoje',
lunarDate: 'Calendário lunar',
jianChu: 'Jianchu',
starGod: 'Deus das estrelas',
godDirection: 'Direção divina',
clash: 'Choque',
evil: 'Direção nefasta',
naYin: 'Na Yin',
fetusGod: 'Deus do feto',
pengzuTaboo: 'Tabu de Pengzu',
}, },
ziwei: { ziwei: {
pageTitle: 'Zi Wei Dou Shu', pageTitle: 'Zi Wei Dou Shu',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: 'Selecione', pleaseSelect: 'Selecione',
generating: 'Gerando carta...', generating: 'Gerando carta...',
generate: 'Gerar carta', generate: 'Gerar carta',
requireBirthDate: 'Selecione primeiro a data de nascimento',
selectBirthDateFirst: 'Selecione primeiro a data de nascimento',
generateFailed: 'Falha ao gerar a carta. Tente novamente mais tarde',
chartTitle: 'Carta Zi Wei Dou Shu', chartTitle: 'Carta Zi Wei Dou Shu',
sanFangAnalysis: 'Análise San Fang Si Zheng', sanFangAnalysis: 'Análise San Fang Si Zheng',
sanFangScore: 'Pontuação San Fang', sanFangScore: 'Pontuação San Fang',
@@ -50,7 +67,7 @@ export default {
monthly: 'Mensal', monthly: 'Mensal',
selectDate: 'Selecionar data', selectDate: 'Selecionar data',
selectMonth: 'Selecionar mês', selectMonth: 'Selecionar mês',
overallScore: 'Fortuna geral: {score}pts', overallScore: ({ named }) => `Fortuna geral: ${named('score')}pts`,
career: 'Carreira: ', career: 'Carreira: ',
wealth: 'Riqueza: ', wealth: 'Riqueza: ',
relationship: 'Relacionamento: ', relationship: 'Relacionamento: ',
@@ -59,15 +76,16 @@ export default {
luckyNumber: 'Número da sorte: ', luckyNumber: 'Número da sorte: ',
luckyDirection: 'Direção da sorte: ', luckyDirection: 'Direção da sorte: ',
noChartHint: 'Primeiro gere uma carta na página Zi Wei Dou Shu', noChartHint: 'Primeiro gere uma carta na página Zi Wei Dou Shu',
goToZiwei: 'Gerar carta',
}, },
search: { search: {
searchConditions: 'Condições de pesquisa', searchConditions: 'Condições de pesquisa',
addCondition: 'Adicionar condição', addCondition: 'Adicionar condição',
addConditionHint: 'Adicione pelo menos uma condição de pesquisa', addConditionHint: 'Adicione pelo menos uma condição de pesquisa',
searchDays: 'Dias de pesquisa', searchDays: 'Dias de pesquisa',
daysUnit: '{count} dias', daysUnit: ({ named }) => `${named('count')} dias`,
searchResults: 'Resultados da pesquisa', searchResults: 'Resultados da pesquisa',
resultCount: '{count} resultados', resultCount: ({ named }) => `${named('count')} resultados`,
matchCount: 'Correspondência', matchCount: 'Correspondência',
sort: 'Ordenar', sort: 'Ordenar',
sortByDate: 'Data', sortByDate: 'Data',
@@ -79,17 +97,17 @@ export default {
excludeCondition: 'Excluir condição', excludeCondition: 'Excluir condição',
activityItems: 'Itens', activityItems: 'Itens',
logicalOperator: 'Operador lógico', logicalOperator: 'Operador lógico',
dateFormat: '{day}/{month}/{year}', dateFormat: ({ named }) => `${named('day')}/${named('month')}/${named('year')}`,
searchHistory: 'Histórico de pesquisa', searchHistory: 'Histórico de pesquisa',
clearHistory: 'Limpar histórico', clearHistory: 'Limpar histórico',
noHistoryTitle: 'Sem histórico', noHistoryTitle: 'Sem histórico',
noHistoryDesc: 'As condições de pesquisa aparecerão aqui após a pesquisa', noHistoryDesc: 'As condições de pesquisa aparecerão aqui após a pesquisa',
confirmClear: 'Confirmar limpeza', confirmClear: 'Confirmar limpeza',
confirmClearContent: 'Deseja limpar todo o histórico de pesquisa?', confirmClearContent: 'Deseja limpar todo o histórico de pesquisa?',
historyConditionDays: '{count} condições, {days} dias de intervalo', historyConditionDays: ({ named }) => `${named('count')} condições, ${named('days')} dias de intervalo`,
justNow: 'Agora mesmo', justNow: 'Agora mesmo',
hoursAgo: 'Há {count}h', hoursAgo: ({ named }) => `${named('count')}h`,
daysAgo: 'Há {count}d', daysAgo: ({ named }) => `${named('count')}d`,
searchTemplates: 'Modelos de pesquisa', searchTemplates: 'Modelos de pesquisa',
noTemplatesTitle: 'Sem modelos', noTemplatesTitle: 'Sem modelos',
noTemplatesDesc: 'Nenhum modelo corresponde aos critérios', noTemplatesDesc: 'Nenhum modelo corresponde aos critérios',
@@ -21,7 +21,21 @@ export default {
searching: '搜索中...', searching: '搜索中...',
searchFailed: '搜索失败,请稍后重试', searchFailed: '搜索失败,请稍后重试',
noResult: '没有找到符合条件的结果', noResult: '没有找到符合条件的结果',
noResultHelp: '请调整搜索条件后重试',
noSearchYetTitle: '欢迎使用万事宜',
noSearchYetDesc: '请从上方模板中选择或添加搜索条件',
startSearch: '开始搜索',
keywordHint: '请输入搜索关键词', keywordHint: '请输入搜索关键词',
todayTitle: '今日黄历',
lunarDate: '农历',
jianChu: '值神',
starGod: '吉神',
godDirection: '吉神方位',
clash: '冲煞',
evil: '煞方',
naYin: '纳音',
fetusGod: '胎神',
pengzuTaboo: '彭祖百忌',
}, },
ziwei: { ziwei: {
pageTitle: '紫微斗数', pageTitle: '紫微斗数',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: '请选择', pleaseSelect: '请选择',
generating: '排盘中...', generating: '排盘中...',
generate: '排盘', generate: '排盘',
requireBirthDate: '请先选择出生日期',
selectBirthDateFirst: '请先选择出生日期',
generateFailed: '排盘失败,请稍后重试',
chartTitle: '紫微斗数盘', chartTitle: '紫微斗数盘',
sanFangAnalysis: '三方四正分析', sanFangAnalysis: '三方四正分析',
sanFangScore: '三方得分', sanFangScore: '三方得分',
@@ -50,7 +67,7 @@ export default {
monthly: '月运', monthly: '月运',
selectDate: '选择日期', selectDate: '选择日期',
selectMonth: '选择月份', selectMonth: '选择月份',
overallScore: '综合运势:{score}分', overallScore: ({ named }) => `综合运势:${named('score')}`,
career: '事业:', career: '事业:',
wealth: '财运:', wealth: '财运:',
relationship: '感情:', relationship: '感情:',
@@ -59,15 +76,16 @@ export default {
luckyNumber: '幸运数字:', luckyNumber: '幸运数字:',
luckyDirection: '幸运方位:', luckyDirection: '幸运方位:',
noChartHint: '请先在紫微斗数页面完成排盘', noChartHint: '请先在紫微斗数页面完成排盘',
goToZiwei: '去排盘',
}, },
search: { search: {
searchConditions: '搜索条件', searchConditions: '搜索条件',
addCondition: '添加条件', addCondition: '添加条件',
addConditionHint: '请至少添加一个搜索条件', addConditionHint: '请至少添加一个搜索条件',
searchDays: '搜索天数', searchDays: '搜索天数',
daysUnit: '{count}天', daysUnit: ({ named }) => `${named('count')}`,
searchResults: '搜索结果', searchResults: '搜索结果',
resultCount: '共{count}条结果', resultCount: ({ named }) => `${named('count')}条结果`,
matchCount: '匹配度', matchCount: '匹配度',
sort: '排序', sort: '排序',
sortByDate: '日期', sortByDate: '日期',
@@ -79,17 +97,17 @@ export default {
excludeCondition: '排除条件', excludeCondition: '排除条件',
activityItems: '事项', activityItems: '事项',
logicalOperator: '逻辑运算符', logicalOperator: '逻辑运算符',
dateFormat: '{year}年{month}月{day}日', dateFormat: ({ named }) => `${named('year')}${named('month')}${named('day')}`,
searchHistory: '搜索历史', searchHistory: '搜索历史',
clearHistory: '清除历史', clearHistory: '清除历史',
noHistoryTitle: '暂无搜索历史', noHistoryTitle: '暂无搜索历史',
noHistoryDesc: '执行搜索后,搜索条件将显示在这里', noHistoryDesc: '执行搜索后,搜索条件将显示在这里',
confirmClear: '确认清除', confirmClear: '确认清除',
confirmClearContent: '确定要清除所有搜索历史吗?', confirmClearContent: '确定要清除所有搜索历史吗?',
historyConditionDays: '{count}个条件,{days}天范围', historyConditionDays: ({ named }) => `${named('count')}个条件,${named('days')}天范围`,
justNow: '刚刚', justNow: '刚刚',
hoursAgo: '{count}小时前', hoursAgo: ({ named }) => `${named('count')}小时前`,
daysAgo: '{count}天前', daysAgo: ({ named }) => `${named('count')}天前`,
searchTemplates: '搜索模板', searchTemplates: '搜索模板',
noTemplatesTitle: '暂无模板', noTemplatesTitle: '暂无模板',
noTemplatesDesc: '没有找到符合条件的模板', noTemplatesDesc: '没有找到符合条件的模板',
@@ -21,7 +21,21 @@ export default {
searching: '搜尋中...', searching: '搜尋中...',
searchFailed: '搜尋失敗,請稍後重試', searchFailed: '搜尋失敗,請稍後重試',
noResult: '沒有找到符合條件的結果', noResult: '沒有找到符合條件的結果',
noResultHelp: '請調整搜尋條件後重試',
noSearchYetTitle: '歡迎使用萬事宜',
noSearchYetDesc: '請從上方模板中選擇或新增搜尋條件',
startSearch: '開始搜尋',
keywordHint: '請輸入搜尋關鍵詞', keywordHint: '請輸入搜尋關鍵詞',
todayTitle: '今日黃曆',
lunarDate: '農曆',
jianChu: '值神',
starGod: '吉神',
godDirection: '吉神方位',
clash: '沖煞',
evil: '煞方',
naYin: '納音',
fetusGod: '胎神',
pengzuTaboo: '彭祖百忌',
}, },
ziwei: { ziwei: {
pageTitle: '紫微斗數', pageTitle: '紫微斗數',
@@ -36,6 +50,9 @@ export default {
pleaseSelect: '請選擇', pleaseSelect: '請選擇',
generating: '排盤中...', generating: '排盤中...',
generate: '排盤', generate: '排盤',
requireBirthDate: '請先選擇出生日期',
selectBirthDateFirst: '請先選擇出生日期',
generateFailed: '排盤失敗,請稍後重試',
chartTitle: '紫微斗數盤', chartTitle: '紫微斗數盤',
sanFangAnalysis: '三方四正分析', sanFangAnalysis: '三方四正分析',
sanFangScore: '三方得分', sanFangScore: '三方得分',
@@ -50,7 +67,7 @@ export default {
monthly: '月運', monthly: '月運',
selectDate: '選擇日期', selectDate: '選擇日期',
selectMonth: '選擇月份', selectMonth: '選擇月份',
overallScore: '綜合運勢:{score}分', overallScore: ({ named }) => `綜合運勢:${named('score')}`,
career: '事業:', career: '事業:',
wealth: '財運:', wealth: '財運:',
relationship: '感情:', relationship: '感情:',
@@ -59,15 +76,16 @@ export default {
luckyNumber: '幸運數字:', luckyNumber: '幸運數字:',
luckyDirection: '幸運方位:', luckyDirection: '幸運方位:',
noChartHint: '請先在紫微斗數頁面完成排盤', noChartHint: '請先在紫微斗數頁面完成排盤',
goToZiwei: '去排盤',
}, },
search: { search: {
searchConditions: '搜尋條件', searchConditions: '搜尋條件',
addCondition: '新增條件', addCondition: '新增條件',
addConditionHint: '請至少新增一個搜尋條件', addConditionHint: '請至少新增一個搜尋條件',
searchDays: '搜尋天數', searchDays: '搜尋天數',
daysUnit: '{count}天', daysUnit: ({ named }) => `${named('count')}`,
searchResults: '搜尋結果', searchResults: '搜尋結果',
resultCount: '共{count}條結果', resultCount: ({ named }) => `${named('count')}條結果`,
matchCount: '匹配度', matchCount: '匹配度',
sort: '排序', sort: '排序',
sortByDate: '日期', sortByDate: '日期',
@@ -79,17 +97,17 @@ export default {
excludeCondition: '排除條件', excludeCondition: '排除條件',
activityItems: '事項', activityItems: '事項',
logicalOperator: '邏輯運算子', logicalOperator: '邏輯運算子',
dateFormat: '{year}年{month}月{day}日', dateFormat: ({ named }) => `${named('year')}${named('month')}${named('day')}`,
searchHistory: '搜尋歷史', searchHistory: '搜尋歷史',
clearHistory: '清除歷史', clearHistory: '清除歷史',
noHistoryTitle: '暫無搜尋歷史', noHistoryTitle: '暫無搜尋歷史',
noHistoryDesc: '執行搜尋後,搜尋條件將顯示在這裡', noHistoryDesc: '執行搜尋後,搜尋條件將顯示在這裡',
confirmClear: '確認清除', confirmClear: '確認清除',
confirmClearContent: '確定要清除所有搜尋歷史嗎?', confirmClearContent: '確定要清除所有搜尋歷史嗎?',
historyConditionDays: '{count}個條件,{days}天範圍', historyConditionDays: ({ named }) => `${named('count')}個條件,${named('days')}天範圍`,
justNow: '剛剛', justNow: '剛剛',
hoursAgo: '{count}小時前', hoursAgo: ({ named }) => `${named('count')}小時前`,
daysAgo: '{count}天前', daysAgo: ({ named }) => `${named('count')}天前`,
searchTemplates: '搜尋模板', searchTemplates: '搜尋模板',
noTemplatesTitle: '暫無模板', noTemplatesTitle: '暫無模板',
noTemplatesDesc: '沒有找到符合條件的模板', noTemplatesDesc: '沒有找到符合條件的模板',
@@ -1,6 +1,7 @@
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { i18n } from './locales' import { i18n } from './locales'
import './styles/iconfont.css'
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
@@ -41,7 +41,7 @@
} }
}, },
"mp-weixin": { "mp-weixin": {
"appid": "", "appid": "wxfd35f544ed1db523",
"setting": { "setting": {
"urlCheck": false "urlCheck": false
}, },
@@ -17,12 +17,6 @@
"style": { "style": {
"navigationBarTitleText": "运势分析" "navigationBarTitleText": "运势分析"
} }
},
{
"path": "pages/push-subscription/index",
"style": {
"navigationBarTitleText": "订阅管理"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -5,6 +5,8 @@
</view> </view>
<view class="page-content"> <view class="page-content">
<TodayAlmanacCard :almanac="todayAlmanac" />
<TemplatePanel @select="selectTemplate" /> <TemplatePanel @select="selectTemplate" />
<SearchConditionPanel <SearchConditionPanel
@@ -31,11 +33,13 @@
:sortOrder="searchRequest.sortOrder" :sortOrder="searchRequest.sortOrder"
@update:sortBy="updateSortBy" @update:sortBy="updateSortBy"
@update:sortOrder="updateSortOrder" @update:sortOrder="updateSortOrder"
@loadMore="loadMoreResults"
/> />
<EmptyState <EmptyState
v-else v-else
:icon="emptyIcon"
:title="emptyTitle"
:description="emptyDescription"
:showAction="hasSearched" :showAction="hasSearched"
:actionText="$t('common.retry')" :actionText="$t('common.retry')"
@action="performSearch" @action="performSearch"
@@ -47,7 +51,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import type { SearchCondition, SearchRequest, SearchResult } from '../../types/search' import type { SearchCondition, SearchRequest, SearchResult } from '../../types/search'
@@ -60,19 +64,36 @@ import EmptyState from '../../components/EmptyState/index.vue'
import Typography from '../../components/Typography/Typography.vue' import Typography from '../../components/Typography/Typography.vue'
import Button from '../../components/Button/Button.vue' import Button from '../../components/Button/Button.vue'
import searchService from '../../services/searchService' import searchService from '../../services/searchService'
import { AlmanacService } from '../../services/almanacService'
import type { Almanac } from '../../algorithms/types'
import TodayAlmanacCard from '../../components/TodayAlmanacCard/index.vue'
const { t } = useI18n() const { t } = useI18n()
const almanacService = new AlmanacService()
const todayAlmanac = ref<Almanac | null>(null)
onShow(() => { onShow(() => {
uni.setNavigationBarTitle({ title: t('almanac.pageTitle') }) loadTodayAlmanac()
}) })
/** 默认加载今日黄历 */
async function loadTodayAlmanac(): Promise<void> {
const today = new Date().toISOString().slice(0, 10)
todayAlmanac.value = await almanacService.getAlmanacByDate(today)
}
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const hasSearched = ref(false) const hasSearched = ref(false)
const searchResults = ref<SearchResult[]>([]) const searchResults = ref<SearchResult[]>([])
const searchMode = ref<'keyword' | 'advanced'>('advanced') const searchMode = ref<'keyword' | 'advanced'>('advanced')
// 使 computed + t() $t() i18n setup
const emptyIcon = computed(() => hasSearched.value ? 'empty' : 'search')
const emptyTitle = computed(() => hasSearched.value ? t('almanac.noResult') : t('almanac.noSearchYetTitle'))
const emptyDescription = computed(() => hasSearched.value ? t('almanac.noResultHelp') : t('almanac.noSearchYetDesc'))
const searchRequest = ref<SearchRequest>({ const searchRequest = ref<SearchRequest>({
conditions: [], conditions: [],
days: 30, days: 30,
@@ -185,10 +206,6 @@ const performSearch = async () => {
} }
} }
const loadMoreResults = () => {
console.log('加载更多结果')
}
const selectTemplate = (condition: SearchRequest) => { const selectTemplate = (condition: SearchRequest) => {
searchRequest.value = { ...condition } searchRequest.value = { ...condition }
performSearch() performSearch()
@@ -11,7 +11,6 @@ const i18n = createI18n({
fortune: { fortune: {
daily: '日运', daily: '日运',
monthly: '月运', monthly: '月运',
yearly: '年运',
pageTitle: '运势分析', pageTitle: '运势分析',
overallScore: '综合评分: {score}', overallScore: '综合评分: {score}',
selectDate: '选择日期', selectDate: '选择日期',
@@ -24,33 +23,14 @@ const i18n = createI18n({
luckyNumber: '幸运数字: ', luckyNumber: '幸运数字: ',
luckyDirection: '幸运方向: ', luckyDirection: '幸运方向: ',
noChartHint: '请先完成排盘', noChartHint: '请先完成排盘',
goToZiwei: '去排盘',
}, },
}, },
}, },
}) })
// Mock wx 全局对象 // Mock wx 全局对象(订阅功能已移除,仅占位)
const mockWx = { const mockWx = {}
requestSubscribeMessage: vi.fn(),
cloud: {
callFunction: vi.fn(),
},
}
// Mock chart data
const mockChart = {
birthInfo: {
birthTime: '1990-06-15 08:30',
birthPlace: '北京',
longitude: 116.4,
latitude: 39.9,
gender: 'male',
},
heavenlyStem: '庚',
earthlyBranch: '午',
palaces: [],
stars: [],
}
function mountPage() { function mountPage() {
return mount(FortunePage, { return mount(FortunePage, {
@@ -78,95 +58,15 @@ describe('FortunePage', () => {
const wrapper = mountPage() const wrapper = mountPage()
expect(wrapper.vm.activeTab).toBe('daily') expect(wrapper.vm.activeTab).toBe('daily')
}) })
})
describe('FortunePage - 订阅功能', () => { it('无排盘数据时,应显示引导去排盘的提示', async () => {
beforeEach(() => {
vi.stubGlobal('wx', mockWx)
vi.clearAllMocks()
// 设置存储中有排盘数据,使 chart 不为 null
const store = (globalThis as any).__uniStorage__ ?? {}
store['eis_ziwei_chart'] = JSON.stringify(mockChart)
;(globalThis as any).__uniStorage__ = store
})
it('有排盘数据时,应显示订阅入口区域', async () => {
const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
const subscriptionSection = wrapper.find('.subscription-section')
expect(subscriptionSection.exists()).toBe(true)
})
it('未订阅时,应显示"订阅每日推送"按钮', async () => {
const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
expect(wrapper.find('.subscribe-btn').exists()).toBe(true)
expect(wrapper.find('.subscribe-btn').text()).toContain('订阅每日推送')
})
it('已订阅时,应显示已订阅状态和推送时间', async () => {
const store = (globalThis as any).__uniStorage__ ?? {}
store['eis_push_subscription'] = JSON.stringify({
pushTime: '07:00',
pushEnabled: true,
subscribedAt: '2026-08-13T00:00:00.000Z',
})
;(globalThis as any).__uniStorage__ = store
const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
expect(wrapper.find('.subscription-status').exists()).toBe(true)
expect(wrapper.find('.subscribed-label').text()).toContain('已订阅')
expect(wrapper.find('.push-time-info').text()).toContain('07:00')
})
it('已订阅时,应显示"管理订阅"入口', async () => {
const store = (globalThis as any).__uniStorage__ ?? {}
store['eis_push_subscription'] = JSON.stringify({
pushTime: '07:00',
pushEnabled: true,
subscribedAt: '2026-08-13T00:00:00.000Z',
})
;(globalThis as any).__uniStorage__ = store
const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
expect(wrapper.find('.manage-link').exists()).toBe(true)
expect(wrapper.find('.manage-link').text()).toContain('管理订阅')
})
it('点击"管理订阅"应跳转到订阅管理页面', async () => {
const store = (globalThis as any).__uniStorage__ ?? {}
store['eis_push_subscription'] = JSON.stringify({
pushTime: '07:00',
pushEnabled: true,
subscribedAt: '2026-08-13T00:00:00.000Z',
})
;(globalThis as any).__uniStorage__ = store
const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
wrapper.find('.manage-link').trigger('tap')
expect(uni.navigateTo).toHaveBeenCalledWith({ url: '/pages/push-subscription/index' })
})
it('无排盘数据时,不应显示订阅入口', async () => {
;(globalThis as any).__uniStorage__ = {} ;(globalThis as any).__uniStorage__ = {}
const wrapper = mountPage() const wrapper = mountPage()
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.subscription-section').exists()).toBe(false) expect(wrapper.find('.no-chart-hint').exists()).toBe(true)
expect(wrapper.find('.hint-action').exists()).toBe(true)
}) })
}) })
@@ -44,20 +44,10 @@
<view v-if="!chart" class="no-chart-hint"> <view v-if="!chart" class="no-chart-hint">
<text class="hint-text">{{ $t('fortune.noChartHint') }}</text> <text class="hint-text">{{ $t('fortune.noChartHint') }}</text>
<button class="hint-action" @tap="goToZiwei">{{ $t('fortune.goToZiwei') }}</button>
</view> </view>
<!-- 订阅入口 --> <BottomNavigation currentTab="fortune" />
<view v-if="chart" class="subscription-section">
<view v-if="isSubscribed" class="subscription-status">
<text class="subscribed-label"> 已订阅每日运势推送</text>
<text class="push-time-info">推送时间每天 {{ pushTime }}</text>
<text class="manage-link" @tap="navigateToSubscription">管理订阅</text>
</view>
<view v-else class="subscription-entry">
<text class="subscribe-hint">开启每日推送不错过每日运势</text>
<button class="subscribe-btn" @tap="handleSubscribe">订阅每日推送</button>
</view>
</view>
</view> </view>
</template> </template>
@@ -70,12 +60,16 @@ import { ZiweiService } from '../../services/ziweiService'
import { storage, STORAGE_KEYS } from '../../utils/storage' import { storage, STORAGE_KEYS } from '../../utils/storage'
import type { ZiweiChart } from '../../algorithms/types' import type { ZiweiChart } from '../../algorithms/types'
import type { DailyFortune, MonthlyFortune } from '../../algorithms/types' import type { DailyFortune, MonthlyFortune } from '../../algorithms/types'
import BottomNavigation from '../../components/BottomNavigation/BottomNavigation.vue'
const fortuneService = new FortuneService() const fortuneService = new FortuneService()
const { t } = useI18n() const { t } = useI18n()
onShow(() => { onShow(() => {
uni.setNavigationBarTitle({ title: t('fortune.pageTitle') }) uni.setNavigationBarTitle({ title: t('fortune.pageTitle') })
// uni-app onMounted
//
loadChartFromStorage()
}) })
const activeTab = ref<'daily' | 'monthly'>('daily') const activeTab = ref<'daily' | 'monthly'>('daily')
@@ -89,13 +83,20 @@ onMounted(async () => {
const today = new Date() const today = new Date()
selectedDate.value = today.toISOString().slice(0, 10) selectedDate.value = today.toISOString().slice(0, 10)
selectedMonth.value = today.toISOString().slice(0, 7) selectedMonth.value = today.toISOString().slice(0, 7)
// onShow
const savedChart = storage.get<ZiweiChart>(STORAGE_KEYS.ZIWEI_CHART) loadChartFromStorage()
if (savedChart) {
chart.value = savedChart
}
}) })
/** 从本地存储加载排盘数据,驱动运势与订阅入口展示 */
function loadChartFromStorage(): void {
const savedChart = storage.get<ZiweiChart>(STORAGE_KEYS.ZIWEI_CHART)
chart.value = savedChart ?? null
if (!savedChart) {
dailyFortune.value = null
monthlyFortune.value = null
}
}
const onDateChange = async (e: any) => { const onDateChange = async (e: any) => {
selectedDate.value = e.detail.value selectedDate.value = e.detail.value
if (chart.value) { if (chart.value) {
@@ -111,103 +112,9 @@ const onMonthChange = async (e: any) => {
} }
} }
// ===== ===== /** 未排盘时引导用户前往紫微斗数页完成排盘 */
const PUSH_SUB_KEY = 'eis_push_subscription' function goToZiwei() {
const SUBSCRIBE_TEMPLATE_ID = '' // uni.navigateTo({ url: '/pages/ziwei/index' })
interface PushSubscription {
pushTime: string
pushEnabled: boolean
subscribedAt: string
}
const isSubscribed = ref(false)
const pushTime = ref('07:00')
onMounted(() => {
checkSubscription()
})
function checkSubscription() {
try {
const raw = uni.getStorageSync(PUSH_SUB_KEY)
if (raw) {
const sub = JSON.parse(raw) as PushSubscription
isSubscribed.value = sub.pushEnabled
pushTime.value = sub.pushTime || '07:00'
}
} catch {
isSubscribed.value = false
}
}
async function handleSubscribe() {
if (!chart.value) {
uni.showToast({ title: '请先完成排盘', icon: 'none' })
return
}
if (!chart.value.birthInfo) {
uni.showToast({ title: '请先设置生辰信息', icon: 'none' })
return
}
try {
//
const tmplIds = [SUBSCRIBE_TEMPLATE_ID]
if (!tmplIds[0]) {
uni.showToast({ title: '推送模板未配置', icon: 'none' })
return
}
const subscribeResult = await wx.requestSubscribeMessage({ tmplIds })
if (subscribeResult[SUBSCRIBE_TEMPLATE_ID] !== 'accept') {
uni.showToast({ title: '需要同意订阅才能推送', icon: 'none' })
return
}
//
const birthInfo = chart.value.birthInfo
const result = await wx.cloud.callFunction({
name: 'subscribe',
data: {
birthInfo: {
birthDate: birthInfo.birthTime?.slice(0, 10) || '',
birthHour: parseInt(birthInfo.birthTime?.slice(11, 13) || '0'),
birthMinute: parseInt(birthInfo.birthTime?.slice(14, 16) || '0'),
birthPlace: birthInfo.birthPlace || '',
longitude: birthInfo.longitude || 0,
latitude: birthInfo.latitude || 0,
gender: birthInfo.gender,
},
templateId: SUBSCRIBE_TEMPLATE_ID,
pushTime: '07:00',
},
})
if (result.result.success) {
//
const subData: PushSubscription = {
pushTime: '07:00',
pushEnabled: true,
subscribedAt: new Date().toISOString(),
}
uni.setStorageSync(PUSH_SUB_KEY, JSON.stringify(subData))
isSubscribed.value = true
pushTime.value = '07:00'
uni.showToast({ title: '订阅成功', icon: 'success' })
} else {
uni.showToast({ title: '订阅失败,请重试', icon: 'none' })
}
} catch (err) {
console.error('Subscribe error:', err)
uni.showToast({ title: '订阅失败,请重试', icon: 'none' })
}
}
function navigateToSubscription() {
uni.navigateTo({ url: '/pages/push-subscription/index' })
} }
</script> </script>
@@ -269,7 +176,7 @@ function navigateToSubscription() {
.overall-luck { .overall-luck {
font-size: 16px; font-size: 16px;
color: #e74c3c; color: #c41e3a;
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -303,66 +210,25 @@ function navigateToSubscription() {
} }
.no-chart-hint { .no-chart-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center; text-align: center;
padding: 40px 20px; padding: 40px 20px;
} }
.hint-text { .hint-text {
font-size: 14px; font-size: 14px;
color: #999;
}
.subscription-section {
margin-top: 16px;
background-color: #fff;
border-radius: 12px;
padding: 16px;
}
.subscription-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.subscribed-label {
font-size: 15px;
font-weight: 600;
color: #52c41a;
}
.push-time-info {
font-size: 13px;
color: #666; color: #666;
} }
.manage-link { .hint-action {
font-size: 14px; width: 180px;
color: #1677ff;
text-decoration: underline;
padding: 4px 0;
}
.subscription-entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.subscribe-hint {
font-size: 14px;
color: #666;
text-align: center;
}
.subscribe-btn {
width: 200px;
height: 40px; height: 40px;
line-height: 40px; line-height: 40px;
text-align: center; text-align: center;
background-color: rgba(44, 24, 16, 255); background-color: rgba(196, 30, 58, 255);
color: #fff; color: #fff;
border-radius: 20px; border-radius: 20px;
font-size: 15px; font-size: 15px;
@@ -1,193 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { triggerOnShow } from '../../../__mocks__/dcloudio/uni-app'
import SubscriptionPage from '../index.vue'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {},
})
// Mock wx 全局对象
const mockWx = {
cloud: {
callFunction: vi.fn(),
},
}
function mountPage() {
return mount(SubscriptionPage, {
global: {
plugins: [i18n],
stubs: {
picker: { template: '<div><slot /></div>' },
},
},
})
}
describe('PushSubscriptionPage', () => {
beforeEach(() => {
vi.stubGlobal('wx', mockWx)
vi.clearAllMocks()
// 默认设置订阅状态
const store = (globalThis as any).__uniStorage__ ?? {}
store['eis_push_subscription'] = JSON.stringify({
pushTime: '07:00',
pushEnabled: true,
subscribedAt: '2026-08-13T00:00:00.000Z',
})
;(globalThis as any).__uniStorage__ = store
})
afterEach(() => {
vi.useRealTimers()
})
it('应显示订阅状态为"已开启"', async () => {
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
expect(wrapper.find('.status-value').exists()).toBe(true)
expect(wrapper.find('.status-value').text()).toContain('已开启')
})
it('应显示当前推送时间', async () => {
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
expect(wrapper.find('.time-value').exists()).toBe(true)
expect(wrapper.find('.time-value').text()).toBe('07:00')
})
it('点击"取消订阅"应弹出确认对话框', async () => {
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
wrapper.find('.unsubscribe-btn').trigger('tap')
expect(uni.showModal).toHaveBeenCalled()
const callArgs = (uni.showModal as any).mock.calls[0][0]
expect(callArgs.title).toContain('确认取消')
expect(callArgs.content).toContain('取消后将不再收到每日运势推送')
})
it('确认取消订阅后应调用云函数并清除缓存', async () => {
// 模拟 wx.cloud.callFunction 返回成功
mockWx.cloud.callFunction.mockResolvedValue({
result: { success: true },
})
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
// 触发取消订阅
wrapper.find('.unsubscribe-btn').trigger('tap')
// 模拟确认对话框
const modalCall = (uni.showModal as any).mock.calls[0][0]
modalCall.success({ confirm: true })
// 等待异步操作完成(包括 setTimeout 1500ms
await new Promise((r) => setTimeout(r, 2000))
await wrapper.vm.$nextTick()
// 验证云函数被调用
expect(mockWx.cloud.callFunction).toHaveBeenCalledWith({ name: 'unsubscribe' })
// 验证本地缓存被清除
expect(uni.removeStorageSync).toHaveBeenCalledWith('eis_push_subscription')
// 验证提示
expect(uni.showToast).toHaveBeenCalledWith({ title: '已取消订阅', icon: 'success' })
// 验证返回上一页
expect(uni.navigateBack).toHaveBeenCalled()
})
it('取消订阅失败时应提示"取消失败"', async () => {
// 模拟云函数返回失败
mockWx.cloud.callFunction.mockResolvedValue({
result: { success: false, error: '未找到订阅记录' },
})
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
wrapper.find('.unsubscribe-btn').trigger('tap')
const modalCall = (uni.showModal as any).mock.calls[0][0]
modalCall.success({ confirm: true })
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
expect(uni.showToast).toHaveBeenCalledWith({ title: '取消失败,请重试', icon: 'none' })
})
it('保存推送时间应调用云函数并更新本地缓存', async () => {
mockWx.cloud.callFunction.mockResolvedValue({
result: { success: true },
})
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
// 修改时间
await wrapper.vm.onTimeChange({ detail: { value: '08:00' } })
await wrapper.vm.$nextTick()
// 保存
wrapper.find('.save-btn').trigger('tap')
await new Promise((r) => setTimeout(r, 50))
await wrapper.vm.$nextTick()
// 验证云函数被调用
expect(mockWx.cloud.callFunction).toHaveBeenCalledWith({
name: 'updatePushTime',
data: { pushTime: '08:00' },
})
// 验证提示
expect(uni.showToast).toHaveBeenCalledWith({ title: '保存成功', icon: 'success' })
})
it('未订阅时,应使用默认时间 07:00', async () => {
// 清空存储
;(globalThis as any).__uniStorage__ = {}
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedTime).toBe('07:00')
})
it('时间选择应四舍五入到 30 分钟步长', async () => {
const wrapper = mountPage()
triggerOnShow()
await wrapper.vm.$nextTick()
// 07:08 → 07:00
wrapper.vm.onTimeChange({ detail: { value: '07:08' } })
expect(wrapper.vm.selectedTime).toBe('07:00')
// 07:15 → 07:30
wrapper.vm.onTimeChange({ detail: { value: '07:15' } })
expect(wrapper.vm.selectedTime).toBe('07:30')
// 07:45 → 08:00
wrapper.vm.onTimeChange({ detail: { value: '07:45' } })
expect(wrapper.vm.selectedTime).toBe('08:00')
// 22:45 → 22:00(上限 22:00
wrapper.vm.onTimeChange({ detail: { value: '22:45' } })
expect(wrapper.vm.selectedTime).toBe('22:00')
// 05:30 → 06:30(下限 06:00,分钟 30 保留)
wrapper.vm.onTimeChange({ detail: { value: '05:30' } })
expect(wrapper.vm.selectedTime).toBe('06:30')
})
})
@@ -1,263 +0,0 @@
<template>
<view class="subscription-manage">
<!-- 订阅状态 -->
<view class="status-card">
<text class="status-title">订阅状态</text>
<view class="status-row">
<text class="status-label">每日运势推送</text>
<text class="status-value active">已开启</text>
</view>
</view>
<!-- 推送时间设置 -->
<view class="setting-card">
<text class="setting-title">推送时间设置</text>
<text class="setting-hint">每日在此时间推送当日运势30分钟步长</text>
<picker
mode="time"
:value="selectedTime"
:start="'06:00'"
:end="'22:00'"
@change="onTimeChange"
>
<view class="time-picker">
<text class="time-label">推送时间</text>
<text class="time-value">{{ selectedTime }}</text>
</view>
</picker>
<button
class="save-btn"
:disabled="!timeChanged"
@tap="savePushTime"
>
保存设置
</button>
</view>
<!-- 取消订阅 -->
<view class="action-card">
<button class="unsubscribe-btn" @tap="handleUnsubscribe">
取消订阅
</button>
<text class="unsubscribe-hint">取消后将不再收到每日运势推送</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const PUSH_SUB_KEY = 'eis_push_subscription'
interface PushSubscription {
pushTime: string
pushEnabled: boolean
subscribedAt: string
}
const selectedTime = ref('07:00')
const savedTime = ref('07:00')
const timeChanged = ref(false)
onShow(() => {
uni.setNavigationBarTitle({ title: '订阅管理' })
loadSubscription()
})
function loadSubscription() {
try {
const raw = uni.getStorageSync(PUSH_SUB_KEY)
if (raw) {
const sub = JSON.parse(raw) as PushSubscription
selectedTime.value = sub.pushTime || '07:00'
savedTime.value = selectedTime.value
timeChanged.value = false
}
} catch {
//
}
}
function onTimeChange(e: any) {
// 30 00 30
const [hour, minute] = e.detail.value.split(':').map(Number)
const roundedMinute = minute < 15 ? 0 : minute < 45 ? 30 : 0
const adjustedHour = minute >= 45 ? hour + 1 : hour
const finalHour = Math.min(Math.max(adjustedHour, 6), 22)
selectedTime.value = `${String(finalHour).padStart(2, '0')}:${String(roundedMinute).padStart(2, '0')}`
timeChanged.value = selectedTime.value !== savedTime.value
}
async function savePushTime() {
try {
const result = await wx.cloud.callFunction({
name: 'updatePushTime',
data: { pushTime: selectedTime.value },
})
if (result.result.success) {
//
const raw = uni.getStorageSync(PUSH_SUB_KEY)
if (raw) {
const sub = JSON.parse(raw) as PushSubscription
sub.pushTime = selectedTime.value
uni.setStorageSync(PUSH_SUB_KEY, JSON.stringify(sub))
}
savedTime.value = selectedTime.value
timeChanged.value = false
uni.showToast({ title: '保存成功', icon: 'success' })
} else {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
} catch (err) {
console.error('Save push time error:', err)
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
async function handleUnsubscribe() {
uni.showModal({
title: '确认取消',
content: '取消后将不再收到每日运势推送,确定要取消吗?',
success: async (res) => {
if (res.confirm) {
try {
const result = await wx.cloud.callFunction({
name: 'unsubscribe',
})
if (result.result.success) {
//
uni.removeStorageSync(PUSH_SUB_KEY)
uni.showToast({ title: '已取消订阅', icon: 'success' })
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({ title: '取消失败,请重试', icon: 'none' })
}
} catch (err) {
console.error('Unsubscribe error:', err)
uni.showToast({ title: '取消失败,请重试', icon: 'none' })
}
}
},
})
}
</script>
<style scoped lang="scss">
.subscription-manage {
min-height: 100vh;
background-color: rgba(244, 244, 245, 0.3);
padding: 16px;
}
.status-card,
.setting-card,
.action-card {
background-color: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.status-title,
.setting-title {
font-size: 16px;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 12px;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.status-label {
font-size: 15px;
color: #333;
}
.status-value {
font-size: 15px;
font-weight: 600;
}
.status-value.active {
color: #52c41a;
}
.setting-hint {
font-size: 13px;
color: #999;
display: block;
margin-bottom: 16px;
}
.time-picker {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
}
.time-label {
font-size: 15px;
color: #333;
}
.time-value {
font-size: 15px;
color: #1677ff;
font-weight: 600;
}
.save-btn {
width: 100%;
height: 40px;
line-height: 40px;
text-align: center;
background-color: rgba(44, 24, 16, 255);
color: #fff;
border-radius: 20px;
font-size: 15px;
margin-top: 12px;
border: none;
}
.save-btn[disabled] {
opacity: 0.5;
}
.unsubscribe-btn {
width: 100%;
height: 40px;
line-height: 40px;
text-align: center;
background-color: #fff;
color: #ff4d4f;
border-radius: 20px;
font-size: 15px;
border: 1px solid #ff4d4f;
}
.unsubscribe-hint {
font-size: 12px;
color: #999;
display: block;
text-align: center;
margin-top: 8px;
}
</style>
@@ -31,9 +31,17 @@
</view> </view>
<view class="action-bar"> <view class="action-bar">
<button data-test="generate-btn" class="generate-btn" @tap="generateChart" :disabled="isCalculating"> <button
data-test="generate-btn"
class="generate-btn"
@tap="generateChart"
:disabled="!birthDate || isCalculating"
:class="{ 'generate-btn--disabled': !birthDate }"
>
{{ isCalculating ? $t('ziwei.generating') : $t('ziwei.generate') }} {{ isCalculating ? $t('ziwei.generating') : $t('ziwei.generate') }}
</button> </button>
<text v-if="!birthDate" class="generate-hint">{{ $t('ziwei.selectBirthDateFirst') }}</text>
<text v-else-if="generateError" class="generate-hint error">{{ generateError }}</text>
</view> </view>
<view v-if="chart" class="section chart-result"> <view v-if="chart" class="section chart-result">
@@ -72,6 +80,8 @@
<text class="section-title">{{ $t('ziwei.summary') }}</text> <text class="section-title">{{ $t('ziwei.summary') }}</text>
<text class="summary-text">{{ chart.summary }}</text> <text class="summary-text">{{ chart.summary }}</text>
</view> </view>
<BottomNavigation currentTab="ziwei" />
</view> </view>
</template> </template>
@@ -83,6 +93,7 @@ import { ZiweiService } from '../../services/ziweiService'
import { PalaceType, EarthlyBranch, MajorStar } from '../../algorithms/enums' import { PalaceType, EarthlyBranch, MajorStar } from '../../algorithms/enums'
import type { ZiweiChart } from '../../algorithms/types' import type { ZiweiChart } from '../../algorithms/types'
import { lookupCityCoordinates } from '../../data/cityCoordinates' import { lookupCityCoordinates } from '../../data/cityCoordinates'
import BottomNavigation from '../../components/BottomNavigation/BottomNavigation.vue'
const ziweiService = new ZiweiService() const ziweiService = new ZiweiService()
const { t } = useI18n() const { t } = useI18n()
@@ -99,6 +110,7 @@ const isCalculating = ref(false)
const isLookingUp = ref(false) const isLookingUp = ref(false)
const detectedLocation = ref('') const detectedLocation = ref('')
const chart = ref<ZiweiChart | null>(null) const chart = ref<ZiweiChart | null>(null)
const generateError = ref('')
let lookupTimer: ReturnType<typeof setTimeout> | null = null let lookupTimer: ReturnType<typeof setTimeout> | null = null
@@ -168,9 +180,13 @@ const getStarName = (star: any): string => {
} }
const generateChart = async () => { const generateChart = async () => {
if (!birthDate.value) return if (!birthDate.value) {
generateError.value = t('ziwei.requireBirthDate')
return
}
isCalculating.value = true isCalculating.value = true
generateError.value = ''
try { try {
const hour = hourIndex.value * 2 const hour = hourIndex.value * 2
const birthTime = `${birthDate.value}T${String(hour).padStart(2, '0')}:00:00` const birthTime = `${birthDate.value}T${String(hour).padStart(2, '0')}:00:00`
@@ -196,6 +212,7 @@ const generateChart = async () => {
}) })
} catch (e) { } catch (e) {
console.error('排盘失败:', e) console.error('排盘失败:', e)
generateError.value = t('ziwei.generateFailed')
} finally { } finally {
isCalculating.value = false isCalculating.value = false
} }
@@ -291,7 +308,7 @@ const generateChart = async () => {
} }
.location-hint.looking-up { .location-hint.looking-up {
color: #999; color: #666;
} }
.action-bar { .action-bar {
@@ -302,12 +319,29 @@ const generateChart = async () => {
width: 100%; width: 100%;
height: 48px; height: 48px;
line-height: 48px; line-height: 48px;
background-color: rgba(44, 24, 16, 255); background-color: rgba(196, 30, 58, 255);
color: #fff; color: #fff;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
border-radius: 12px; border-radius: 12px;
border: none; border: none;
&--disabled {
background-color: rgba(196, 30, 58, 0.35);
}
}
.generate-hint {
display: block;
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: #666;
text-align: center;
&.error {
color: rgba(196, 30, 58, 255);
}
} }
.generate-btn[disabled] { .generate-btn[disabled] {
@@ -336,7 +370,7 @@ const generateChart = async () => {
.palace-branch { .palace-branch {
font-size: 12px; font-size: 12px;
color: #999; color: #666;
display: block; display: block;
margin-top: 2px; margin-top: 2px;
} }
@@ -344,7 +378,7 @@ const generateChart = async () => {
.palace-score { .palace-score {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #e74c3c; color: #c41e3a;
display: block; display: block;
margin-top: 4px; margin-top: 4px;
} }
@@ -70,11 +70,13 @@ describe('SearchService', () => {
expect(history.length).toBe(0) expect(history.length).toBe(0)
}) })
// 55 次顺序搜索在覆盖率插桩(v8 instrumentation)下会变慢,超过默认 5s 超时;
// 此处放宽超时以适配覆盖率运行环境,逻辑本身正确(验证历史上限 50 条)
it('搜索历史最多保留50条', async () => { it('搜索历史最多保留50条', async () => {
for (let i = 0; i < 55; i++) { for (let i = 0; i < 55; i++) {
await service.searchByKeyword(`关键词${i}`) await service.searchByKeyword(`关键词${i}`)
} }
const history = await service.getSearchHistory() const history = await service.getSearchHistory()
expect(history.length).toBeLessThanOrEqual(50) expect(history.length).toBeLessThanOrEqual(50)
}) }, 30000)
}) })
@@ -0,0 +1,7 @@
/* 自动生成: scripts/iconfont-build.py —— FontAwesome 子集化图标字体(仅含所需字形) */
@font-face {
font-family: 'fa-icons';
src: url(data:font/ttf;charset=utf-8;base64,d09GMk9UVE8AAAyQAAkAAAAAFvAAAAxJA4MDAAAAAAAAAAAAAAAAAAAAAAAAAAAADaRCBmAAggABNgIkA4EQBAYFgxwHIBtNFlFUsWIAvi7gyX4N1WIxHoskZLWddjlD5vbdBuHryUYGn7y3UCDgf++QWBlRwckISWYtGmu7ZzZvjjeSWmg0qxZyg2Sa/obysixTYQpneymcbCaqpHQL8nV3eR6tH1kS5O0eOXxpWrUJUtK7sY7n8Xf//BJjUhKjVtRsryQlSOdubMaSsUxNsqqq4wuqrE4VSOIjurcAPE8IiskBKXbt7yZb/8umVoFqi95xFaKKkBQZ4dZaiZ+tkQ9ya8YugNC5CuPXzJf/OTORZh4gS0mLy1u0bNnePiX52PxEsWTZkp1kj/MuLiC1as1UV7UwcSpRD6gQ1W2eaPdgZgPc9cJpPH7u1d4rLRmQ+nMFNG5Eak4ll1yTeyVMBsyvQG4ApAgcAVpE41mPpZrzc9PLPLLWuhdK7CFtXaYugBXgR47KVEE4JBYfJ+WHdFQQKNmPE0ry+igrtB4w8j+gzJ404XIn5P+0nT3rZs97+8dRnZ7Mfm1LTcp+mGaSvxYzj58SPXQmTx/O3v5ah9nLZfnExN9ktnBFXArCNC9rMmPFjooTN15INF6AEBFiJEiRIUeBEhU06jRp06XPkDFTTnqMvEpcT6VmS00fwQgWOJ2iBAReh4SggNFoXrA6T0aoNTRBYiMoAQUNnCA1ks1QkwJqDRI6QwLPbM3l+hoDMxgGLiv5LEXFHMjpdCNyPO9NkCRLUn0ac43UFKxPwf4pM6vDWG/Km3T77StnKnPDXE/sU7KfsG2lRkGWVrc9BGEJryUEj+RXRLl1U2LDK/ilsBdjsk6+VVO6Th8wtWaH+WhZO26fOhe3yl3xseFQ+BW3+Dy5pvXpYQ7NB8qysoSz4bxAzIc/0cUA1L6wK4nFsFAlwBAMBSPjJOhsJhDaUmNo/K6jGUDYUEPAEAK290YE9FFqbHDzGkimaQLXoBg7DRnljmzipjiac9NciIxqwk85alH7+VPXyKP4WKtELcsT50c9f66P9oR3PG/Lwjt+qm3/Y553fJQJH/Ml7p7BaptZ+8Dd9iPhpR73aAmIMLA8sTRvG0ypmrwmlqGusuX8G/+Ym8vlpjjDy8ePP8hO425pvwtxgW6EwNAWUXGCgsgLgXmMg+FkqJjMSo6IUlxnIGWimMIQMaV4ncMMNYN6DMI/wPE2WOFmaCpYCWUHFjkEQUpHsbl6ZEuLiC01H4LhMcwnHKLOWz2fj3T3rl6aHg29uykuw8EvbQLL/h68lHk3tEarvl4VhhLvU4FmhXp4T1PgW/3HCtkaQNRFVBhw1yK1nBpYzD9YHhA5H8AX42OpwcABpNaW89DuFvkYP51A/f4E22u2VJdCJHEvdLlwxyflmIah6B1kl3bgkQxHdOW2nAPZewbBALUDak1bkSfZdZPUUksgJbeSPd5z4VkRy/hu3/aEO0ItukQFqFnEaoJeOo4nh1Cb6LM6Qs8WZhobVdzn5p2o9JMkAaQwcK8We7i+0Zokgvdf0W8E46bikhZI5cIyli9CpDzq1cS1QCm8nXUof4b51V5nyyX5KAvVeuThvQK/VtO2wbXbiFkDQzVgDc2wxqbzqHDKae3Rc4WZeqL7HI+nw7U70xnUPMWTfMEburqiJ+DM/NUVcQ+KeV0Be66KJ0H4joij3JHIY3JrlkOpIqwyEtINPaEODBwOHD1+vBTokNggjo/pEqBw1IFpuLpySLhFxy241ctxiG/yjFgnthfpBRgLBgK+QB07OweE4l1/eKsE7D8Fw43kMaWh6BxfqqDllrzDlz/YoWkr9jzObP8+gARweh7iJD0uKAAeSut69AffxzC4cByIO4EN6+E5caK7U264iTEAXRpMrTahCDCSXkQ00GvJA7ptPUG89Abqnx/f/5Vd1Z3Fyy4AGhDBCrAdvAiAw4AmBRDG+2Azq3DYrnPbxytENlNJ9Zw0tkiJq2cz1EcnTk2RTOpoxffFk1KxpKqOZxWMCTPaK2GbcbYTLvCL4aL9JrvA2wCcqB+8zCSCwVKQQCWbAsW9Tbmi7EvilVxhjo+d3/Xv8a2MzPT/0143MpHE80iPx7NOQUDqfpPBCXmyGoR/h+MF2EE/aDTCOLohByRh3J4li2IiaiOQ0w8V9LHI3Fx8q0BbG2tpoav61WVxA/hcNjK4vJAlh3fqsIhSqc5gBuyGVXh7ClaVqbQGMT4eRv9DMl9FzPFIbtb1bx0CLAKbH6+/ZSSKXdX1R/o2fYlnd2yxVnFoSJfZsJNDiDW7blbul5jbxVhBZOPW7xpmum6UnukjTQNxoAeAElUmZKYjhAy/uYSGSHA8SPmpLAzwOgVIfkCGPVx3m5wG00ToWwUTrjKtVxEo3ZyqTvLfH4IQwFAiZjyAnQFwRYny/jkCqoyLSNy0JjkjGiQB2KxIwmsijmhUTtfy718ohcreVSZR9KoiMhHojAFDiEcVEm9UKf6gSljEMqmyvuAkUZh/2X4tQilXDGNyYAZYrmFj1NCgSgMrPo/Y10c8qmhEOAlrqQXMwmQVlB3NdAJcvX79W/ZFwAzAqLObwA11ylr6kh35h1ROOMo5UTlXNFGezkyVnCz1SCplvhopAMIN9jHXygsTgWr7cZiSGuEIyGhkC8K3XQ43Iwe/pt44ooj3oH3dE8B/mFqKrICUXHCxmvWvEmY6KmXT9wSNphk6mH8U6xqCScluGXyJdI7jYQQE9BWKCeyS+pimxQU0PuQfXXgs3fCozVE8MnyzExg8Pr4s0wel4woe5dsbykhrPagNY8jCVcCWhsf3AApSLiMQkPu2+B4+LZq3v483JQ58npB8boex3bXAP1NtwRKu1Co7BHggEkgyJEXHoIMjhNSrpQNNVbXlF6EoywLiQpmq+fvcFFigw/6M6iT278OJ0pQIndSXqmGY/afg4aMaQ6mhbFGhp0vt62CI9Amf8Kky1Zai2Ulb2xI2s+3V99eZJOACZTGJEZLK7AAUl7EAo5BSQmd40AFH+dJVcBO1Iw9QFCjOY2HbqssdQm5cOzGVrsCXThdfeSzqAHWlf1CoAKiX08hx/XUsdGtdEdlaYJDA+8h1bAWAUsb17YFE1IHhAeYi7/o8IDUN+6z4UcEJfYSStus6n6Rehb4d3al76XnDsh1ueybKV0Y6I4Y5lXPFQuW2LyYcFtrNdpSPFhs7RfBeRP64Ezh1fHv7kBDCt5foGeFLn8/iOJ/bOsQOxllZqQfdIllEH1+ri8kT9PUHtcJjt7GIugABGSMYBw/yPlZy0ZZgY5SA9ZLDlitQ9joNOJSPAjvAi3YkQqY0Q5jQ47EG2XoSLGejT2qIC0mz8Kfa2Qpghjp+qGOY8HniviGqYnx/X3EwB3VNDWBlRp7w6tTRbuDs1dWz6IUFA/gqYQuETu3IGCSD2MK2aaLM4vMBA2Yy4gWw3VxQCt7UPrdbQX+2Mk2dPZfFM4hUaizWOEPQVfILzzZCCqWktYJcsQ7Yxlioq12PnvxyqdzLC3QoAPtmvhdUakgrFEply7FzFj7oq11WlrLMN1tuDOeW/r/+ISOVHHVUpLLIeeWceo/T1fLnfIvoZLp2qlxot24HUypnSXg1vL9DQP/Np5ffydqmsjiPkg4ElRwXiTS0rSZkLeKt1/KH0KeSudV2kUg1mY1VOCoCWtjDpzpYWMfWiKzUsHJkPo1AO9Oe/UBo5qdYl7Md9wWaFmvH9tZoH830FEUhFsc+ciLyWJQHKZAgENHZ40EYDY3a6L7SpamGvZ73tATJ4Ls0/1xrRZkxb08OPLlYWyfC4U6WxGMtuWdvB2b3rnekNCtrM2NFxVu4dOWqNGnRaaV9LrvmVdwSm4S8yGytiFmh6k1RaYoJDdHxrWTE8GOOHy8eBDfe4DpOYh8h2NiE9ZhADbKQgdDyr1zlUYkuHiRxadkZU2TX/wHklMj5+7O/G/r/aI2x05ueem7y4w47Pu5VF2+f4PkdtXWE72SKWRY7vc4Zajr81Rh0h1FMdy9JvyYb5lV4sFByhw09ltliWjt2VkPu6bHTqM2xTSU7L0kFxGu68AvIKt+6Se2Mpm1ttyDPY9gGGCMUDzHSidbSyl2MdufAsRnrYU5hGjTq0kxDUDU5MMZVGbecI/tJ9aYDh+hQoWVzHQpjZ2oS65Ob7zhyWm/gWhrlUuEJ2nRel2iGwV5dzxsVPVJmn5vnpAaSKR6H9xHeCCSzxpxJOiyFxVa2HdKMvjAsqW5+VUMaS1/dXlMucwEA) format('truetype');
font-weight: normal;
font-style: normal;
}
@@ -0,0 +1,38 @@
{
"arrow-up": 61538,
"arrow-down": 61539,
"arrow-left": 61536,
"arrow-right": 61537,
"check": 61452,
"close": 61453,
"right": 61524,
"checkbox-checked": 61770,
"checkbox-unchecked": 61640,
"plus": 61543,
"minus": 61544,
"search": 61442,
"calendar": 61555,
"star": 61445,
"heart": 61444,
"settings": 61459,
"home": 61461,
"user": 61447,
"info": 61737,
"warning": 61553,
"refresh": 61473,
"download": 61465,
"upload": 61587,
"share": 61540,
"copy": 61637,
"delete": 61944,
"edit": 61508,
"filter": 61616,
"sort": 61660,
"export": 61773,
"history": 61914,
"bookmark": 61486,
"chevron-up": 61559,
"chevron-down": 61560,
"chevron-left": 61523,
"chevron-right": 61524
}
@@ -1,11 +1,27 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import Uni from '@dcloudio/vite-plugin-uni' import Uni from '@dcloudio/vite-plugin-uni'
import px2rpx from 'postcss-px2rpx'
const uniPlugin = Uni.default || Uni const uniPlugin = Uni.default || Uni
// 仅微信小程序构建启用 px→rpx 自适应转换(H5/其他端保持 px)
// 标准换算:375 设计稿基准,1px = 2rpxpostcss-px2rpx rpxUnit=0.5
const isMpWeixin = process.env.UNI_PLATFORM === 'mp-weixin'
export default defineConfig({ export default defineConfig({
plugins: [...uniPlugin()], plugins: [...uniPlugin()],
css: { css: {
postcss: isMpWeixin
? {
plugins: [
px2rpx({
rpxUnit: 0.5,
minPixelValue: 1,
mediaQuery: false,
}),
],
}
: undefined,
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
api: 'modern-compiler' api: 'modern-compiler'
+26
View File
@@ -0,0 +1,26 @@
{
"miniprogramRoot": "everything-is-suitable-uniapp/dist/build/mp-weixin/",
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wxfd35f544ed1db523",
"editorSetting": {}
}