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
@@ -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()
})
})