diff --git a/.gitignore b/.gitignore index fb263b6..09be9e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules/ dist/ +dist-h5/ .swc/ .DS_Store - diff --git a/config/index.ts b/config/index.ts index 0db465e..2fd244b 100644 --- a/config/index.ts +++ b/config/index.ts @@ -10,7 +10,7 @@ export default defineConfig(async () => ({ 828: 1.81 / 2 }, sourceRoot: 'src', - outputRoot: 'dist', + outputRoot: process.env.TARO_ENV === 'h5' ? 'dist-h5' : 'dist', plugins: ['@tarojs/plugin-framework-react'], defineConstants: {}, copy: { @@ -19,6 +19,14 @@ export default defineConfig(async () => ({ }, framework: 'react', compiler: 'webpack5', + h5: { + postcss: { + pxtransform: { + enable: true, + config: { baseFontSize: 20, minRootSize: 16, maxRootSize: 40 } + } + } + }, mini: { postcss: { pxtransform: { diff --git a/docs/five-elements-rules.md b/docs/five-elements-rules.md new file mode 100644 index 0000000..335ea92 --- /dev/null +++ b/docs/five-elements-rules.md @@ -0,0 +1,39 @@ +# 前端五行报告:普通扶抑简化参考算法 v1 + +用户确认采用可解释的普通扶抑综合评分。本规则为本产品的确定性参考算法,不是统一命理标准或专业断命依据;只作传统文化娱乐参考。 + +## 输入与排盘 + +- 只使用保存后的公历生日、出生时辰,不使用接口返回的 `elementReport`。 +- 使用锁定版本的 `lunar-typescript` 生成四柱和地支藏干;年/月柱沿用库的节气划分。 +- 输入按北京时间处理,不转换浏览器时区,不作真太阳时校正。 +- 只有时辰时,使用该时辰起始小时(子时23点,丑时1点等);库 `setSect(2)` 约定日柱零点换日。由于缺少具体分钟,节气交界的时辰无法精确划分;不声称处理了分钟级边界。 +- 日期支持1900至2100年;非法日期、缺失/非法时辰返回空状态,不伪造默认五行或日主。 + +## 组成比例 + +四个天干各10份,年/日/时支各10份,月支18份。地支分到其藏干:一藏干100%,两藏干70%/30%,三藏干60%/30%/10%,顺序使用历法库藏干顺序。这些份数为产品约定。用最大余数法转换为整数百分比,保证五项之和为100%。 + +## 三项评分 + +1. 得令(40%):以月支主五行作为季节,辰戌丑未简化为土。日主与季节同类为旺100分,季节生日主为相80分,日主生季节为休50分,日主克季节为囚30分,季节克日主为死15分。旺、相标为月令支持。 +2. 得地(30%):四支藏干中与日主同五行的份额相加,达到两个完整根计100分,其余按比例计分并封顶100分。有任何同类藏干即标为有根;“有根”不等于“根气充分”。 +3. 得势(30%):排除日干本身,计算其他天干和所有地支藏干中印星(生日主)与比劫(同日主)的加权份额占比。月支仍取18份,其他支10份,其他天干10份,占比即评分。达到50%标为生扶力量占优。 + +总分 = 得令×0.4 + 得地×0.3 + 得势×0.3,四舍五入到整数。该启发式中月令也参与藏干和得势,是刻意强调季节的产品约定,并非三份独立统计样本。 + +## 扶抑喜忌 + +- 小于45分:偏弱。喜生扶,列印星、比劫;忌项为食伤、财、官杀。 +- 大于55分:偏强。喜泄耗制衡,列食伤、财、官杀;忌项为印星、比劫。 +- 45到55分(含边界):相对平衡,不强行指定喜用/忌用;页面提示保持协调。 + +此处“喜用”是扶抑方向集合,不进一步判断唯一用神或精确优先级。不包含从格、专旺、化气、合冲刑害、调候、藏干司令天数等;不能把本算法表述为完整八字喜用神体系。 + +## 更新与验收 + +`useDidShow` 重新读取 `GET /api/user/profile` 的个人资料;生日或时辰改变后,`useMemo` 重新计算整份报告。昵称、头像、性别、地点不参与本版计算。加载失败显示重试入口,不退回假资料。保存使用原来的 `PUT /api/user/profile`,无需新增报告接口。 + +执行 `npm run test:elements`,覆盖强弱反转、藏干根气、无效日期、百分比守恒、时辰变化、节气月份切换和子时换日。构建验证仍需 `tsc --noEmit`、H5与小程序构建。 + +开发者工具验收:我的→我的元素,检查五项比例、强弱、得令/得地/得势说明;返回修改生日或时辰并保存,再次进入元素页检查四柱和报告。测试平衡命例时,喜忌应显示“暂无明显偏向/偏忌”。 diff --git a/docs/home-rules.md b/docs/home-rules.md new file mode 100644 index 0000000..4a8d2ff --- /dev/null +++ b/docs/home-rules.md @@ -0,0 +1,51 @@ +# 首页动态生成与验收(开发维护说明) + +用户确认:纯前端简化参考规则;页面不公开公式、权重、计算依据;维度卡片仅展示列表,不做详情交互。因此下面内容仅为维护文档,不渲染进首页。 + +## 数据与更新 + +只请求 `GET /api/user/profile` 获取生日、时辰、头像,不读取旧 `/api/home/overview` 的报告,也不使用异常时的固定42分示例。沿用 `calculateFiveElements` 的扶抑规则,以及按春节换年的生肖和合冲关系。需要有效生日、时辰,缺失时显示完善资料入口;网络失败显示重试。H5显式 `?preview=1` 由现有预览适配器提供示例个人资料,不请求后端。 + +北京时间日期作为基准,昨日/今日/明日分别偏移−1/0/+1。每份报告独立生成整套公历、星期、农历、摘要、指数、吉色吉数、吉向吉时和八个维度。页面显示时重读个人资料;北京时间零点刷新相对日期。生日、时辰、日期任一输入变化后重新计算,相同输入结果稳定,不调用随机数。 + +## 评分约定 + +本产品的简化娱乐规则,不代表统一命理标准。分值仅用于界面参考,不解释为健康、财富或事件发生的概率。 + +- 日主喜忌由已经确认的普通扶抑算法产生。 +- 某元素在喜用集合:基础权重 + round(max(0,20−个人该元素百分比)/5)。在忌用集合:−基础权重 − round(max(0,个人该元素百分比−20)/5)。相对平衡时不加减。即先判断扶抑方向,再小幅调节份额,不把“缺少”直接等同于“喜用”。 +- 当日公共基分=50 + 日干五行喜忌项(基础权重10) + 出生生肖年支对当日日支的关系分×5。关系分沿用六合+2、三合同组+1、六冲−2、相害−1、其余0。 +- 各维度再加该维度五行的喜忌项(基础权重8),以及日干五行对维度五行的同类/生助+6、相克−6、其余0。 +- 黄历宜忌读取所选日期的历法库数据,匹配该维度活动关键词。命中忌为−10,否则命中宜为+10,否则0;同项冲突优先忌。不把黄历活动误写成事件必然发生。 +- 维度分限制15到90;≥50显示宜,<50显示忌。展示文案按宜/忌选择对应普通生活建议,再用日干奇偶选择文案变体;不是固定的一套八维文案,也不以随机数伪装计算。 +- 八个维度等权平均并四舍五入为综合指数。≥80大吉、≥65小吉、≥50平日、≥35小逆、其余谨慎。 +- 维度关联:情感/社交=火,健康/事业=木,生活=土,财富=金,学业/出行=水。活动词见 `definitions`,属于产品简化映射。 + +普通建议仅涉及日常沟通、整理、作息、预算核对、任务安排和路线准备,不生成诊疗、投资或事故预测。摘要由当日状态和最高/最低分维度形成;同分时使用均衡描述。 + +## 吉色、吉数、吉向、吉时 + +- 有利五行取扶抑报告喜用集合;相对平衡时只在颜色/数字展示中取两个较低占比元素作装饰参考,不重新定义喜用。结合所选日干的生助关系排序,取两项。 +- 色盘映射:木为绿、火为粉橘、土为杏麦、金为白金、水为蓝;按日干奇偶选色调。 +- 数字映射采用产品约定的河图数:木3/8、火2/7、土5/0、金4/9、水1/6,按日支奇偶选对应数字。 +- 吉向读取所选当日 `getDayPositionXiDesc()`,作为传统喜神方位参考;指南针图标跟随方向旋转。 +- 吉时只在当日 `getTimes()` 的“吉”片段中选择,以时干喜忌项(基础权重2)和出生年支对时支关系排序,取两个不重复时支。用历法库具体起止片段显示:早子时00–01,夜子时23–24,不把跨日片段混作同一天。若无时段,显示暂无参考时段。 + +## 图标、列表、排序和导航 + +八维图标、水滴和灵境头像为原创矢量源转换的256像素透明PNG,源码在 `scripts/render-home-icons.cjs`。统一由共享 `AppTabBar` 提供首页与我的导航;H5关闭Taro重复系统tabBar,微信端保留原tab配置并使用 `switchTab`。 + +`src/index.html` 交给Taro的HTML插件生成预览入口与正确的脚本文件名,不再手写分块编号。模板注入rem响应式脚本,H5使用baseFontSize20、minRootSize16、maxRootSize40;修复以前缺少脚本导致宽屏/窄屏缩放不一致的问题。 + +维度按本机顺序展示,不默认按宜忌重排。排序页用上下按钮调整,完成后写入 `home.dimension-order.v1`;损坏或旧存储通过去重/过滤/补齐修复为八维。页面没有不可点击的伪详情箭头,也没有评分依据展开入口。 + +## 验收 + +1. 开发者工具打开首页;检查水滴头像、三个日期、真实农历、紫色指数卡、四块参考信息、八维图标与卡片、底部灵境头像。 +2. 依次切换昨日/今日/明日,日期与状态、整份报告同步变化;不同字段允许在某些日期相同,不人为强制每项变化。 +3. 修改生日或时辰保存后回首页,报告按新资料重新计算;昵称/头像不参与评分。 +4. 点击排序,用上下按钮调整后完成,退出重进仍保留八维顺序;首尾按钮不可越界。 +5. 列表无详情点击交互;页面不显示评分公式、干支依据、权重或测试工具,只保留简短娱乐参考标注。 +6. 切换窄屏、常规屏、长屏检查文本、卡片和安全区;从首页切我的,导航样式一致,H5不重复渲染原生tabBar。 + +自动验证:`npm run test:home`。验证真实日期/农历、三日切换、评分一致性、同输入稳定、生日时辰变化、吉时来源和边界、排序修复及图标资产。 diff --git a/docs/zodiac-rules.md b/docs/zodiac-rules.md new file mode 100644 index 0000000..038efff --- /dev/null +++ b/docs/zodiac-rules.md @@ -0,0 +1,51 @@ +# 我的生肖 + +按用户确认,以春节(农历正月初一)换生肖,使用公历生日经 `lunar-typescript` 转为农历,读取 `getYearShengXiao()`。不使用公历年份模12,也不使用以立春为界的八字年柱。 + +`src/utils/zodiac.ts` 只接收生日,返回十二生肖名称或空值。缺失或无效生日不默认展示兔子。支持1900至2100年,和当前元素报告的生日范围一致。 + +“我的生肖”入口与弹窗共用同一个 `zodiac` 结果及 `ZodiacMascot` 组件。十二张图标为本地256×256透明PNG,矢量源保存在 `scripts/render-zodiac-icons.cjs`。通过 `sharp` 可重新生成,运行时无需依赖 `sharp`。 + +页面返回时通过现有 `GET /api/user/profile` 获取最新生日,生日变化后重新计算生肖。保存生日沿用 `PUT /api/user/profile`,不新增生肖接口。图标、弹窗标题和“属X”名称随结果一起变化;隐藏生日开关不影响计算。 + +## 验收 + +1. 在开发者工具使用测试账号,进入“我的”,点击“我的生肖”,入口及弹窗应显示同一种动物,弹窗有对应“属X”名称。 +2. 修改测试账号生日为2024-02-09并保存,返回应显示兔;改为2024-02-10并保存,返回应显示龙。 +3. 2024-02-05在立春之后但春节之前,仍应为兔;避免把“我的元素”的节气年柱与日常生肖混用。 +4. 对缺失或非法生日,入口显示“待完善”,弹窗提示完善生日并提供编辑入口。 +5. 点击遮罩或“知道了”关闭弹窗,进入资料编辑与生肖按钮保持独立。 + +自动验证:`npm run test:zodiac`,覆盖春节前后、十二生肖周期、非法生日、资料改变和图标资产完整性。H5和微信小程序均需构建通过。 + +## 动态生肖周运:合冲简化规则 v1 + +用户已确认采用纯前端合冲关系计算,并要求公开权重与判断依据。输入为保存后的生日和北京时间当前日期。生日先按春节换年得到生肖,再对应子丑寅卯辰巳午未申酉戌亥的出生年支。按北京时间本周一至周日,用历法库计算七天日支;不是按公历日期模12伪造日支。 + +每天按以下互斥关系计一次分: + +| 关系 | 分数 | 对应关系 | +| --- | --- | --- | +| 六合 | +2 | 子丑、寅亥、卯戌、辰酉、巳申、午未 | +| 三合同组 | +1 | 申子辰、亥卯未、寅午戌、巳酉丑,同组且不同支 | +| 六冲 | −2 | 子午、丑未、寅申、卯酉、辰戌、巳亥 | +| 相害 | −1 | 子未、丑午、寅巳、卯辰、申亥、酉戌 | +| 平常 | 0 | 其余关系,包括相同地支 | + +六合与六冲表复用历法库 `LunarUtil.HE_ZHI_6` / `LunarUtil.CHONG`。三合同组只是出生年支与日支两支间的助力关系,不声称完整三合成局。不纳入刑、破或八字整体。权重与以下阈值是本产品的简化娱乐参考规则,不是统一命理标准。 + +七天分数求和:≥2为“顺势”,≤−2为“谨慎”,−1至1为“平稳”。正分日列为助力日,负分日列为留意日。摘要由本周实际天数、周状态及其对应建议组成;同一生肖同周稳定,不随机。不同生肖/周次可能同为“平稳”,不保证每次切换都换一个状态。 + +弹窗展示周一/周日的完整公历日期、状态、摘要、助力/留意日期,可展开权重、周总分与七天日支/关系/分数。明确标注“仅作传统文化娱乐参考”。普通建议限于沟通、协调、确认安排,不生成健康或投资预测。 + +页面显示、弹窗打开时检查北京时间日期;弹窗保持打开时在北京时间零点刷新,跨周后重新计算。没有后端报告接口,没有持久化周运结果。无效生日不给出周运;无效设备日期显示日期检查提示。 + +### 周运验收 + +1. “我的”→“我的生肖”,确认显示本周周一至周日区间、生肖、动态摘要和日期。 +2. 展开“判断依据”,逐日分数合计应等于周总分;总分对应状态应与上述阈值一致。 +3. 修改生日跨生肖,检查图标、名称、七天关系和摘要一起更新;同生肖的不同生日在同周允许结果相同。 +4. 按北京时间周日/周一检查周区间,跨年周应保留完整年份。不要用UTC日期替代北京时间日期。 +5. 小屏下滚动查看详细依据,“知道了”按钮始终可用;遮罩仍可关闭弹窗。 + +测试:`npm run test:zodiac-weekly`,覆盖所有地支关系及对称性、北京时间零点、同周稳定、跨年/跨周、十二生肖结果差异、分数复算、非法输入。 diff --git a/package.json b/package.json index 28bdb91..bafe9a8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "version": "0.0.1", "private": true, "scripts": { + "test:elements": "node --test scripts/fiveElements.test.cjs", + "test:zodiac": "node --test scripts/zodiac.test.cjs", + "test:zodiac-weekly": "node --test scripts/zodiacWeekly.test.cjs", + "test:home": "node --test scripts/homeOverview.test.cjs", "dev:weapp": "taro build --type weapp --watch", "build:weapp": "taro build --type weapp" }, @@ -16,6 +20,7 @@ "@tarojs/runtime": "3.6.35", "@tarojs/shared": "3.6.35", "@tarojs/taro": "3.6.35", + "china-area-data": "^5.0.1", "lunar-typescript": "^1.8.6", "react": "18.2.0", "react-dom": "18.2.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7576bd..6481282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@tarojs/taro': specifier: 3.6.35 version: 3.6.35(@tarojs/helper@3.6.35(supports-color@8.1.1))(@tarojs/runtime@3.6.35(@tarojs/shared@3.6.35))(@types/react@18.3.31)(postcss@8.5.26) + china-area-data: + specifier: ^5.0.1 + version: 5.0.1 lunar-typescript: specifier: ^1.8.6 version: 1.8.6 @@ -2083,6 +2086,9 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + china-area-data@5.0.1: + resolution: {integrity: sha512-BQDPpiv5Nn+018ekcJK2oSD9PAD+E1bvXB0wgabc//dFVS/KvRqCgg0QOEUt3vBkx9XzB5a9BmkJCEZDBxVjVw==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -8818,6 +8824,8 @@ snapshots: charenc@0.0.2: {} + china-area-data@5.0.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 diff --git a/scripts/fiveElements.test.cjs b/scripts/fiveElements.test.cjs new file mode 100644 index 0000000..cadec41 --- /dev/null +++ b/scripts/fiveElements.test.cjs @@ -0,0 +1,84 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const ts = require('typescript') +const filename = path.resolve(__dirname, '../src/utils/fiveElements.ts') +const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } +}).outputText +const loaded = new Module(filename, module) +loaded.filename = filename +loaded.paths = Module._nodeModulePaths(path.dirname(filename)) +loaded._compile(compiled, filename) +const { analyzePillars, calculateFiveElements } = loaded.exports + +test('同一日主在强弱不同的四柱下,扶抑方向应反转', () => { + const strong = analyzePillars(['甲寅', '乙卯', '甲寅', '乙卯']) + const weak = analyzePillars(['丙午', '丁巳', '甲申', '戊戌']) + assert.equal(strong.dayMaster, weak.dayMaster) + assert.equal(strong.strength, '偏强') + assert.equal(weak.strength, '偏弱') + assert.equal(strong.favorable, '火土金') + assert.equal(weak.favorable, '水木') + assert.equal(strong.analysis.season.supported, true) + assert.equal(weak.analysis.roots.supported, false) +}) + +test('地支藏干中的同类根必须计入,得势不包含日干自身', () => { + const report = analyzePillars(['丙午', '丁巳', '甲辰', '戊戌']) + assert.equal(report.analysis.roots.supported, true) // 辰藏乙,不能只看辰土。 + assert.ok(report.analysis.roots.score > 0) + const noSupport = analyzePillars(['丙午', '丁巳', '甲申', '戊戌']) + assert.ok(noSupport.analysis.support.score < 20) +}) + +test('有效日期和时辰改变后重新排盘,不受昵称或后端报告影响', () => { + const first = calculateFiveElements('1998-06-18', '辰时') + assert.deepEqual(first.pillars, ['戊寅', '戊午', '丙申', '壬辰']) + const changedDate = calculateFiveElements('1998-12-18', '辰时') + const changedHour = calculateFiveElements('1998-06-18', '戌时') + assert.notDeepEqual(first.scores, changedDate.scores) + assert.notDeepEqual(first.pillars, changedHour.pillars) + assert.notDeepEqual(first.scores, changedHour.scores) + assert.deepEqual(first, calculateFiveElements('1998-06-18', '7')) +}) + +test('百分比总和为100,强弱有界,喜忌互斥,平衡不强行指定喜忌', () => { + let balanced = false + for (let month = 1; month <= 12; month++) { + for (const hour of ['子时', '辰时', '午时', '酉时']) { + const report = calculateFiveElements(`2000-${String(month).padStart(2, '0')}-15`, hour) + assert.equal(report.scores.reduce((sum, score) => sum + score.value, 0), 100) + assert.ok(report.scores.every((score) => Number.isInteger(score.value) && score.value >= 0 && score.value <= 100)) + assert.ok(report.strengthScore >= 0 && report.strengthScore <= 100) + assert.ok(![...report.favorable].some((element) => report.unfavorable.includes(element))) + if (report.strength === '相对平衡') { + balanced = true + assert.equal(report.favorable, '') + assert.equal(report.unfavorable, '') + } + } + } + assert.equal(balanced, true) +}) + +test('不把无效生日、缺失时辰或越界时辰转换成虚构报告', () => { + for (const [date, hour] of [ + ['', '辰时'], ['2025-02-29', '辰时'], ['2024-02-30', '辰时'], + ['2024-13-01', '辰时'], ['2000-01-01', ''], ['2000-01-01', '24'], + ['2000-01-01', '-1'], ['2000-01-01', '辰时xxx'] + ]) assert.equal(calculateFiveElements(date, hour), null) + assert.ok(calculateFiveElements('2024-02-29', '子时')) + assert.throws(() => analyzePillars(['甲子', '无效', '甲子', '甲子'])) +}) + +test('节气前后切换月柱;子时换日采用历法库第二流派', () => { + const before = calculateFiveElements('2024-02-03', '午时') + const after = calculateFiveElements('2024-02-05', '午时') + assert.notEqual(before.pillars[1], after.pillars[1]) + const early = calculateFiveElements('2024-06-18', '0') + const late = calculateFiveElements('2024-06-18', '23') + assert.equal(early.dayMaster, late.dayMaster) +}) diff --git a/scripts/frontendPreview.test.cjs b/scripts/frontendPreview.test.cjs new file mode 100644 index 0000000..dc9e910 --- /dev/null +++ b/scripts/frontendPreview.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') + +function loadApi(env, search) { + let requests = 0 + let uploads = 0 + const taro = { default: { + getStorageSync: () => '', + request: async () => { requests++; return { data: { code: 0, data: { id: 9 } } } }, + uploadFile: async () => { uploads++; return { data: JSON.stringify({ code: 0, data: { id: 9 } }) } } + } } + const cache = {} + function load(name) { + if (cache[name]) return cache[name] + const source = fs.readFileSync(path.resolve(__dirname, `../src/services/${name}.ts`), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText + const exports = {} + cache[name] = exports + vm.runInNewContext(code, { + exports, process: { env: { TARO_ENV: env } }, window: { location: { search } }, URLSearchParams, + require: (id) => id === '@tarojs/taro' ? taro : load('frontendPreview') + }) + return exports + } + return { api: load('request').api, counts: () => ({ requests, uploads }) } +} + +test('显式H5预览使用示例资料,阻止后端读取、保存和上传', async () => { + const { api, counts } = loadApi('h5', '?preview=1') + const profile = await api.profile() + assert.equal(profile.id, 0) + assert.equal(profile.birthday, '1998-06-18') + await assert.rejects(api.updateProfile({ nickname: 'Changed' }), /纯前端预览/) + await assert.rejects(api.uploadAvatar('local.png'), /纯前端预览/) + await assert.rejects(api.agreement('privacy'), /纯前端预览/) + assert.deepEqual(counts(), { requests: 0, uploads: 0 }) + profile.nickname = 'Mutated' + assert.equal((await api.profile()).nickname, 'You') +}) + +test('普通H5和微信端不使用预览资料', async () => { + for (const [env, search] of [['h5', ''], ['h5', '?preview=0'], ['weapp', '?preview=1']]) { + const { api, counts } = loadApi(env, search) + assert.equal((await api.profile()).id, 9) + assert.deepEqual(counts(), { requests: 1, uploads: 0 }) + } +}) diff --git a/scripts/homeOverview.test.cjs b/scripts/homeOverview.test.cjs new file mode 100644 index 0000000..547aeef --- /dev/null +++ b/scripts/homeOverview.test.cjs @@ -0,0 +1,110 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const ts = require('typescript') +const { Solar } = require('lunar-typescript') +const cache = {} +function load(name) { + if (cache[name]) return cache[name].exports + const filename = path.resolve(__dirname, `../src/utils/${name}.ts`) + const loaded = new Module(filename, module) + cache[name] = loaded + loaded.filename = filename + loaded.paths = Module._nodeModulePaths(path.dirname(filename)) + const original = loaded.require.bind(loaded) + loaded.require = (id) => id.startsWith('./') ? load(id.slice(2)) : original(id) + loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } }).outputText, filename) + return loaded.exports +} +const { generateHomeOverview: generate, normalizeDimensionOrder, HOME_DIMENSION_IDS } = load('homeOverview') +const profile = { birthday: '1998-06-18', birthHour: '辰时' } + +test('昨日今日明日整体切换,公历星期农历均来自实际所选日期', () => { + const reports = [-1, 0, 1].map((offset) => generate(profile, '2026-10-15', offset)) + assert.deepEqual(reports.map((item) => item.activeDay), ['昨日', '今日', '明日']) + assert.deepEqual(reports.map((item) => item.date), ['2026-10-14', '2026-10-15', '2026-10-16']) + reports.forEach((report) => { + const [y, m, d] = report.date.split('-').map(Number) + const solar = Solar.fromYmd(y, m, d) + const lunar = solar.getLunar() + assert.equal(report.weekName, `星期${'日一二三四五六'[solar.getWeek()]}`) + assert.equal(report.lunarDate, `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`) + assert.equal(report.luckyDirection, lunar.getDayPositionXiDesc()) + assert.equal(report.days.find((day) => day.label === report.activeDay).status, report.status) + }) + assert.notDeepEqual(reports[0].dimensions, reports[1].dimensions) + assert.notDeepEqual(reports[1].dimensions, reports[2].dimensions) +}) + +test('八维评分有界,宜忌与评分一致,综合指数可复算', () => { + for (let day = 1; day <= 31; day++) { + const report = generate(profile, `2026-10-${String(day).padStart(2, '0')}`) + assert.equal(report.dimensions.length, 8) + assert.equal(new Set(report.dimensions.map((item) => item.id)).size, 8) + report.dimensions.forEach((item) => { + assert.ok(item.score >= 15 && item.score <= 90) + assert.equal(item.suitable, item.score >= 50) + assert.ok(item.headline && item.detail) + }) + assert.equal(report.overallIndex, Math.round(report.dimensions.reduce((sum, item) => sum + item.score, 0) / 8)) + assert.equal(report.status, report.overallIndex >= 80 ? '大吉' : report.overallIndex >= 65 ? '小吉' : report.overallIndex >= 50 ? '平日' : report.overallIndex >= 35 ? '小逆' : '谨慎') + assert.equal(report.luckyColors.length, 2) + assert.equal(new Set(report.luckyNumbers).size, 2) + } +}) + +test('吉时仅取所选当日的吉时片段,不越过日期边界且时支不重复', () => { + for (let day = 1; day <= 31; day++) { + const report = generate(profile, `2026-10-${String(day).padStart(2, '0')}`) + const times = Solar.fromYmd(2026, 10, day).getLunar().getTimes() + assert.ok(report.luckyHours.length > 0 && report.luckyHours.length <= 2) + assert.equal(new Set(report.luckyHours.map((hour) => hour.branch)).size, report.luckyHours.length) + report.luckyHours.forEach(({ branch, time }) => { + const [start, end] = time.split('–') + const match = times.find((hour) => hour.getZhi() === branch && hour.getMinHm() === start) + assert.ok(match) + assert.equal(match.getTianShenLuck(), '吉') + assert.ok(end <= '24:00') + assert.ok(start < end) + }) + } +}) + +test('日期、生日、时辰影响报告;相同输入稳定且不读取后端报告', () => { + const first = generate(profile, '2026-10-15') + assert.deepEqual(first, generate({ ...profile, elementReport: { invalid: true } }, '2026-10-15')) + assert.notDeepEqual(first, generate({ birthday: '1999-11-06', birthHour: '辰时' }, '2026-10-15')) + let changedHour = false + for (const hour of ['子时', '卯时', '午时', '戌时']) { + if (JSON.stringify(generate({ ...profile, birthHour: hour }, '2026-10-15')) !== JSON.stringify(first)) changedHour = true + } + assert.equal(changedHour, true) +}) + +test('跨月、跨年与非法日期安全处理,不回退为固定42分报告', () => { + assert.equal(generate(profile, '2026-01-01', -1).date, '2025-12-31') + assert.equal(generate(profile, '2026-12-31', 1).date, '2027-01-01') + assert.equal(generate(profile, '2024-03-01', -1).date, '2024-02-29') + for (const date of ['', '2026-02-30', '2026-13-01']) assert.equal(generate(profile, date), null) + assert.equal(generate({ birthday: '', birthHour: '' }, '2026-10-15'), null) + assert.equal(generate(profile, '2026-10-15', 2), null) +}) + +test('排序兼容缺失、重复、旧字段和损坏存储,保留八个维度', () => { + assert.deepEqual(normalizeDimensionOrder(null), HOME_DIMENSION_IDS) + assert.deepEqual(normalizeDimensionOrder('bad'), HOME_DIMENSION_IDS) + const order = normalizeDimensionOrder(['travel', 'travel', 'removed', 'wealth', null]) + assert.deepEqual(order.slice(0, 2), ['travel', 'wealth']) + assert.equal(order.length, 8) + assert.equal(new Set(order).size, 8) +}) + +test('首页八个维度、头像与天宫图标资产存在', () => { + for (const icon of ['heart', 'gourd', 'vase', 'coin', 'fan', 'scroll', 'bamboo', 'horse', 'water', 'spirit']) { + const data = fs.readFileSync(path.resolve(__dirname, `../src/assets/home/${icon}.png`)) + assert.equal(data.subarray(1, 4).toString(), 'PNG') + assert.equal(data.readUInt32BE(16), 256) + } +}) diff --git a/scripts/render-home-icons.cjs b/scripts/render-home-icons.cjs new file mode 100644 index 0000000..afa563e --- /dev/null +++ b/scripts/render-home-icons.cjs @@ -0,0 +1,27 @@ +// Original vector sources; generate portable PNG assets with sharp. +const sharp = require(process.argv[2] || 'sharp') +const fs = require('node:fs') +const path = require('node:path') +const output = path.resolve(__dirname, '../src/assets/home') +const art = { + heart: ``, + gourd: ``, + vase: ``, + coin: ``, + fan: ``, + scroll: ``, + bamboo: ``, + horse: ``, + water: ``, + spirit: `` +} +const gradients = `` +async function main() { + fs.mkdirSync(output, { recursive: true }) + for (const [name, content] of Object.entries(art)) { + const svg = `${gradients}${content}` + await sharp(Buffer.from(svg)).png().toFile(path.join(output, `${name}.png`)) + } + console.log(`Rendered ${Object.keys(art).length} home icons`) +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/render-zodiac-icons.cjs b/scripts/render-zodiac-icons.cjs new file mode 100644 index 0000000..4bb729b --- /dev/null +++ b/scripts/render-zodiac-icons.cjs @@ -0,0 +1,33 @@ +// Vector source for the twelve original zodiac mascots. PNG output works in both H5 and WeChat. +// Run with a locally installed sharp, or pass its module path as the first argument. +const path = require('node:path') +const fs = require('node:fs') +const sharp = require(process.argv[2] || 'sharp') +const output = path.resolve(__dirname, '../src/assets/zodiac') +const face = (fill, extra = '') => `${extra}` +const eyes = `` +const smile = `` +const art = { + rat: `${face('#e8e1f4')}${eyes}${smile}`, + ox: `${face('#efd6b0')}${eyes}`, + tiger: `${face('#ffd17d')}${eyes}${smile}`, + rabbit: `${face('#fff4d8')}${eyes}${smile}`, + dragon: `${face('#a1e2c3')}${eyes}`, + snake: ``, + horse: `${face('#dfb18b')}${eyes}`, + goat: `${face('#f7e6d0')}${eyes}${smile}`, + monkey: `${face('#bd9373')}${eyes}${smile}`, + rooster: `${face('#fff1ce')}${eyes}`, + dog: `${face('#eed1a9')}${eyes}${smile}`, + pig: `${face('#ffd3dc')}${eyes}` +} + +async function main() { + fs.mkdirSync(output, { recursive: true }) + for (const [name, content] of Object.entries(art)) { + const svg = `${content}` + await sharp(Buffer.from(svg)).png().toFile(path.join(output, `${name}.png`)) + } + console.log(`Rendered ${Object.keys(art).length} zodiac icons`) +} +main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/scripts/zodiac.test.cjs b/scripts/zodiac.test.cjs new file mode 100644 index 0000000..3ae21e0 --- /dev/null +++ b/scripts/zodiac.test.cjs @@ -0,0 +1,64 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const crypto = require('node:crypto') +const ts = require('typescript') +const filename = path.resolve(__dirname, '../src/utils/zodiac.ts') +const loaded = new Module(filename, module) +loaded.filename = filename +loaded.paths = Module._nodeModulePaths(path.dirname(filename)) +loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } +}).outputText, filename) +const { calculateZodiac, zodiacNames } = loaded.exports + +test('春节前一天与正月初一换生肖,不按1月1日或立春换年', () => { + for (const [before, after, previous, next] of [ + ['2023-01-21', '2023-01-22', '虎', '兔'], + ['2024-02-09', '2024-02-10', '兔', '龙'], + ['2025-01-28', '2025-01-29', '龙', '蛇'], + ['2026-02-16', '2026-02-17', '蛇', '马'] + ]) { + assert.equal(calculateZodiac(before), previous) + assert.equal(calculateZodiac(after), next) + } + assert.equal(calculateZodiac('2024-01-01'), '兔') + assert.equal(calculateZodiac('2024-02-05'), '兔') // 立春后,春节前仍属兔。 +}) + +test('十二生肖全部覆盖,年份跨越周期仍正确', () => { + zodiacNames.forEach((name, index) => { + assert.equal(calculateZodiac(`${2008 + index}-06-15`), name) + assert.equal(calculateZodiac(`${2020 + index}-06-15`), name) + }) +}) + +test('无效或缺失生日不默认显示某种生肖,闰年有效日期可计算', () => { + for (const birthday of ['', '2025-02-29', '2024-02-30', '2024-00-10', '2024-13-01', '2024-01-00', '2024-1-1', '1899-12-31', '2101-01-01']) { + assert.equal(calculateZodiac(birthday), null) + } + assert.equal(calculateZodiac('2024-02-29'), '龙') +}) + +test('修改生日跨春节后返回不同生肖,重复计算稳定', () => { + const first = calculateZodiac('1999-02-15') + const changed = calculateZodiac('1999-02-16') + assert.equal(first, '虎') + assert.equal(changed, '兔') + assert.equal(calculateZodiac('1999-02-15'), first) +}) + +test('十二个图标均为独立的256像素透明PNG,非重复占位图', () => { + const files = ['rat', 'ox', 'tiger', 'rabbit', 'dragon', 'snake', 'horse', 'goat', 'monkey', 'rooster', 'dog', 'pig'] + const hashes = files.map((name) => { + const data = fs.readFileSync(path.resolve(__dirname, `../src/assets/zodiac/${name}.png`)) + assert.equal(data.subarray(1, 4).toString(), 'PNG') + assert.equal(data.readUInt32BE(16), 256) + assert.equal(data.readUInt32BE(20), 256) + assert.equal(data[25], 6) // RGBA,保留透明背景。 + return crypto.createHash('sha256').update(data).digest('hex') + }) + assert.equal(new Set(hashes).size, 12) +}) diff --git a/scripts/zodiacWeekly.test.cjs b/scripts/zodiacWeekly.test.cjs new file mode 100644 index 0000000..fd1b7bd --- /dev/null +++ b/scripts/zodiacWeekly.test.cjs @@ -0,0 +1,98 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const ts = require('typescript') +const { Solar } = require('lunar-typescript') +const cache = {} +function load(name) { + if (cache[name]) return cache[name].exports + const filename = path.resolve(__dirname, `../src/utils/${name}.ts`) + const loaded = new Module(filename, module) + cache[name] = loaded + loaded.filename = filename + loaded.paths = Module._nodeModulePaths(path.dirname(filename)) + const originalRequire = loaded.require.bind(loaded) + loaded.require = (id) => id === './zodiac' ? load('zodiac') : originalRequire(id) + loaded._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2017 } + }).outputText, filename) + return loaded.exports +} +const { getBeijingDate, getZodiacRelation, calculateWeeklyZodiacFortune: weekly } = load('zodiacWeekly') + +test('合冲表对称,12生肖均覆盖1六合、2三合、1六冲、1相害', () => { + const branches = [...'子丑寅卯辰巳午未申酉戌亥'] + for (const first of branches) { + const counts = {} + for (const second of branches) { + const relation = getZodiacRelation(first, second) + assert.equal(relation, getZodiacRelation(second, first)) + counts[relation] = (counts[relation] || 0) + 1 + } + assert.deepEqual(counts, { 平常: 7, 六合: 1, 三合: 2, 六冲: 1, 相害: 1 }) + assert.equal(getZodiacRelation(first, first), '平常') + } + assert.equal(getZodiacRelation('子', '丑'), '六合') + assert.equal(getZodiacRelation('寅', '午'), '三合') + assert.equal(getZodiacRelation('子', '午'), '六冲') + assert.equal(getZodiacRelation('子', '未'), '相害') + assert.throws(() => getZodiacRelation('鼠', '子')) +}) + +test('北京时间零点边界、周一到周日及跨年周正确', () => { + assert.equal(getBeijingDate(new Date('2026-10-18T15:59:59Z')), '2026-10-18') + assert.equal(getBeijingDate(new Date('2026-10-18T16:00:00Z')), '2026-10-19') + assert.equal(getBeijingDate(new Date('invalid')), '') + const monday = weekly('1998-06-18', '2026-10-12') + const sunday = weekly('1998-06-18', '2026-10-18') + assert.deepEqual(monday, sunday) + assert.equal(monday.weekStart, '2026-10-12') + assert.equal(monday.weekEnd, '2026-10-18') + assert.equal(weekly('1998-06-18', '2026-10-19').weekStart, '2026-10-19') + const newYear = weekly('1998-06-18', '2027-01-01') + assert.equal(newYear.weekStart, '2026-12-28') + assert.equal(newYear.weekEnd, '2027-01-03') +}) + +test('逐日分值与周状态可复算,日期不遗漏、不重复,状态有差异', () => { + const statuses = new Set() + const date = '2026-10-15' + for (let year = 2008; year < 2020; year++) { + const report = weekly(`${year}-06-15`, date) + assert.equal(report.days.length, 7) + assert.equal(new Set(report.days.map((day) => day.date)).size, 7) + const weights = { 六合: 2, 三合: 1, 六冲: -2, 相害: -1, 平常: 0 } + report.days.forEach((day) => { + const [y, m, d] = day.date.split('-').map(Number) + assert.equal(day.branch, Solar.fromYmd(y, m, d).getLunar().getDayZhi()) + assert.equal(day.relation, getZodiacRelation(report.branch, day.branch)) + assert.equal(day.score, weights[day.relation]) + }) + assert.equal(report.score, report.days.reduce((sum, day) => sum + day.score, 0)) + assert.equal(report.status, report.score >= 2 ? '顺势' : report.score <= -2 ? '谨慎' : '平稳') + assert.ok(report.supportiveDays.every((day) => day.score > 0)) + assert.ok(report.cautiousDays.every((day) => day.score < 0)) + statuses.add(report.status) + } + assert.deepEqual([...statuses].sort(), ['平稳', '谨慎', '顺势'].sort()) +}) + +test('生日、周次驱动结果,同生肖同周稳定,跨周明细改变', () => { + const first = weekly('1998-06-18', '2026-10-15') + assert.deepEqual(first, weekly('1998-06-18', '2026-10-15')) + assert.deepEqual(first, weekly('2010-06-18', '2026-10-15')) + assert.notDeepEqual(first.days, weekly('1999-06-18', '2026-10-15').days) + assert.notDeepEqual(first.days, weekly('1998-06-18', '2026-10-22').days) + assert.equal(weekly('2024-02-09', '2026-10-15').zodiac, '兔') + assert.equal(weekly('2024-02-10', '2026-10-15').zodiac, '龙') +}) + +test('非法生日与非法参考日期不给出虚构周运', () => { + assert.equal(weekly('', '2026-10-15'), null) + assert.equal(weekly('2025-02-29', '2026-10-15'), null) + for (const date of ['', 'invalid', '2026-02-30', '2026-13-01', '2026-00-01', '2026-10-00']) { + assert.equal(weekly('1998-06-18', date), null) + } +}) diff --git a/src/app.config.ts b/src/app.config.ts index 0cac7d6..77d7672 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -15,7 +15,7 @@ export default defineAppConfig({ navigationStyle: 'custom', backgroundColor: '#fffdf7' }, - tabBar: { + tabBar: process.env.TARO_ENV === 'h5' ? undefined : { custom: true, color: '#cfcfcf', selectedColor: '#9a6d0f', diff --git a/src/assets/home/bamboo.png b/src/assets/home/bamboo.png new file mode 100644 index 0000000..5a75ebc Binary files /dev/null and b/src/assets/home/bamboo.png differ diff --git a/src/assets/home/coin.png b/src/assets/home/coin.png new file mode 100644 index 0000000..5b64562 Binary files /dev/null and b/src/assets/home/coin.png differ diff --git a/src/assets/home/fan.png b/src/assets/home/fan.png new file mode 100644 index 0000000..d00602b Binary files /dev/null and b/src/assets/home/fan.png differ diff --git a/src/assets/home/gourd.png b/src/assets/home/gourd.png new file mode 100644 index 0000000..140064a Binary files /dev/null and b/src/assets/home/gourd.png differ diff --git a/src/assets/home/heart.png b/src/assets/home/heart.png new file mode 100644 index 0000000..4b69d5a Binary files /dev/null and b/src/assets/home/heart.png differ diff --git a/src/assets/home/horse.png b/src/assets/home/horse.png new file mode 100644 index 0000000..1e96769 Binary files /dev/null and b/src/assets/home/horse.png differ diff --git a/src/assets/home/scroll.png b/src/assets/home/scroll.png new file mode 100644 index 0000000..666021f Binary files /dev/null and b/src/assets/home/scroll.png differ diff --git a/src/assets/home/spirit.png b/src/assets/home/spirit.png new file mode 100644 index 0000000..d6944d9 Binary files /dev/null and b/src/assets/home/spirit.png differ diff --git a/src/assets/home/vase.png b/src/assets/home/vase.png new file mode 100644 index 0000000..07ffbcb Binary files /dev/null and b/src/assets/home/vase.png differ diff --git a/src/assets/home/water.png b/src/assets/home/water.png new file mode 100644 index 0000000..30d4a14 Binary files /dev/null and b/src/assets/home/water.png differ diff --git a/src/assets/zodiac/dog.png b/src/assets/zodiac/dog.png new file mode 100644 index 0000000..7768b7a Binary files /dev/null and b/src/assets/zodiac/dog.png differ diff --git a/src/assets/zodiac/dragon.png b/src/assets/zodiac/dragon.png new file mode 100644 index 0000000..a98a5d2 Binary files /dev/null and b/src/assets/zodiac/dragon.png differ diff --git a/src/assets/zodiac/goat.png b/src/assets/zodiac/goat.png new file mode 100644 index 0000000..af5c362 Binary files /dev/null and b/src/assets/zodiac/goat.png differ diff --git a/src/assets/zodiac/horse.png b/src/assets/zodiac/horse.png new file mode 100644 index 0000000..18a486a Binary files /dev/null and b/src/assets/zodiac/horse.png differ diff --git a/src/assets/zodiac/monkey.png b/src/assets/zodiac/monkey.png new file mode 100644 index 0000000..d4e98f0 Binary files /dev/null and b/src/assets/zodiac/monkey.png differ diff --git a/src/assets/zodiac/ox.png b/src/assets/zodiac/ox.png new file mode 100644 index 0000000..8c0d907 Binary files /dev/null and b/src/assets/zodiac/ox.png differ diff --git a/src/assets/zodiac/pig.png b/src/assets/zodiac/pig.png new file mode 100644 index 0000000..711e81d Binary files /dev/null and b/src/assets/zodiac/pig.png differ diff --git a/src/assets/zodiac/rabbit.png b/src/assets/zodiac/rabbit.png new file mode 100644 index 0000000..6045b8f Binary files /dev/null and b/src/assets/zodiac/rabbit.png differ diff --git a/src/assets/zodiac/rat.png b/src/assets/zodiac/rat.png new file mode 100644 index 0000000..af26694 Binary files /dev/null and b/src/assets/zodiac/rat.png differ diff --git a/src/assets/zodiac/rooster.png b/src/assets/zodiac/rooster.png new file mode 100644 index 0000000..172b57c Binary files /dev/null and b/src/assets/zodiac/rooster.png differ diff --git a/src/assets/zodiac/snake.png b/src/assets/zodiac/snake.png new file mode 100644 index 0000000..4c9ac89 Binary files /dev/null and b/src/assets/zodiac/snake.png differ diff --git a/src/assets/zodiac/tiger.png b/src/assets/zodiac/tiger.png new file mode 100644 index 0000000..004f652 Binary files /dev/null and b/src/assets/zodiac/tiger.png differ diff --git a/src/components/AppTabBar.scss b/src/components/AppTabBar.scss new file mode 100644 index 0000000..8d34d0d --- /dev/null +++ b/src/components/AppTabBar.scss @@ -0,0 +1,40 @@ +.app-tabbar { + position: fixed; + z-index: 12; + left: 50%; + bottom: 0; + width: 100%; + max-width: 750px; + height: calc(158px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + border: 2px solid rgba(255,255,255,.8); + border-bottom: 0; + border-radius: 30px 30px 0 0; + box-sizing: border-box; + background: rgba(255,255,255,.97); + box-shadow: 0 -10px 32px rgba(96,77,125,.08); + display: flex; + align-items: center; + justify-content: space-around; + transform: translateX(-50%); +} +.app-tabbar .app-tabbar-item, .app-tabbar .app-tabbar-realm { + width: 150px; height: 132px; margin: 0; padding: 0; flex: none; + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 12px; color: #30283e; background: transparent; border: 0; + font-size: 27px; font-weight: 700; line-height: 1.2; + overflow: visible; +} +.app-tabbar-label { display: block; flex: none; line-height: 34px; white-space: nowrap; } +.app-tabbar .app-tabbar-item.active { color: #fb9b2d; } +.app-home-symbol { width: 48px; height: 48px; position: relative; flex: none; } +.app-home-roof { position: absolute; left: 8px; top: 2px; width: 30px; height: 30px; border: 7px solid currentColor; border-right: 0; border-bottom: 0; border-radius: 5px; box-sizing: border-box; transform: rotate(45deg); } +.app-home-body { position: absolute; left: 8px; bottom: 1px; width: 32px; height: 27px; border-radius: 3px 3px 7px 7px; background: currentColor; } +.app-home-body > view { position: absolute; left: 12px; bottom: 0; width: 9px; height: 14px; border-radius: 5px 5px 0 0; background: #fff; } +.app-person-symbol { width: 52px; height: 48px; position: relative; flex: none; } +.app-person-head { position: absolute; left: 50%; top: 1px; width: 19px; height: 19px; border-radius: 50%; background: currentColor; transform: translateX(-50%); } +.app-person-body { position: absolute; left: 4px; bottom: 0; width: 44px; height: 24px; border-radius: 23px 23px 7px 7px; background: currentColor; } +// 共用48px图标占位,放大图标只向上延伸,不挤压或裁切下方文字。 +.app-realm-symbol { position: relative; width: 116px; height: 48px; flex: none; } +.app-spirit-frame { position: absolute; left: 0; bottom: 0; width: 116px; height: 116px; padding: 9px; border: 2px solid #fff; box-sizing: border-box; border-radius: 50%; background: linear-gradient(145deg,#fff,#edf3ff); box-shadow: 0 7px 20px rgba(137,110,160,.12); } +.app-spirit-image { display: block; width: 100%; height: 100%; border-radius: 50%; } diff --git a/src/components/AppTabBar.tsx b/src/components/AppTabBar.tsx new file mode 100644 index 0000000..02126df --- /dev/null +++ b/src/components/AppTabBar.tsx @@ -0,0 +1,27 @@ +import Taro from '@tarojs/taro' +import { Button, Image, Text, View } from '@tarojs/components' +import spirit from '../assets/home/spirit.png' +import './AppTabBar.scss' + +export function AppTabBar({ active }: { active: 'home' | 'my' }) { + const navigate = (page: 'home' | 'my') => { + if (page === active) return + const url = `/pages/${page}/index` + if (process.env.TARO_ENV === 'weapp') Taro.switchTab({ url }) + else Taro.redirectTo({ url }) + } + return + + + + +} diff --git a/src/components/RadarChart.scss b/src/components/RadarChart.scss index 70111c2..39cc802 100644 --- a/src/components/RadarChart.scss +++ b/src/components/RadarChart.scss @@ -1,81 +1,31 @@ -.radar { - position: relative; - width: 520px; - height: 470px; - margin: 22px auto 42px; -} - -.radar-web, -.radar-fill { +.radar { position: relative; width: 100%; height: 490px; margin: 0 auto; } +.radar-plot { position: absolute; width: 66%; height: 360px; left: 17%; top: 75px; } +.radar-ring, .radar-ring-inner, .radar-fill { position: absolute; - left: 110px; - top: 58px; - width: 300px; - height: 300px; - clip-path: polygon(50% 0, 98% 36%, 80% 98%, 20% 98%, 2% 36%); -} - -.radar-web { - border: 2px solid rgba(255, 219, 117, 0.5); - background: rgba(255, 237, 184, 0.18); -} - -.web-1 { - transform: scale(1); -} - -.web-2 { - transform: scale(0.68); -} - -.web-3 { - transform: scale(0.36); -} - -.radar-fill { - background: #13cce9; - opacity: 0.95; -} - -.radar-label { - position: absolute; - display: flex; - flex-direction: column; - align-items: center; - font-weight: 700; -} - -.radar-name { - font-size: 34px; -} - -.radar-value { - margin-top: 8px; - font-size: 22px; -} - -.radar-label-0 { - left: 50%; - top: 0; - transform: translateX(-50%); -} - -.radar-label-1 { - right: 0; - top: 150px; -} - -.radar-label-2 { - right: 50px; - bottom: 12px; -} - -.radar-label-3 { - left: 48px; - bottom: 12px; -} - -.radar-label-4 { - left: 0; - top: 150px; + inset: 0; + clip-path: polygon(50% 4%, 93.75% 35.79%, 77.04% 87.21%, 22.96% 87.21%, 6.25% 35.79%); } +.radar-ring { background: #decac6; } +.radar-ring-inner { inset: 2px; background: #fff2e9; } +.radar-ring:nth-child(even) .radar-ring-inner { background: #fff9f1; } +.radar-axis { position: absolute; left: 50%; top: 50%; width: 46%; border-top: 2px dashed rgba(171,151,151,.35); transform-origin: left center; } +.radar-fill { background: rgba(255,143,201,.72); } +.radar-dot { position: absolute; width: 9px; height: 9px; border: 2px solid rgba(255,255,255,.5); border-radius: 50%; transform: translate(-50%,-50%); } +.radar-label { position: absolute; display: flex; flex-direction: column; align-items: center; gap: 6px; font-weight: 700; } +.radar-name-row { display: flex; align-items: center; gap: 6px; } +.radar-name { font-size: 32px; line-height: 40px; } +.radar-value { min-width: 64px; height: 28px; padding: 0 8px; box-sizing: border-box; border-radius: 20px; color: #fff; font-size: 21px; font-weight: 500; line-height: 28px; text-align: center; } +.radar-label-0 { left: 50%; top: 0; transform: translateX(-50%); } +.radar-label-1 { right: 2%; top: 177px; } +.radar-label-2 { right: 20%; bottom: 0; } +.radar-label-3 { left: 20%; bottom: 0; } +.radar-label-4 { left: 2%; top: 177px; } +.element-mark { position: relative; width: 28px; height: 30px; border-radius: 11px 13px 10px 12px; background: currentColor; box-shadow: inset 5px 5px 8px rgba(255,255,255,.35); } +.mark-1 { border-radius: 8px 8px 16px 16px; height: 23px; } +.mark-2 { border-radius: 70% 35% 65% 40%; transform: rotate(25deg); } +.mark-3 { border-radius: 90% 12% 75% 30%; } +.mark-4 { border-radius: 70% 15% 60% 45%; transform: rotate(-12deg); } +.mark-eye { position: absolute; top: 12px; width: 3px; height: 4px; border-radius: 50%; background: rgba(49,44,65,.65); } +.mark-eye.left { left: 8px; } +.mark-eye.right { right: 8px; } +.mark-smile { position: absolute; left: 11px; top: 17px; width: 6px; height: 3px; border-bottom: 2px solid rgba(49,44,65,.55); border-radius: 50%; } diff --git a/src/components/RadarChart.tsx b/src/components/RadarChart.tsx index 84ae494..ed3c760 100644 --- a/src/components/RadarChart.tsx +++ b/src/components/RadarChart.tsx @@ -2,45 +2,52 @@ import { View, Text } from '@tarojs/components' import { ElementScore } from '../services/request' import './RadarChart.scss' -type Props = { - scores: ElementScore[] -} - -const positions = [ - { left: 50, top: 3 }, - { left: 86, top: 35 }, - { left: 72, top: 84 }, - { left: 28, top: 84 }, - { left: 14, top: 35 } -] - -const elementMarks: Record = { - 土: '●', 金: '✦', 水: '●', 木: '●', 火: '●' -} +type Props = { scores: ElementScore[] } +const order = ['土', '金', '水', '木', '火'] +const colors = ['#b87338', '#dca823', '#42b7e4', '#55b56a', '#ef6777'] +const points = order.map((_, index) => { + const angle = (-90 + index * 72) * Math.PI / 180 + return { x: 50 + Math.cos(angle) * 46, y: 50 + Math.sin(angle) * 46 } +}) export function RadarChart({ scores }: Props) { - const polygon = scores.map((score, index) => { - const point = positions[index] - const scale = Math.max(score.value, 4) / 100 - const x = 50 + (point.left - 50) * scale - const y = 50 + (point.top - 50) * scale - return `${x}% ${y}%` - }).join(', ') + const values = order.map((name, index) => ({ + name, + value: Math.max(0, Math.min(100, scores.find((score) => score.name === name)?.value || 0)), + color: colors[index] + })) + const maximum = Math.max(50, ...values.map((score) => score.value)) + const dataPoints = values.map((score, index) => ({ + x: 50 + (points[index].x - 50) * score.value / maximum, + y: 50 + (points[index].y - 50) * score.value / maximum + })) + const polygon = dataPoints.map(({ x, y }) => `${x}% ${y}%`).join(', ') return ( - - - - - {scores.map((score, index) => ( - - {elementMarks[score.name]} {score.name} - {score.value}% + + {[1, 0.8, 0.6, 0.4, 0.2].map((scale) => ( + + + + ))} + {points.map((_, index) => ( + + ))} + + {dataPoints.map((point, index) => ( + + ))} + + {values.map((score, index) => ( + + + + + + {score.name} + + {score.value}% ))} diff --git a/src/components/ZodiacMascot.scss b/src/components/ZodiacMascot.scss new file mode 100644 index 0000000..1124486 --- /dev/null +++ b/src/components/ZodiacMascot.scss @@ -0,0 +1,3 @@ +.zodiac-mascot { display: block; width: 86px; height: 86px; flex: none; } +.zodiac-mascot-empty { color: #8d7d9c; font-size: 22px; line-height: 1.4; } +.zodiac-large .zodiac-mascot { width: 148px; height: 148px; } diff --git a/src/components/ZodiacMascot.tsx b/src/components/ZodiacMascot.tsx new file mode 100644 index 0000000..52bdac8 --- /dev/null +++ b/src/components/ZodiacMascot.tsx @@ -0,0 +1,26 @@ +import { Image, Text } from '@tarojs/components' +import type { ZodiacName } from '../utils/zodiac' +import rat from '../assets/zodiac/rat.png' +import ox from '../assets/zodiac/ox.png' +import tiger from '../assets/zodiac/tiger.png' +import rabbit from '../assets/zodiac/rabbit.png' +import dragon from '../assets/zodiac/dragon.png' +import snake from '../assets/zodiac/snake.png' +import horse from '../assets/zodiac/horse.png' +import goat from '../assets/zodiac/goat.png' +import monkey from '../assets/zodiac/monkey.png' +import rooster from '../assets/zodiac/rooster.png' +import dog from '../assets/zodiac/dog.png' +import pig from '../assets/zodiac/pig.png' +import './ZodiacMascot.scss' + +const icons: Record = { + 鼠: rat, 牛: ox, 虎: tiger, 兔: rabbit, 龙: dragon, 蛇: snake, + 马: horse, 羊: goat, 猴: monkey, 鸡: rooster, 狗: dog, 猪: pig +} + +export function ZodiacMascot({ zodiac }: { zodiac: ZodiacName | null }) { + return zodiac + ? + : 待完善 +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..076c420 --- /dev/null +++ b/src/index.html @@ -0,0 +1,13 @@ + + + + + + + Hera 前端预览 + + + +
+ + diff --git a/src/pages/agreement/index.scss b/src/pages/agreement/index.scss index b35a822..4f582bb 100644 --- a/src/pages/agreement/index.scss +++ b/src/pages/agreement/index.scss @@ -1,51 +1,154 @@ .agreement-page { - color: #222; - padding-left: 30px; - padding-right: 30px; + width: 100%; + max-width: 750px; + min-height: 100vh; + margin: 0 auto; + padding: calc(132px + env(safe-area-inset-top)) 32px calc(36px + env(safe-area-inset-bottom)); + box-sizing: border-box; + color: #28243a; + background: + radial-gradient(circle at 16% 5%, rgba(180, 155, 255, 0.62), transparent 31%), + linear-gradient(180deg, #c8b7ff 0%, #e8e2ff 34%, #f0f5ff 70%, #edf5ff 100%); + overflow: hidden; } -.tabs { - margin-top: 210px; - display: flex; - justify-content: space-around; +.agreement-page .nav { + left: 50%; + right: auto; + width: 100%; + max-width: 750px; + height: calc(106px + env(safe-area-inset-top)); + padding-bottom: 20px; + transform: translateX(-50%); } -.tab { +.agreement-page .nav-title { + color: #27213f; + font-size: 38px; + font-weight: 800; +} + +.agreement-page .back { + color: #30294b; +} + +.agreement-tabs { position: relative; - color: #8f640d; - font-size: 34px; - font-weight: 700; - padding-bottom: 18px; + z-index: 1; + width: 100%; + height: 84px; + padding: 6px; + border: 2px solid rgba(255, 255, 255, 0.68); + border-radius: 46px; + background: rgba(255, 255, 255, 0.48); + box-shadow: 0 10px 28px rgba(95, 76, 139, 0.08); + display: flex; + box-sizing: border-box; + overflow: hidden; } -.current::after { - content: ''; +.tab-indicator { position: absolute; - left: 32px; - right: 32px; - bottom: 0; - height: 8px; - border-radius: 4px; - background: #8f640d; + left: 6px; + top: 6px; + width: calc(50% - 6px); + height: 68px; + border-radius: 38px; + background: linear-gradient(135deg, #7862dc 0%, #9279ed 100%); + box-shadow: 0 8px 18px rgba(102, 78, 193, 0.24); + transition: transform 220ms cubic-bezier(0.22, 0.72, 0.22, 1); } -.article { - height: calc(100vh - 350px); - margin-top: 42px; +.agreement-tabs.privacy-active .tab-indicator { + transform: translateX(100%); +} + +.agreement-tab { + position: relative; + z-index: 2; + width: 50%; + height: 68px; + flex: none; + color: #59516f; + font-size: 30px; + font-weight: 700; + line-height: 68px; + text-align: center; + transition: color 180ms ease; +} + +.agreement-tab + .agreement-tab { + margin-top: 0; +} + +.agreement-tab.current { + color: #fff; +} + +.agreement-article { + height: calc(100vh - 252px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + margin-top: 28px; + border: 2px solid rgba(255, 255, 255, 0.72); + border-radius: 28px; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 16px 38px rgba(85, 72, 119, 0.08); + box-sizing: border-box; + overflow: hidden; +} + +.article-inner { + min-height: 100%; + padding: 38px 34px 56px; + box-sizing: border-box; + animation: article-enter 230ms cubic-bezier(0.22, 0.72, 0.22, 1); } .article-title { display: block; - color: #252525; + margin-bottom: 30px; + color: #28213f; font-size: 40px; font-weight: 800; - margin-bottom: 26px; + line-height: 1.35; + text-align: center; } .paragraph { display: block; - color: #2f2f2f; - font-size: 30px; - line-height: 1.95; - margin-bottom: 28px; + margin-bottom: 26px; + color: #4c465c; + font-size: 28px; + line-height: 1.9; + text-align: justify; +} + +.paragraph:last-child { + margin-bottom: 0; +} + +@keyframes article-enter { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media screen and (max-height: 700px) { + .agreement-page { + padding-top: calc(112px + env(safe-area-inset-top)); + } + + .agreement-article { + height: calc(100vh - 222px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + margin-top: 22px; + } + + .article-inner { + padding-top: 30px; + } } diff --git a/src/pages/agreement/index.tsx b/src/pages/agreement/index.tsx index 56e593b..9fcb362 100644 --- a/src/pages/agreement/index.tsx +++ b/src/pages/agreement/index.tsx @@ -4,30 +4,46 @@ import { NavBar } from '../../components/NavBar' import { api } from '../../services/request' import './index.scss' +type AgreementType = 'user' | 'privacy' + const sampleUser = '为使用小程序软件服务(以下简称“本服务”),您应当阅读并遵守《小程序软件注册协议》以及专项规则等。请您务必审慎阅读、充分理解各条款内容,特别是免除或限制责任的相应条款。' const samplePrivacy = '欢迎使用那吉。本隐私政策旨在向您说明我们收集、使用、存储、披露个人信息的方式、范围及您享有的相关权利,确保您在充分知情的前提下自愿使用本应用。' export default function AgreementPage() { - const [active, setActive] = useState<'user' | 'privacy'>('user') - const [body, setBody] = useState(sampleUser) + const [active, setActive] = useState('user') + const [content, setContent] = useState>({ + user: sampleUser, + privacy: samplePrivacy + }) useEffect(() => { - api.agreement(active).then((res) => setBody(res.body)).catch(() => setBody(active === 'user' ? sampleUser : samplePrivacy)) + let cancelled = false + api.agreement(active) + .then((res) => { + if (!cancelled) setContent((current) => ({ ...current, [active]: res.body })) + }) + .catch(() => undefined) + return () => { + cancelled = true + } }, [active]) return ( - + - - - + + + + - - {active === 'user' ? '用户协议' : '一、引言'} - {body} - 一、协议的范围{'\n'}1.1 本协议是您与那吉之间关于使用那吉软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。 - 二、适用范围{'\n'}本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息,涵盖注册、登录、使用功能、参与互动等场景。 - 三、个人信息的收集与使用{'\n'}我们遵循合法、正当、必要原则,仅收集为实现服务功能所必需的信息。 + + + {active === 'user' ? '用户协议' : '隐私政策'} + {content[active]} + 一、协议的范围{'\n'}1.1 本协议是您与那吉之间关于使用那吉软件服务所订立的协议。所有规则均为本协议不可分割的一部分,与协议正文具有同等法律效力。 + 二、适用范围{'\n'}本政策适用于您通过本应用进行的所有操作及我们因此收集的个人信息,涵盖注册、登录、使用功能、参与互动等场景。 + 三、个人信息的收集与使用{'\n'}我们遵循合法、正当、必要原则,仅收集为实现服务功能所必需的信息。 + ) diff --git a/src/pages/home/index.scss b/src/pages/home/index.scss index 6b8289f..3b9ad1f 100644 --- a/src/pages/home/index.scss +++ b/src/pages/home/index.scss @@ -1,602 +1,99 @@ .home-page { - min-height: 100vh; - padding: calc(216px + env(safe-area-inset-top)) 30px calc(178px + env(safe-area-inset-bottom)); - box-sizing: border-box; - color: #29243a; - background: - linear-gradient(180deg, rgba(154, 124, 255, 0.92) 0%, rgba(220, 201, 255, 0.76) 27%, rgba(253, 241, 255, 0.9) 52%, #eef6ff 100%); - overflow-x: hidden; -} - -.day-switch { - display: flex; - align-items: center; - gap: 52px; -} - -.avatar-orb { - width: 132px; - height: 132px; - flex: none; - border-radius: 70px; - background: #fffef0; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 12px 26px rgba(119, 102, 154, 0.12); -} - -.water-face { - width: 82px; - height: 96px; - border-radius: 50% 50% 52% 52%; - background: linear-gradient(180deg, #9ff5ff 0%, #39c6ea 100%); - color: #3274c8; - font-size: 42px; - line-height: 78px; - text-align: center; -} - -.day-tabs { - flex: 1; - display: grid; - grid-template-columns: repeat(3, 1fr); - align-items: center; -} - -.day-tab { - min-width: 0; - text-align: center; - color: rgba(42, 34, 62, 0.58); - position: relative; -} - -.day-tab + .day-tab::before { - content: ''; - position: absolute; - left: -2px; - top: 20px; - width: 2px; - height: 12px; - background: rgba(42, 34, 62, 0.4); -} - -.day-label, -.day-status { - display: block; -} - -.day-label { - font-size: 36px; - line-height: 1.2; -} - -.day-status { - width: 112px; - height: 48px; - margin: 12px auto 0; - border-radius: 26px; - font-size: 28px; - line-height: 48px; -} - -.day-tab.active { - color: #29243a; - font-weight: 900; -} - -.day-tab.active .day-label { - font-size: 46px; -} - -.day-tab.active .day-status { - background: #7956f4; - color: #fff; - box-shadow: 0 8px 18px rgba(105, 71, 220, 0.26); -} - -.date-row { - margin-top: 52px; - color: #4f485f; - font-size: 28px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.month-day { - font-size: 34px; - font-weight: 900; -} - -.summary-card { - min-height: 202px; - margin-top: 38px; - border-radius: 18px; - background: linear-gradient(105deg, #7139f3 0%, #8344ff 54%, #d35af3 100%); - color: #fff; - padding: 42px 34px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: space-between; - overflow: hidden; - position: relative; -} - -.summary-card::after { - content: ''; - position: absolute; - right: -22px; - top: -36px; - width: 236px; - height: 236px; - border-radius: 50%; - background: radial-gradient(circle, rgba(131, 226, 255, 0.95) 0%, rgba(61, 121, 250, 0.72) 50%, rgba(255, 255, 255, 0) 70%); - filter: blur(1px); -} - -.summary-copy { - width: 420px; - font-size: 34px; - line-height: 1.75; - font-weight: 800; - position: relative; - z-index: 1; -} - -.index-orb { - width: 178px; - height: 178px; - border-radius: 96px; - background: radial-gradient(circle at 45% 36%, #e8ffff 0%, #86dfff 42%, #5a71ff 100%); - color: #fff; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - box-shadow: 0 0 0 12px rgba(255, 255, 255, 0.22); - position: relative; - z-index: 1; -} - -.index-number { - font-size: 72px; - line-height: 0.95; - font-weight: 900; -} - -.index-orb text:last-child { - margin-top: 10px; - font-size: 24px; -} - -.lucky-grid-home { - margin-top: 24px; - display: grid; - grid-template-columns: 1.35fr 0.75fr; - gap: 22px; -} - -.lucky-box { - min-height: 90px; - border-radius: 18px; - padding: 0 28px; - background: rgba(255, 255, 255, 0.52); - display: flex; - align-items: center; - box-sizing: border-box; - gap: 18px; -} - -.colors-box { - background: rgba(246, 233, 255, 0.72); -} - -.number-box { - background: rgba(234, 225, 255, 0.72); -} - -.direction-box { - background: rgba(255, 241, 198, 0.82); -} - -.hour-box { - background: rgba(196, 235, 255, 0.82); -} - -.lucky-label { - flex: none; - color: #29243a; - font-size: 30px; - font-weight: 900; -} - -.color-chip, -.hour-chip { - display: flex; - align-items: center; - gap: 10px; - color: #423a52; - font-size: 28px; - white-space: nowrap; -} - -.swatch { - width: 48px; - height: 48px; - border-radius: 12px; -} - -.number-slots { - display: flex; - gap: 12px; -} - -.number-slots text { - width: 48px; - height: 58px; - border-radius: 10px; - background: linear-gradient(180deg, #8f75ff, #7454ef); - color: #fff; - font-size: 48px; - line-height: 58px; - text-align: center; - font-weight: 900; - box-shadow: inset 0 -12px 0 rgba(255, 255, 255, 0.18); -} - -.compass-icon { - width: 42px; - height: 42px; - border-radius: 50%; - background: #fff; - color: #ff9f27; - font-size: 30px; - line-height: 42px; - text-align: center; -} - -.hour-chip text:first-child { - width: 42px; - height: 42px; - border-radius: 22px; - background: #5bb8df; - color: #fff; - text-align: center; - line-height: 42px; - font-weight: 900; -} - -.dimension-head { - margin-top: 46px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.dimension-head > text { - color: #29243a; - font-size: 42px; - font-weight: 900; -} - -.dimension-head button { - color: #6d6578; - font-size: 28px; -} - -.dimension-list { - margin-top: 28px; - display: flex; - flex-direction: column; - gap: 22px; -} - -.dimension-card { - min-height: 204px; - border-radius: 18px; - background: rgba(255, 255, 255, 0.84); - padding: 26px 60px 28px 34px; - box-sizing: border-box; - display: flex; - align-items: flex-start; - position: relative; - box-shadow: 0 8px 22px rgba(90, 79, 116, 0.06); -} - -.dimension-card.suitable { - background: rgba(255, 255, 255, 0.86); -} - -.dimension-card.avoid { - background: linear-gradient(180deg, rgba(255, 249, 236, 0.9) 0%, rgba(255, 255, 255, 0.86) 48%); -} - -.dimension-icon { - width: 62px; - height: 62px; - flex: none; - margin-right: 20px; - border-radius: 18px; - position: relative; -} - -.icon-heart { - background: radial-gradient(circle at 32% 32%, #ffd2df, #ff79a3 68%, #fff 70%); - transform: rotate(-10deg); -} - -.icon-gourd { - background: radial-gradient(circle at 50% 28%, #ffc65c 0 28%, transparent 29%), radial-gradient(circle at 50% 65%, #f0a222 0 48%, transparent 49%); -} - -.icon-vase { - background: radial-gradient(circle at 50% 18%, #d5fff0 0 24%, transparent 25%), linear-gradient(180deg, #7df0c9, #2ccda0); - border-radius: 30px 30px 16px 16px; -} - -.icon-coin { - border-radius: 50%; - background: radial-gradient(circle, #fff8bb 0 33%, #f1b82d 34% 100%); -} - -.icon-fan { - border-radius: 50% 50% 12px 12px; - background: conic-gradient(from 235deg, #c29cff, #fff, #8e63ef, #c29cff); -} - -.icon-scroll { - border-radius: 12px; - background: repeating-linear-gradient(90deg, #ffe8aa 0 10px, #f5c868 11px 16px); -} - -.icon-bamboo { - background: linear-gradient(135deg, transparent 20%, #30d3b9 21% 38%, transparent 39% 50%, #25bfa8 51% 68%, transparent 69%); -} - -.icon-horse { - border-radius: 50% 28px 50% 30px; - background: linear-gradient(135deg, #dbe9ff, #6b94f8); -} - -.dimension-copy { - flex: 1; - min-width: 0; -} - -.dimension-title-row { - display: flex; - align-items: center; - gap: 20px; -} - -.dimension-title { - color: #29243a; - font-size: 34px; - font-weight: 900; - white-space: nowrap; -} - -.dimension-headline { - min-width: 0; - color: #5d5667; - font-size: 28px; - line-height: 1.3; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.dimension-detail { - display: block; - margin-top: 30px; - color: #666071; - font-size: 28px; - line-height: 1.7; -} - -.dimension-tag { - position: absolute; - right: 34px; - top: 30px; - width: 48px; - height: 48px; - border-radius: 50%; - color: #fff; - font-size: 28px; - line-height: 48px; - text-align: center; - font-weight: 900; -} - -.dimension-tag.suitable { - background: #4bc660; -} - -.dimension-tag.avoid { - background: #f45b72; -} - -.dimension-arrow { - position: absolute; - right: 34px; - bottom: 30px; - color: #3f394a; - font-size: 52px; - line-height: 1; -} - -.home-tabbar { - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 12; - height: calc(158px + env(safe-area-inset-bottom)); - padding-bottom: env(safe-area-inset-bottom); - border-radius: 26px 26px 0 0; - background: rgba(255, 255, 255, 0.96); - box-shadow: 0 -10px 32px rgba(85, 79, 110, 0.1); - display: flex; - align-items: center; - justify-content: space-around; - box-sizing: border-box; -} - -.home-tabbar-item, -.home-realm-tab { - width: 150px; - height: 132px; - color: #29243a; - font-size: 28px; - font-weight: 900; - line-height: 1.1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; -} - -.home-tabbar-item.active { - color: #ff9b2a; -} - -.home-tab-icon { - width: 42px; - height: 36px; - border-radius: 0 0 18px 18px; - background: currentColor; - position: relative; -} - -.home-tab-icon::before { - content: ''; - position: absolute; - left: 5px; - top: -22px; - width: 32px; - height: 32px; - border-radius: 18px 18px 10px 10px; - background: currentColor; -} - -.home-mine-icon { - width: 48px; - height: 32px; - border-radius: 24px 24px 14px 14px; - background: currentColor; - position: relative; -} - -.home-mine-icon::before { - content: ''; - position: absolute; - left: 13px; - top: -26px; - width: 22px; - height: 22px; - border-radius: 50%; - background: currentColor; -} - -.home-realm-tab { - margin-top: -78px; -} - -.home-realm-avatar { - width: 112px; - height: 112px; - border-radius: 60px; - border: 12px solid #f0f7ff; - box-shadow: 0 10px 24px rgba(121, 129, 164, 0.16); -} - -.sort-page { - padding-top: calc(188px + env(safe-area-inset-top)); -} - -.sort-back { - position: fixed; - left: 30px; - top: calc(48px + env(safe-area-inset-top)); - z-index: 3; - width: 58px; - height: 58px; - color: #2d2549; - font-size: 72px; - line-height: 44px; -} - -.sort-title { - position: fixed; - left: 0; - right: 0; - top: calc(116px + env(safe-area-inset-top)); - z-index: 2; - color: #29243a; - font-size: 42px; - line-height: 1; - font-weight: 900; - text-align: center; -} - -.sort-hint-row { - display: flex; - align-items: center; - color: #6e6678; - font-size: 28px; -} - -.sort-handle-symbol { - margin: 0 16px; - color: #6e6678; - font-size: 42px; -} - -.sort-done { - margin-left: auto; - color: #29243a; - font-size: 30px; - font-weight: 900; -} - -.sort-list { - margin-top: 62px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.sort-item { - height: 112px; - border-radius: 18px; - background: rgba(255, 255, 255, 0.7); - padding: 0 34px 0 38px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: space-between; - color: #29243a; - font-size: 32px; - font-weight: 800; -} - -.sort-controls { - display: flex; - align-items: center; - gap: 14px; - color: #77717f; - font-size: 38px; -} - -.sort-controls button { - width: 44px; - height: 44px; - border-radius: 22px; - background: rgba(121, 86, 244, 0.1); - color: #6d57da; - font-size: 28px; - line-height: 44px; -} + position: relative; width: 100%; max-width: 750px; min-height: 100vh; margin: 0 auto; + padding: calc(182px + env(safe-area-inset-top)) 30px calc(210px + env(safe-area-inset-bottom)); + box-sizing: border-box; color: #2c263a; overflow-x: hidden; + background: linear-gradient(180deg,#9d81fb 0,#c6acfb 300px,#eddaff 660px,#f7edff 1120px,#f6f2ff 100%); +} +.home-preview-note { position: absolute; top: calc(110px + env(safe-area-inset-top)); left: 0; right: 0; color: rgba(60,41,104,.65); font-size: 22px; line-height: 1.5; text-align: center; } +.home-day-switch { display: flex; align-items: center; gap: 38px; padding: 0 16px; } +.home-avatar-orb { flex: none; width: 108px; height: 108px; border: 3px solid rgba(255,255,255,.75); border-radius: 50%; box-sizing: border-box; background: #fffef0; display: flex; align-items: center; justify-content: center; box-shadow: 0 10px 20px rgba(115,78,168,.1); overflow: hidden; } +.home-water-image { width: 86px; height: 86px; } +.home-avatar-image { width: 100%; height: 100%; border-radius: 50%; } +.home-day-tabs { display: flex; flex: 1; min-width: 0; align-items: center; } +.home-page .home-day-tab { flex: 1; min-width: 0; height: 108px; margin: 0; padding: 0; position: relative; display: flex; flex-direction: column; align-items: center; color: #6f568f; background: transparent; border: 0; font-weight: 500; } +.home-page .home-day-tab + .home-day-tab { margin-top: 0; } +.home-day-tab + .home-day-tab::before { content: ''; position: absolute; left: 0; top: 23px; width: 2px; height: 9px; border-radius: 2px; background: rgba(79,52,112,.3); } +.home-day-label { display: block; height: 58px; font-size: 35px; line-height: 58px; white-space: nowrap; } +.home-day-status { min-width: 96px; height: 40px; padding: 0 8px; box-sizing: border-box; margin-top: 6px; border-radius: 24px; font-size: 25px; line-height: 40px; color: #a17bb8; white-space: nowrap; } +.home-day-tab.active .home-day-label { color: #2e2340; font-size: 43px; font-weight: 800; } +.home-day-tab.active .home-day-status { color: #fff; background: linear-gradient(135deg,#9864fa,#8150ee); box-shadow: 0 5px 12px rgba(124,71,216,.13); font-weight: 700; } +.home-date-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 20px; margin: 38px 10px 28px; color: #5e486f; font-size: 25px; line-height: 1.5; } +.home-solar-date { display: flex; align-items: baseline; white-space: nowrap; } +.home-date-number { font-size: 38px; font-weight: 800; line-height: 1.1; } +.home-lunar-date { font-size: 24px; } +.home-summary-card { position: relative; min-height: 200px; padding: 28px; border-radius: 24px; display: flex; align-items: center; justify-content: space-between; gap: 18px; box-sizing: border-box; color: #fff; background: linear-gradient(115deg,#7841f6 0,#8749fc 50%,#c773ef 100%); box-shadow: 0 12px 24px rgba(139,81,211,.1); overflow: hidden; } +.home-summary-card::after { content: ''; position: absolute; right: -40px; top: -90px; width: 330px; height: 330px; border: 2px solid rgba(255,255,255,.15); border-radius: 50%; } +.home-summary-copy { position: relative; z-index: 1; flex: 1; min-width: 0; font-size: 28px; font-weight: 600; line-height: 1.8; letter-spacing: 1px; } +.home-index-halo { position: relative; z-index: 1; width: 174px; height: 174px; padding: 9px; border: 2px solid rgba(240,208,255,.4); border-radius: 50%; box-sizing: border-box; background: rgba(230,195,255,.16); box-shadow: 0 0 16px rgba(255,218,251,.3); flex: none; } +.home-index-orb { width: 100%; height: 100%; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: radial-gradient(circle at 36% 25%,#b8f3ff 0,#77b7ec 28%,#547be3 61%,#96b6fc 87%,#d9d7ff 100%); box-shadow: inset 0 3px 9px rgba(255,255,255,.8),inset 0 -8px 16px rgba(64,81,187,.2); } +.home-index-number { font-size: 76px; font-weight: 800; line-height: 1; letter-spacing: -3px; } +.home-index-label { margin-top: 8px; font-size: 22px; line-height: 1.3; } +.home-summary-stars { position: absolute; inset: 0; pointer-events: none; } +.home-summary-stars > view { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: rgba(255,240,255,.7); } +.home-summary-stars > view:nth-child(1) { top: 25px; left: 42px; } +.home-summary-stars > view:nth-child(2) { top: 35px; right: 15px; width: 4px; height: 4px; } +.home-summary-stars > view:nth-child(3) { bottom: 27px; right: 29px; width: 3px; height: 3px; } +.home-lucky-row { display: flex; gap: 20px; margin-top: 20px; } +.home-lucky-box { min-width: 0; min-height: 90px; padding: 18px 20px; box-sizing: border-box; border: 1px solid rgba(255,255,255,.3); border-radius: 22px; display: flex; align-items: center; gap: 16px; } +.home-colors-box { flex: 1.8; background: rgba(231,211,250,.68); } +.home-numbers-box { flex: 1; background: rgba(220,208,252,.72); } +.home-direction-box { flex: 1; background: linear-gradient(110deg,#ffe6bd,#fff0df); } +.home-hours-box { flex: 1.8; background: linear-gradient(110deg,#c2e7fa,#dfedff); } +.home-lucky-label { flex: none; font-size: 27px; font-weight: 800; white-space: nowrap; } +.home-color-list { display: flex; gap: 16px; min-width: 0; flex: 1; flex-wrap: wrap; } +.home-color-chip { display: flex; align-items: center; gap: 8px; white-space: nowrap; font-size: 25px; } +.home-swatch { width: 42px; height: 42px; flex: none; border-radius: 12px; border: 1px solid rgba(120,100,150,.06); box-sizing: border-box; } +.home-number-slots { display: flex; gap: 10px; } +.home-number-slot { position: relative; width: 43px; height: 52px; border-radius: 8px; color: #fff; background: linear-gradient(180deg,#9279ee 49%,#a18af1 50%); text-align: center; } +.home-number-slot > text { position: relative; z-index: 1; font-size: 43px; font-weight: 700; line-height: 52px; } +.home-number-slot::after { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: rgba(92,65,152,.26); } +.home-compass { flex: none; width: 42px; height: 42px; border: 5px solid #f8d787; border-radius: 50%; background: #fff9eb; box-sizing: border-box; position: relative; } +.home-compass-needle { position: absolute; inset: 0; } +.home-compass-needle::before { content: ''; position: absolute; left: 10px; top: 3px; border-left: 5px solid transparent; border-right: 5px solid transparent; border-bottom: 14px solid #ee9b82; } +.home-compass-needle::after { content: ''; position: absolute; left: 10px; bottom: 3px; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 14px solid #7eafdd; } +.home-direction-value { font-size: 25px; white-space: nowrap; } +.home-hours-list { display: flex; gap: 12px; flex-wrap: wrap; min-width: 0; } +.home-hour-chip { display: flex; align-items: center; gap: 5px; color: #39495c; font-size: 23px; white-space: nowrap; } +.home-hour-branch { width: 34px; height: 34px; border-radius: 50%; color: #fff; background: #72b8df; line-height: 34px; text-align: center; font-weight: 700; } +.home-no-hour { font-size: 22px; color: #7385a1; } +.home-dimension-head { display: flex; align-items: center; justify-content: space-between; margin-top: 42px; } +.home-dimension-head > text { flex: none; white-space: nowrap; font-size: 37px; font-weight: 800; letter-spacing: -1px; } +.home-dimension-head > button { width: auto; flex: none; margin: 0; display: flex; align-items: center; gap: 9px; color: #85738f; font-size: 25px; padding: 14px 0 14px 20px; } +.home-sort-symbol { width: 21px; height: 23px; position: relative; } +.home-sort-symbol > view { position: absolute; top: 3px; width: 3px; height: 16px; border-radius: 2px; background: currentColor; } +.home-sort-symbol > view:first-child { left: 5px; } +.home-sort-symbol > view:last-child { right: 5px; } +.home-sort-symbol > view:first-child::after { content: ''; position: absolute; top: 0; left: -3px; width: 6px; height: 6px; border-left: 2px solid currentColor; border-top: 2px solid currentColor; transform: rotate(45deg); } +.home-sort-symbol > view:last-child::after { content: ''; position: absolute; bottom: 0; left: -3px; width: 6px; height: 6px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); } +.home-dimension-list { display: flex; flex-direction: column; gap: 18px; margin-top: 26px; } +.home-dimension-card { min-height: 206px; padding: 20px 26px 28px; border: 2px solid rgba(255,255,255,.9); border-radius: 26px; box-sizing: border-box; background: linear-gradient(180deg,#fff8ec,#fff 65%); box-shadow: 0 7px 18px rgba(115,83,149,.025); } +.home-dimension-card.dimension-emotion { background: linear-gradient(180deg,#fff0f7,#fff 65%); } +.home-dimension-card.dimension-life { background: linear-gradient(180deg,#f3fff0,#fff 65%); } +.home-dimension-card.dimension-social { background: linear-gradient(180deg,#f6efff,#fff 65%); } +.home-dimension-card.dimension-career { background: linear-gradient(180deg,#effff9,#fff 65%); } +.home-dimension-card.dimension-travel { background: linear-gradient(180deg,#f0f7ff,#fff 65%); } +.home-dimension-title-row { display: flex; align-items: center; gap: 11px; min-width: 0; } +.home-dimension-icon { display: block; width: 56px; height: 56px; flex: none; } +.home-dimension-title { font-size: 33px; font-weight: 800; white-space: nowrap; } +.home-dimension-headline { min-width: 0; flex: 1; margin-left: 4px; font-size: 26px; line-height: 1.45; color: #665b6c; } +.home-dimension-tag { flex: none; width: 43px; height: 43px; border-radius: 50%; color: #fff; font-size: 25px; font-weight: 700; line-height: 43px; text-align: center; } +.home-dimension-tag.suitable { background: #6ac87d; } +.home-dimension-tag.avoid { background: #f17d8f; } +.home-dimension-detail { display: block; margin: 24px 1px 0; color: #77707d; font-size: 27px; line-height: 1.85; } +.home-entertainment-note { display: block; margin: 28px 12px 0; color: #b0a1bd; font-size: 21px; line-height: 1.7; text-align: center; } +.home-empty { display: flex; flex-direction: column; align-items: center; gap: 28px; padding: 90px 20px; color: #7d6593; font-size: 28px; line-height: 1.8; text-align: center; } +.home-empty-image { width: 120px; height: 120px; } +.home-empty button { padding: 22px 40px; border-radius: 40px; color: #fff; background: #9977e9; font-size: 28px; } +.home-sort-page { padding-top: calc(130px + env(safe-area-inset-top)); } +.home-sort-nav { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.home-sort-nav > text { flex: 1; text-align: center; white-space: nowrap; font-size: 36px; font-weight: 800; } +.home-sort-nav > button { width: auto; flex: none; min-width: 70px; margin: 0; font-size: 29px; color: #6e4e9c; line-height: 60px; } +.home-sort-nav > button:first-child { font-size: 64px; text-align: left; } +.home-sort-hint { display: block; margin: 24px 0; color: #8e7c9e; font-size: 25px; line-height: 1.8; } +.home-sort-list { display: flex; flex-direction: column; gap: 12px; } +.home-sort-item { min-height: 96px; display: flex; align-items: center; gap: 20px; padding: 18px 24px; border-radius: 22px; box-sizing: border-box; background: rgba(255,255,255,.75); font-size: 28px; color: #5c466d; } +.home-sort-icon { width: 48px; height: 48px; } +.home-sort-controls { display: flex; gap: 16px; margin-left: auto; } +.home-sort-controls > button { width: 46px; height: 46px; border-radius: 50%; font-size: 27px; color: #9174cc; background: rgba(175,144,223,.12); line-height: 46px; } +.home-sort-controls > button[disabled] { opacity: .3; } +@media screen and (max-height: 700px) { .home-page { padding-top: calc(148px + env(safe-area-inset-top)); } .home-preview-note { top: calc(98px + env(safe-area-inset-top)); } .home-sort-page { padding-top: calc(110px + env(safe-area-inset-top)); } } diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index cf5fa7e..0b8e45f 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -1,306 +1,147 @@ -import { useEffect, useMemo, useState } from 'react' -import Taro from '@tarojs/taro' +import { useEffect, useMemo, useRef, useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' import { Button, Image, Text, View } from '@tarojs/components' -import avatarAsset from '../../assets/fortune/avatar.jpg' -import { api, DimensionItem, HomeOverview } from '../../services/request' +import { AppTabBar } from '../../components/AppTabBar' +import { api, resolveAssetUrl, UserProfile } from '../../services/request' +import { isFrontendPreview } from '../../services/frontendPreview' +import { generateHomeOverview, HOME_DIMENSION_IDS, normalizeDimensionOrder } from '../../utils/homeOverview' +import { getBeijingDate } from '../../utils/zodiacWeekly' +import water from '../../assets/home/water.png' +import heart from '../../assets/home/heart.png' +import gourd from '../../assets/home/gourd.png' +import vase from '../../assets/home/vase.png' +import coin from '../../assets/home/coin.png' +import fan from '../../assets/home/fan.png' +import scroll from '../../assets/home/scroll.png' +import bamboo from '../../assets/home/bamboo.png' +import horse from '../../assets/home/horse.png' import './index.scss' -type PageMode = 'home' | 'sort' - -const weekNames = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] - -const pad = (value: number) => `${value}`.padStart(2, '0') - -const formatDate = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` - -const createDate = (offset: number) => { - const date = new Date() - date.setDate(date.getDate() + offset) - return date -} - -const createMockHome = (): HomeOverview => { - const yesterday = createDate(-1) - const today = createDate(0) - const tomorrow = createDate(1) - - return { - activeDay: '今日', - days: [ - { - label: '昨日', - date: formatDate(yesterday), - weekName: weekNames[yesterday.getDay()], - lunarDate: '农历丙午年 九月初二', - status: '小逆' - }, - { - label: '今日', - date: formatDate(today), - weekName: weekNames[today.getDay()], - lunarDate: '农历丙午年 九月初三', - status: '小逆' - }, - { - label: '明日', - date: formatDate(tomorrow), - weekName: weekNames[tomorrow.getDay()], - lunarDate: '农历丙午年 九月初四', - status: '平日' - } - ], - summary: '小逆之日,少言多思,养精蓄锐为上。', - overallIndex: 42, - luckyColors: [ - { name: '绿茶色', value: '#a7ad2a' }, - { name: '瀑布蓝', value: '#50c3cf' } - ], - luckyNumbers: ['6', '8'], - luckyDirection: '东北', - luckyHours: [ - { branch: '子', time: '23-01点' }, - { branch: '申', time: '15-17点' } - ], - dimensions: [ - { - id: 'emotion', - title: '情感', - icon: 'heart', - headline: '默契收集,心流时刻', - detail: '适合捕捉和TA的默契瞬间,让这些小细节成为关系里的锚点。', - suitable: true - }, - { - id: 'health', - title: '健康', - icon: 'gourd', - headline: '润肺养身,舒缓秋燥', - detail: '秋日干燥适合润肺养身,舒缓身体的秋燥不适感。', - suitable: true - }, - { - id: 'life', - title: '生活', - icon: 'vase', - headline: '收纳旧物,沉淀回忆', - detail: '秋日里适合收纳旧物,在整理中沉淀那些温暖的小回忆。', - suitable: true - }, - { - id: 'wealth', - title: '财富', - icon: 'coin', - headline: '勿乱囤货,按需消费', - detail: '财富能量忌盲目囤积,适合避开非必要的批量采购,保持理性消费。', - suitable: false - }, - { - id: 'social', - title: '社交', - icon: 'fan', - headline: '勿涉纷争,保持中立', - detail: '易卷入人际是非,适合远离矛盾,保持中立姿态。', - suitable: false - }, - { - id: 'study', - title: '学业', - icon: 'scroll', - headline: '基础松散,学业忌跳级', - detail: '底层知识不牢,别急于学习进阶内容。', - suitable: false - }, - { - id: 'career', - title: '事业', - icon: 'bamboo', - headline: '敷衍了事,别凑活', - detail: '今日易因松散出错,适合认真对待每一项小任务,别敷衍。', - suitable: false - }, - { - id: 'travel', - title: '出行', - icon: 'horse', - headline: '勿负重行,轻装简从', - detail: '今日出行负重易生疲惫,建议轻装出门,避免带过多不必要的物品。', - suitable: false - } - ] - } -} - -const iconClass = (icon: string) => `dimension-icon icon-${icon}` +const icons: Record = { heart, gourd, vase, coin, fan, scroll, bamboo, horse } +const ORDER_KEY = 'home.dimension-order.v1' +const directionAngles: Record = { 正北: 0, 东北: 45, 正东: 90, 东南: 135, 正南: 180, 西南: 225, 正西: 270, 西北: 315 } export default function HomePage() { - const [overview, setOverview] = useState(createMockHome) - const [mode, setMode] = useState('home') - const [orderedIds, setOrderedIds] = useState([]) + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [loadFailed, setLoadFailed] = useState(false) + const [mode, setMode] = useState<'home' | 'sort'>('home') + const [today, setToday] = useState(() => getBeijingDate()) + const [dayOffset, setDayOffset] = useState<-1 | 0 | 1>(0) + const [orderedIds, setOrderedIds] = useState(HOME_DIMENSION_IDS) + const [avatarFailed, setAvatarFailed] = useState(false) + const requestSequence = useRef(0) + + const refreshProfile = () => { + setToday(getBeijingDate()) + setLoading(true) + setLoadFailed(false) + const sequence = ++requestSequence.current + api.profile().then((data) => { + if (sequence !== requestSequence.current) return + setProfile(data) + setAvatarFailed(false) + }).catch(() => { + if (sequence !== requestSequence.current) return + setProfile(null) + setLoadFailed(true) + }).finally(() => { + if (sequence === requestSequence.current) setLoading(false) + }) + try { setOrderedIds(normalizeDimensionOrder(Taro.getStorageSync(ORDER_KEY))) } catch { setOrderedIds([...HOME_DIMENSION_IDS]) } + } + useDidShow(refreshProfile) useEffect(() => { - api.homeOverview() - .then((data) => { - setOverview(data) - setOrderedIds(data.dimensions.map((item) => item.id)) - }) - .catch(() => { - const mock = createMockHome() - setOverview(mock) - setOrderedIds(mock.dimensions.map((item) => item.id)) - }) + let timer: ReturnType + const refreshDate = () => { + const now = new Date() + setToday(getBeijingDate(now)) + timer = setTimeout(refreshDate, 86400000 - ((now.getTime() + 28800000) % 86400000) + 100) + } + refreshDate() + return () => { clearTimeout(timer); requestSequence.current++ } }, []) - const activeDay = overview.days.find((day) => day.label === overview.activeDay) || overview.days[1] + const overview = useMemo(() => profile ? generateHomeOverview(profile, today, dayOffset) : null, [profile?.birthday, profile?.birthHour, today, dayOffset]) const orderedDimensions = useMemo(() => { - const byId = overview.dimensions.reduce>((result, item) => { - result[item.id] = item - return result - }, {}) - return orderedIds.map((id) => byId[id]).filter(Boolean) - }, [orderedIds, overview.dimensions]) + if (!overview) return [] + return orderedIds.map((id) => overview.dimensions.find((item) => item.id === id)).filter(Boolean) + }, [overview, orderedIds]) const moveDimension = (index: number, direction: -1 | 1) => { const target = index + direction if (target < 0 || target >= orderedIds.length) return const next = [...orderedIds] - const current = next[index] - next[index] = next[target] - next[target] = current + ;[next[index], next[target]] = [next[target], next[index]] setOrderedIds(next) } - - const go = (url: string) => Taro.redirectTo({ url }) - - const renderTabbar = () => ( - - - - 首页 - - - - - ) - - if (mode === 'sort') { - return ( - - - 排序 - - 按住右侧 - ☰ - 上下拖动排序 - - - - {orderedDimensions.map((item, index) => ( - - {item.title} - - - - ☰ - - - ))} - - {renderTabbar()} - - ) + const finishSorting = () => { + try { Taro.setStorageSync(ORDER_KEY, orderedIds); setMode('home') } + catch { Taro.showToast({ title: '排序保存失败,请重试', icon: 'none' }) } } - return ( - - - - ⌣ - - - {overview.days.map((day) => ( - - {day.label} - {day.status} - - ))} - + if (loading || !overview) { + return + + + {loading ? '正在准备你的每日参考…' : loadFailed ? '个人资料加载失败,请重试' : '请先完善有效的出生日期和时辰'} + {!loading && } - - - {Number(activeDay.date.slice(5, 7))}月{Number(activeDay.date.slice(8, 10))}日 {activeDay.weekName} - {activeDay.lunarDate} - - - - {overview.summary} - - {overview.overallIndex} - 综合指数 - - - - - - 吉色 - {overview.luckyColors.map((color) => ( - - - {color.name} - - ))} - - - 吉数 - - {overview.luckyNumbers.map((number) => {number})} - - - - 吉向 - ⌖ - {overview.luckyDirection} - - - 吉时 - {overview.luckyHours.map((hour) => ( - - {hour.branch} - {hour.time} - - ))} - - - - - 维度解析 - - - - - {orderedDimensions.map((item) => ( - - - - - {item.title} - {item.headline} - - {item.detail} - - - {item.suitable ? '宜' : '忌'} - - › - - ))} - - {renderTabbar()} + - ) + } + + if (mode === 'sort') { + return + + 维度排序 + + 点击上下箭头调整显示顺序,完成后保存在本机。 + {orderedDimensions.map((item, index) => + {item.title} + + + + + )} + + + } + + const activeDay = overview.days[dayOffset + 1] + return + {isFrontendPreview() && 前端预览 · 示例资料 · 不保存个人资料} + + setAvatarFailed(true)} /> + {overview.days.map((day, index) => )} + + + {Number(activeDay.date.slice(5, 7))}月{Number(activeDay.date.slice(8, 10))}日 {activeDay.weekName}{activeDay.lunarDate} + + + + {overview.summary} + {overview.overallIndex}综合指数 + + + + 吉色{overview.luckyColors.map((color) => {color.name})} + 吉数{overview.luckyNumbers.map((number) => {number})} + + + 吉向{overview.luckyDirection} + 吉时{overview.luckyHours.length ? overview.luckyHours.map((hour) => {hour.branch}{hour.time.replace(/:00/g, '')}) : 暂无参考时段} + + + 维度解析 + {orderedDimensions.map((item) => + {item.title}{item.headline}{item.suitable ? '宜' : '忌'} + {item.detail} + )} + 每日简化参考 · 仅作传统文化娱乐参考,不作为现实决策依据 + + } diff --git a/src/pages/my/index.scss b/src/pages/my/index.scss index 2c55a39..4aa919a 100644 --- a/src/pages/my/index.scss +++ b/src/pages/my/index.scss @@ -9,10 +9,22 @@ overflow-x: hidden; } +.frontend-preview-note { + position: absolute; + top: calc(100px + env(safe-area-inset-top)); + left: 0; + right: 0; + color: rgba(69, 49, 109, 0.65); + font-size: 22px; + line-height: 32px; + text-align: center; +} + .profile-area { display: flex; align-items: center; min-height: 140px; + cursor: pointer; } .profile-avatar-button { @@ -25,6 +37,13 @@ align-items: center; justify-content: center; box-shadow: 0 12px 26px rgba(119, 102, 154, 0.12); + overflow: hidden; +} + +.profile-avatar-image { + width: 100%; + height: 100%; + border-radius: 50%; } .water-avatar, @@ -436,22 +455,60 @@ display: flex; align-items: center; justify-content: center; - padding: 0 84px; + padding: 36px 60px; box-sizing: border-box; } .zodiac-modal { width: 100%; - min-height: 620px; + max-width: 650px; + min-height: 0; + height: calc(100vh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); border-radius: 34px; background: linear-gradient(180deg, #fbf7ff 0%, #f7f2ff 100%); box-shadow: 0 28px 0 -14px rgba(193, 127, 255, 0.52), 26px 30px 0 -18px rgba(114, 160, 255, 0.46); display: flex; flex-direction: column; align-items: center; - padding: 80px 52px 54px; + padding: 80px 32px 28px; box-sizing: border-box; position: relative; + max-height: calc(100vh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + overflow: hidden; +} + +.zodiac-scroll { width: 100%; height: 0; min-height: 0; flex: 1; margin-top: 18px; } +.zodiac-body { display: flex; flex-direction: column; align-items: center; padding: 0 10px 8px; box-sizing: border-box; } +.zodiac-body > * { flex-shrink: 0; } +.zodiac-body .zodiac-large { margin-top: 16px; } +.zodiac-title { flex: none; } +.zodiac-week-range { margin-top: 18px; font-size: 23px; color: #8c7b9f; text-align: center; } +.zodiac-status.positive { background: #d4efdd; color: #358259; } +.zodiac-status.neutral { background: #ece3fa; color: #806399; } +.zodiac-status.cautious { background: #ffe4d6; color: #b97a4e; } +.zodiac-date-guide { width: 100%; margin-top: 24px; padding: 22px; border: 2px solid rgba(255,255,255,.8); border-radius: 20px; background: rgba(255,255,255,.65); box-sizing: border-box; font-size: 24px; line-height: 1.8; color: #72647e; } +.zodiac-date-guide > view { display: flex; align-items: flex-start; gap: 16px; } +.zodiac-date-guide > view + view { margin-top: 16px; } +.guide-label { flex: none; font-weight: 700; } +.guide-label.supportive { color: #56a476; } +.guide-label.cautious { color: #c58a62; } +.zodiac-rules-toggle { width: 100%; margin: 22px 0 0; color: #9574bb; font-size: 24px; line-height: 44px; } +.zodiac-rules { width: 100%; padding: 22px; box-sizing: border-box; border-radius: 20px; background: rgba(237,228,249,.65); } +.zodiac-rule-copy { display: block; color: #93849f; font-size: 22px; line-height: 1.8; } +.zodiac-rule-copy + .zodiac-rule-copy { margin-top: 12px; } +.zodiac-day { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 0; border-bottom: 1px solid rgba(199,182,217,.4); font-size: 23px; line-height: 1.5; color: #928599; } +.zodiac-day.supportive { color: #53916d; } +.zodiac-day.cautious { color: #bb815f; } +.zodiac-week-note { margin-top: 20px; color: #a297ad; font-size: 21px; line-height: 1.7; text-align: center; } +.zodiac-modal .zodiac-confirm { flex: none; margin-top: 24px; } + +.zodiac-modal .lucky-flower { + top: 18px; + left: 50%; + width: 48px; + height: 48px; + font-size: 24px; + transform: translateX(-50%); } .lucky-flower { @@ -476,6 +533,10 @@ font-weight: 900; } +.zodiac-animal-name { margin-top: 22px; font-size: 34px; font-weight: 800; color: #4f3d70; } +.zodiac-calendar-note { margin-top: 10px; color: #9b8bad; font-size: 23px; line-height: 1.5; text-align: center; } +.zodiac-edit { margin-top: 24px; color: #8465c9; font-size: 28px; line-height: 1.5; } + .zodiac-large { margin-top: 46px; color: #5b4539; @@ -519,7 +580,11 @@ } .element-detail { - padding-top: calc(104px + env(safe-area-inset-top)); + width: 100%; + max-width: 750px; + margin: 0 auto; + padding: calc(130px + env(safe-area-inset-top)) 30px calc(220px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, #b4a1fc 0%, #e2d4fc 28%, #eee8ff 51%, #edf5ff 100%); } .element-back { @@ -535,32 +600,32 @@ } .element-radar { - margin-top: 52px; + margin: 0 0 44px; } .element-radar .radar { - margin-bottom: 40px; + margin-bottom: 0; } .element-radar .radar-fill { - background: #ff8fc9; - opacity: 0.72; + background: rgba(255, 143, 201, 0.72); } .element-card { - margin-top: 26px; - border-radius: 18px; - padding: 30px 28px 36px; - background: rgba(255, 255, 255, 0.72); + margin-top: 20px; + border-radius: 26px; + padding: 24px 22px 34px; + border: 2px solid rgba(255,255,255,0.45); + background: linear-gradient(145deg, rgba(255,248,241,0.78), rgba(255,255,255,0.58)); box-sizing: border-box; } .favorable-card { - background: rgba(242, 255, 248, 0.8); + background: linear-gradient(160deg, rgba(231,255,242,0.85), rgba(255,255,255,0.58)); } .avoid-card { - background: rgba(255, 247, 249, 0.78); + background: linear-gradient(160deg, rgba(255,234,241,0.8), rgba(255,255,255,0.58)); } .element-title-row { @@ -620,6 +685,24 @@ font-weight: 900; } +.strength-summary { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 26px; + padding: 20px; + border-radius: 18px; + color: #65528e; + background: rgba(232, 220, 255, 0.45); + font-size: 26px; + line-height: 1.6; +} +.pillars-summary { font-size: 23px; color: #7b708e; } +.strength-factors { display: flex; flex-direction: column; gap: 14px; margin-top: 24px; color: #71677f; font-size: 25px; line-height: 1.7; } +.element-method-note { display: block; margin: 28px 10px 0; color: #81778e; font-size: 22px; line-height: 1.7; } +.element-empty { display: flex; flex-direction: column; gap: 30px; padding: 100px 30px; color: #65528e; font-size: 28px; line-height: 1.8; text-align: center; } +.element-empty button { padding: 22px; border-radius: 40px; color: #fff; background: #9279ed; font-size: 28px; } + .green-text { color: #49bb68; font-weight: 900; @@ -629,3 +712,850 @@ color: #ef5b80; font-weight: 900; } + +/* “我的”首页视觉重构:所有图标均由稳定的 CSS 图形组成,避免依赖字体字形。 */ +.my-home { + width: 100%; + max-width: 750px; + margin: 0 auto; + padding: calc(182px + env(safe-area-inset-top)) 30px calc(196px + env(safe-area-inset-bottom)); + background: + radial-gradient(circle at 75% 8%, rgba(226, 204, 255, 0.76), transparent 31%), + linear-gradient(180deg, #9e83f5 0%, #cbbaf7 23%, #e8e9f9 43%, #edf4ff 63%, #eaf3ff 100%); +} + +.my-home .profile-area { + min-height: 146px; + padding: 0 8px; +} + +.my-home .profile-avatar-button { + width: 136px; + height: 136px; + border: 4px solid rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 235, 0.98); + box-shadow: 0 14px 34px rgba(92, 69, 147, 0.14); + box-sizing: border-box; +} + +.water-mascot { + position: relative; + width: 84px; + height: 100px; + flex: none; +} + +.water-mascot::before { + content: ''; + position: absolute; + left: 7px; + top: 18px; + width: 70px; + height: 70px; + border-radius: 70% 43% 66% 44%; + background: linear-gradient(145deg, #a9f8ff 4%, #53d8f2 54%, #28bce7 100%); + box-shadow: + inset 10px 10px 18px rgba(255, 255, 255, 0.36), + 0 8px 14px rgba(43, 171, 218, 0.18); + transform: rotate(45deg); +} + +.water-highlight { + position: absolute; + z-index: 1; + left: 22px; + top: 28px; + width: 15px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.32); + transform: rotate(24deg); +} + +.water-eye { + position: absolute; + z-index: 2; + top: 61px; + width: 7px; + height: 10px; + border-radius: 50%; + background: #3464c7; +} + +.water-eye.left { left: 30px; } +.water-eye.right { right: 29px; } + +.water-smile { + position: absolute; + z-index: 2; + left: 34px; + top: 70px; + width: 18px; + height: 10px; + border: 3px solid #3464c7; + border-top: 0; + border-radius: 0 0 16px 16px; + box-sizing: border-box; +} + +.water-mascot.compact { + transform: scale(0.78); +} + +.my-home .profile-info { + margin-left: 30px; +} + +.my-home .profile-name { + max-width: 306px; + color: #242035; + font-size: 42px; + font-weight: 800; + letter-spacing: -1px; +} + +.my-home .gender-chip { + width: 42px; + height: 42px; + background: linear-gradient(145deg, #71d6ff, #3cbcf3); + font-size: 29px; + box-shadow: 0 5px 10px rgba(61, 183, 235, 0.22); +} + +.my-home .birthday-row { + margin-top: 20px; + gap: 11px; + color: #625a78; + font-size: 27px; + font-weight: 500; +} + +.birthday-value { + width: 178px; + flex: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 27px; + font-variant-numeric: tabular-nums; + line-height: 32px; + letter-spacing: 1px; + white-space: nowrap; + text-align: left; +} + +.birthday-icon { + position: relative; + width: 30px; + height: 27px; + flex: none; +} + +.cake-candle { + position: absolute; + top: 6px; + width: 2px; + height: 7px; + border-radius: 2px; + background: currentColor; +} + +.cake-candle.left { left: 7px; } +.cake-candle.middle { left: 14px; } +.cake-candle.right { left: 21px; } + +.cake-candle::before { + content: ''; + position: absolute; + top: -5px; + left: -0.5px; + width: 3px; + height: 4px; + border-radius: 70% 30% 65% 35%; + background: currentColor; +} + +.cake-body { + position: absolute; + left: 2px; + bottom: 2px; + width: 26px; + height: 13px; + border: 2px solid currentColor; + border-radius: 3px 3px 2px 2px; + box-sizing: border-box; +} + +.cake-icing { + position: absolute; + top: 1px; + left: 0; + right: 0; + height: 3px; + border-bottom: 2px solid currentColor; + border-radius: 0 0 50% 50%; +} + +.cake-plate { + position: absolute; + left: 0; + bottom: 0; + width: 30px; + height: 2px; + border-radius: 2px; + background: currentColor; +} + +.birthday-visibility { + width: 46px; + height: 40px; + flex: none; + margin: -10px -8px -10px -3px; + border-radius: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.birthday-visibility:active { + background: rgba(105, 88, 148, 0.1); +} + +.my-home .eye-line { + position: relative; + width: 34px; + height: 18px; + flex: none; + border: 0; + border-bottom: 4px solid #7b6845; + border-radius: 0 0 50% 50%; + transform: translateY(-3px); +} + +.birthday-visibility.open .eye-line { + height: 22px; + border: 3px solid #7b6845; + border-radius: 50%; + transform: none; + box-sizing: border-box; +} + +.eye-pupil { + position: absolute; + left: 50%; + top: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: #7b6845; + transform: translate(-50%, -50%); +} + +.eye-lash { + position: absolute; + bottom: -7px; + width: 3px; + height: 8px; + border-radius: 3px; + background: #7b6845; +} + +.eye-lash.left { left: 2px; transform: rotate(35deg); } +.eye-lash.right { right: 2px; transform: rotate(-35deg); } + +.my-home .profile-arrow { + width: 42px; + color: rgba(48, 40, 72, 0.38); + font-size: 50px; + font-weight: 300; + text-align: right; +} + +.my-home .quick-card { + min-height: 194px; + margin-top: 48px; + padding: 25px 40px; + gap: 56px; + border: 2px solid rgba(255, 255, 255, 0.48); + border-radius: 28px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 18px 44px rgba(92, 72, 134, 0.08); + backdrop-filter: blur(16px); +} + +.my-home .quick-entry { + width: 142px; + height: 142px; + flex: none; + color: #29243a; + font-size: 29px; + font-weight: 500; + gap: 14px; + line-height: 1; + justify-content: flex-start; + box-sizing: border-box; +} + +.my-home .quick-entry + .quick-entry { + margin-top: 0 !important; +} + +.my-home .quick-entry > text { + width: 100%; + height: 34px; + line-height: 34px; + text-align: center; +} + +.quick-icon { + position: relative; + width: 94px; + height: 94px; + border-radius: 50%; + background: linear-gradient(145deg, #f2ebff, #e8ddff); + box-shadow: + inset 0 2px 0 rgba(255, 255, 255, 0.68), + 0 9px 20px rgba(121, 91, 172, 0.13); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.my-home .zodiac-icon, +.my-home .element-icon { + width: 94px; + height: 94px; + color: inherit; + font-size: inherit; + line-height: inherit; + background: linear-gradient(145deg, #f2ebff, #e8ddff); +} + +.my-home .element-icon .water-mascot.compact { + transform: translateY(-4px) scale(0.78); +} + +.rabbit-mascot { + position: relative; + width: 62px; + height: 76px; +} + +.rabbit-ear { + position: absolute; + z-index: 1; + top: 0; + width: 20px; + height: 43px; + border: 3px solid #70513d; + border-radius: 50% 50% 42% 42%; + background: #fff8d8; + box-sizing: border-box; +} + +.rabbit-ear.left { left: 9px; transform: rotate(-5deg); } +.rabbit-ear.right { right: 9px; transform: rotate(5deg); } + +.rabbit-ear view { + position: absolute; + left: 50%; + top: 8px; + width: 7px; + height: 24px; + border-radius: 50%; + background: #ffbdc8; + transform: translateX(-50%); +} + +.rabbit-head { + position: absolute; + z-index: 2; + left: 3px; + bottom: 1px; + width: 56px; + height: 49px; + border: 3px solid #70513d; + border-radius: 46% 46% 42% 42%; + background: #fff8d8; + box-sizing: border-box; + box-shadow: inset 0 -5px 0 rgba(255, 225, 176, 0.42); +} + +.rabbit-eye { + position: absolute; + top: 22px; + width: 4px; + height: 5px; + border-radius: 50%; + background: #5d4337; +} + +.rabbit-eye.left { left: 15px; } +.rabbit-eye.right { right: 15px; } + +.rabbit-nose { + position: absolute; + left: 50%; + top: 28px; + width: 6px; + height: 5px; + border-radius: 50% 50% 70% 70%; + background: #f28ca6; + transform: translateX(-50%); +} + +.my-home .palace-section { + margin-top: 38px; + padding: 0 2px; +} + +.my-home .section-title-row { + gap: 11px; +} + +.my-home .section-title { + color: #252034; + font-size: 40px; + font-weight: 800; + letter-spacing: -1px; +} + +.my-home .collection-text { + color: #393347; + font-size: 27px; +} + +.my-home .collection-text text { + color: #e84a7d; +} + +.my-home .palace-placeholder { + width: 222px; + height: 300px; + margin-top: 25px; + padding: 16px 15px 18px; + border: 2px solid rgba(255, 218, 108, 0.72); + border-radius: 21px; + background: + radial-gradient(circle at 75% 18%, rgba(255, 251, 210, 0.9), transparent 34%), + linear-gradient(155deg, #ffd670 0%, #ffe49b 54%, #fff0b3 100%); + box-shadow: 0 15px 28px rgba(198, 151, 53, 0.18); +} + +.my-home .palace-copy { + width: 26px; + color: #7a4c16; + font-size: 23px; + line-height: 1.18; +} + +.my-home .palace-coin { + width: 160px; + height: 160px; + margin-top: -49px; + filter: saturate(1.04) contrast(1.02); +} + +.my-home .palace-name { + margin-top: 5px; + color: #72440e; + font-size: 28px; + font-weight: 800; +} + +.my-home .settings-card { + margin-top: 38px; + padding: 0 31px; + border: 2px solid rgba(255, 255, 255, 0.65); + border-radius: 28px; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 16px 36px rgba(85, 76, 111, 0.08); + backdrop-filter: blur(16px); +} + +.my-home .settings-row { + min-height: 108px; + gap: 25px; + color: #29243a; + font-size: 31px; + font-weight: 500; + border-bottom-color: rgba(215, 219, 232, 0.72); +} + +.my-home .menu-icon { + width: 48px; + height: 48px; + color: #332d4d; +} + +.my-home .headset-icon { + border: 0; + border-radius: 0; +} + +.my-home .headset-icon::before, +.my-home .headset-icon::after { + content: none; +} + +.headset-band { + position: absolute; + left: 5px; + top: 4px; + width: 38px; + height: 34px; + border: 4px solid currentColor; + border-bottom: 0; + border-radius: 24px 24px 0 0; + box-sizing: border-box; +} + +.headset-ear { + position: absolute; + top: 24px; + width: 9px; + height: 17px; + border: 4px solid currentColor; + border-radius: 8px; + box-sizing: border-box; + background: #fff; +} + +.headset-ear.left { left: 2px; } +.headset-ear.right { right: 2px; } + +.headset-mic { + position: absolute; + right: 8px; + bottom: 3px; + width: 14px; + height: 9px; + border-bottom: 4px solid currentColor; + border-radius: 0 0 10px 10px; + transform: rotate(-12deg); +} + +.my-home .star-icon { + border: 0; + border-radius: 0; + line-height: 1; +} + +.star-core { + position: absolute; + inset: 8px; + border: 4px solid currentColor; + border-radius: 12px; + transform: rotate(45deg); + box-sizing: border-box; +} + +.star-plus { + position: absolute; + z-index: 2; + left: 50%; + top: 50%; + width: 18px; + height: 4px; + border-radius: 4px; + background: currentColor; + transform: translate(-50%, -50%); +} + +.star-plus.vertical { + transform: translate(-50%, -50%) rotate(90deg); +} + +.my-home .agreement-icon { + border: 4px solid currentColor; + border-radius: 9px; +} + +.my-home .agreement-icon::before, +.my-home .agreement-icon::after { + content: none; +} + +.agreement-line { + position: absolute; + left: 9px; + height: 3px; + border-radius: 3px; + background: currentColor; +} + +.agreement-line.first { top: 13px; width: 24px; } +.agreement-line.second { top: 24px; width: 18px; } + +.agreement-check { + position: absolute; + right: -9px; + bottom: -9px; + width: 22px; + height: 22px; + border: 4px solid currentColor; + border-radius: 50%; + background: #fff; + box-sizing: border-box; +} + +.agreement-check::after { + content: ''; + position: absolute; + left: 5px; + top: 3px; + width: 6px; + height: 3px; + border-left: 3px solid currentColor; + border-bottom: 3px solid currentColor; + transform: rotate(-45deg); +} + +.my-home .row-arrow { + color: rgba(42, 36, 60, 0.4); + font-size: 48px; +} + +.my-page .my-tabbar { + left: 50%; + right: auto; + width: 100%; + max-width: 750px; + height: calc(158px + env(safe-area-inset-bottom)); + border: 2px solid rgba(255, 255, 255, 0.7); + border-bottom: 0; + border-radius: 30px 30px 0 0; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 -13px 34px rgba(74, 70, 102, 0.1); + transform: translateX(-50%); + backdrop-filter: blur(18px); +} + +.my-page .my-tabbar-item, +.my-page .realm-tab { + width: 150px; + height: 132px; + gap: 10px; + color: #2d2940; + font-size: 27px; + font-weight: 700; +} + +.my-page .my-tabbar-item.active { + color: #ff9a29; +} + +.my-page .home-tab-icon { + position: relative; + width: 48px; + height: 46px; + border-radius: 0; + background: transparent; +} + +.my-page .home-tab-icon::before { + content: none; +} + +.home-roof { + position: absolute; + left: 8px; + top: 2px; + width: 30px; + height: 30px; + border: 7px solid currentColor; + border-right: 0; + border-bottom: 0; + border-radius: 5px; + box-sizing: border-box; + transform: rotate(45deg); +} + +.home-body { + position: absolute; + left: 8px; + bottom: 1px; + width: 32px; + height: 27px; + border-radius: 3px 3px 7px 7px; + background: currentColor; +} + +.home-door { + position: absolute; + left: 12px; + bottom: 0; + width: 9px; + height: 14px; + border-radius: 5px 5px 0 0; + background: #fff; +} + +.my-page .mine-tab-icon { + position: relative; + width: 52px; + height: 48px; + border-radius: 0; + background: transparent; +} + +.my-page .mine-tab-icon::before { + content: none; +} + +.mine-head { + position: absolute; + left: 50%; + top: 1px; + width: 19px; + height: 19px; + border-radius: 50%; + background: currentColor; + transform: translateX(-50%); +} + +.mine-body { + position: absolute; + left: 4px; + bottom: 0; + width: 44px; + height: 24px; + border-radius: 23px 23px 7px 7px; + background: currentColor; +} + +.my-page .realm-tab { + margin-top: -76px; +} + +.realm-avatar-wrap { + width: 118px; + height: 118px; + padding: 9px; + border-radius: 50%; + background: linear-gradient(145deg, #fff, #e8f2ff); + box-shadow: 0 10px 26px rgba(104, 114, 157, 0.18); + box-sizing: border-box; +} + +.my-page .realm-avatar { + width: 100%; + height: 100%; + border: 0; + border-radius: 50%; + box-shadow: none; +} + +.realm-spirit { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 50%; + background: + radial-gradient(circle at 28% 30%, rgba(255, 255, 255, 0.96) 0 12%, transparent 13%), + linear-gradient(145deg, #fffaff 0%, #ffe9f5 52%, #e9efff 100%); +} + +.spirit-ear { + position: absolute; + z-index: 1; + top: 12px; + width: 34px; + height: 40px; + border-radius: 88% 18% 55% 44%; + background: linear-gradient(145deg, #fff 16%, #f6bddb 86%); + box-shadow: 0 3px 8px rgba(219, 124, 172, 0.18); +} + +.spirit-ear.left { left: 12px; transform: rotate(-24deg); } +.spirit-ear.right { right: 12px; transform: scaleX(-1) rotate(-24deg); } + +.spirit-ear view { + position: absolute; + left: 11px; + top: 7px; + width: 11px; + height: 23px; + border-radius: 80% 30% 60% 40%; + background: rgba(242, 113, 168, 0.54); +} + +.spirit-face { + position: absolute; + z-index: 2; + left: 12px; + right: 12px; + bottom: 9px; + height: 67px; + border-radius: 48% 48% 45% 45%; + background: + radial-gradient(circle at 50% 16%, rgba(255, 255, 255, 0.9), transparent 42%), + linear-gradient(180deg, #fffafd, #fce9f5); + box-shadow: inset 0 -7px 12px rgba(226, 190, 225, 0.24); +} + +.spirit-eye { + position: absolute; + top: 32px; + width: 18px; + height: 9px; + border-bottom: 4px solid #9f5c95; + border-radius: 0 0 50% 50%; + box-sizing: border-box; +} + +.spirit-eye.left { left: 10px; transform: rotate(8deg); } +.spirit-eye.right { right: 10px; transform: rotate(-8deg); } + +.spirit-nose { + position: absolute; + left: 50%; + top: 39px; + width: 8px; + height: 8px; + border-radius: 60% 60% 55% 55%; + background: #ee75a7; + transform: translateX(-50%) rotate(45deg); +} + +.spirit-cheek { + position: absolute; + top: 45px; + width: 14px; + height: 7px; + border-radius: 50%; + background: rgba(248, 134, 178, 0.3); +} + +.spirit-cheek.left { left: 6px; } +.spirit-cheek.right { right: 6px; } + +.zodiac-large { + width: 174px; + height: 174px; + flex: none; + margin-top: 32px; + border-radius: 50%; + background: linear-gradient(145deg, #f5edff, #e7dcff); + box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.72), 0 14px 26px rgba(121, 91, 172, 0.14); + display: flex; + align-items: center; + justify-content: center; +} + + +@media screen and (max-height: 720px) { + .my-home { + padding-top: calc(142px + env(safe-area-inset-top)); + } + + .my-home .quick-card { + margin-top: 34px; + } + + .my-home .palace-section, + .my-home .settings-card { + margin-top: 30px; + } +} diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index 96dfffe..ddc15e9 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -1,50 +1,23 @@ import { useEffect, useMemo, useState } from 'react' -import Taro from '@tarojs/taro' -import { Button, Image, Text, View } from '@tarojs/components' +import Taro, { useDidShow } from '@tarojs/taro' +import { Button, Image, ScrollView, Text, View } from '@tarojs/components' import { RadarChart } from '../../components/RadarChart' -import { api, UserProfile } from '../../services/request' -import avatarAsset from '../../assets/fortune/avatar.jpg' +import { AppTabBar } from '../../components/AppTabBar' +import { ZodiacMascot } from '../../components/ZodiacMascot' +import { api, resolveAssetUrl, UserProfile } from '../../services/request' import coinAsset from '../../assets/fortune/coin.jpg' import { calculateFiveElements } from '../../utils/fiveElements' +import { calculateZodiac } from '../../utils/zodiac' +import { calculateWeeklyZodiacFortune, getBeijingDate, ZODIAC_WEEKLY_RULES } from '../../utils/zodiacWeekly' +import { isFrontendPreview } from '../../services/frontendPreview' import './index.scss' type PageMode = 'home' | 'element' -const mockProfile: UserProfile = { - id: 1, - nickname: 'You', - gender: '男', - birthday: '1998-06-18', - birthHour: '辰时', - province: '河南省', - city: '洛阳市', - district: '老城区', - points: 120, - remainingEdits: 5, - elementReport: { - scores: [ - { name: '土', color: '#b46a18', value: 42 }, - { name: '金', color: '#d59a05', value: 22 }, - { name: '水', color: '#35bfe8', value: 8 }, - { name: '木', color: '#36b85b', value: 22 }, - { name: '火', color: '#ed3c43', value: 7 } - ], - favorable: '金水', - unfavorable: '木火土', - dayMaster: '壬' - } -} - -const formatMaskedBirthday = (birthday: string) => { +const formatBirthday = (birthday: string, visible: boolean) => { const [year, month, day] = birthday.split('-') if (!year || !month || !day) return '****_**_**' - return '****_**_**' -} - -const formatBirthday = (birthday: string) => { - const [year, month, day] = birthday.split('-') - if (!year || !month || !day) return '****_**_**' - return `${year}_${month}_${day}` + return visible ? `${year}_${month}_${day}` : '****_**_**' } const genderSymbol = (gender: string) => (gender === '女' ? '♀' : '♂') @@ -63,22 +36,68 @@ const traitText = (elements: string) => elements .filter(Boolean) .join('、') +const WaterMascot = ({ compact = false }: { compact?: boolean }) => ( + + + + + + +) + export default function MyPage() { - const [profile, setProfile] = useState(mockProfile) + const [profile, setProfile] = useState(null) + const [profileError, setProfileError] = useState(false) const [mode, setMode] = useState('home') const [zodiacOpen, setZodiacOpen] = useState(false) + const [zodiacRulesOpen, setZodiacRulesOpen] = useState(false) + const [beijingDate, setBeijingDate] = useState(() => getBeijingDate()) const [birthdayVisible, setBirthdayVisible] = useState(false) + const previewNote = isFrontendPreview() + ? 前端预览 · 示例资料 · 不保存修改 + : null - const elementReport = useMemo( - () => calculateFiveElements(profile.birthday, profile.birthHour), - [profile.birthday, profile.birthHour] + const zodiac = useMemo(() => calculateZodiac(profile?.birthday || ''), [profile?.birthday]) + const weeklyFortune = useMemo( + () => calculateWeeklyZodiacFortune(profile?.birthday || '', beijingDate), + [profile?.birthday, beijingDate] ) useEffect(() => { + if (!zodiacOpen) return + let timer: ReturnType + const refreshDate = () => { + const now = new Date() + setBeijingDate(getBeijingDate(now)) + const untilMidnight = 86400000 - ((now.getTime() + 28800000) % 86400000) + timer = setTimeout(refreshDate, untilMidnight + 100) + } + refreshDate() + return () => clearTimeout(timer) + }, [zodiacOpen]) + + const openZodiac = () => { + setBeijingDate(getBeijingDate()) + setZodiacRulesOpen(false) + setZodiacOpen(true) + } + + const elementReport = useMemo( + () => profile ? calculateFiveElements(profile.birthday, profile.birthHour) : null, + [profile?.birthday, profile?.birthHour] + ) + + const refreshProfile = () => { + setBeijingDate(getBeijingDate()) + setProfileError(false) api.profile() .then(setProfile) - .catch(() => setProfile(mockProfile)) - }, []) + .catch(() => { + setProfileError(true) + setProfile(null) + }) + } + useDidShow(refreshProfile) const go = (url: string) => Taro.navigateTo({ url }) @@ -87,25 +106,21 @@ export default function MyPage() { Taro.showToast({ title: '企业微信入口待配置', icon: 'none' }) } - const renderTabs = () => ( - - - - - - 我的 - - - ) + const renderTabs = () => - const renderElement = () => ( + const renderElement = () => !elementReport ? ( + {previewNote} + + + 请先完善有效的出生日期和时辰,再查看元素报告。 + + + {renderTabs()} + + ) : ( + + {previewNote} @@ -118,63 +133,111 @@ export default function MyPage() { 日主,即出生日期中的天干,其五行属性直接代表自身能量基础。 - 你适应力强,擅长在变化中寻找机会与路径。 + 你的日主为{elementReport.dayMaster}{elementReport.masterElement},对应的五行特质是{traitText(elementReport.masterElement)}。 + + 日主{elementReport.strength} · 综合评分 {elementReport.strengthScore}/100 + 四柱:{elementReport.pillars.join(' · ')} + + + 得令:{elementReport.analysis.season.description} + 得地:{elementReport.analysis.roots.description} + 得势:{elementReport.analysis.support.description} + 喜用 - {elementReport.favorable} + {elementReport.favorable || '暂无明显偏向'} 喜用,即更容易让事情进展顺、做得稳、发挥得好的行为方式。 + {'\n'}根据得令、得地、得势综合判断,作为平衡日主强弱的五行参考。 - 你更容易发挥{traitText(elementReport.favorable)}的优势,遇事能找到适合自己的节奏。 + {elementReport.strength === '相对平衡' + ? '当前日主相对平衡,不预设需要补充的五行,宜保持协调。' + : `日主${elementReport.strength},以${elementReport.strength === '偏弱' ? '生扶' : '泄耗制衡'}为参考方向。可关注${traitText(elementReport.favorable)}。`} 忌用 - {elementReport.unfavorable} + {elementReport.unfavorable || '暂无明显偏忌'} 忌用,即容易让你更累、更乱,或做了很多却效果不好的行为方式。 - 当{traitText(elementReport.unfavorable)}的能量偏旺时,适合放慢节奏,避免陷入无谓消耗。 + {elementReport.unfavorable + ? `日主${elementReport.strength}时,${elementReport.unfavorable}不宜继续偏增。可留意${traitText(elementReport.unfavorable)}失衡带来的消耗。` + : '当前不划分固定偏忌,避免单一五行过度偏增。'} + 普通扶抑简化参考算法 v1,仅作传统文化娱乐参考;不含从格、化气、合冲刑害及调候等特殊判断。按北京时间公历、节气月柱与零点换日计算;时辰以起始小时代表,不作真太阳时校正。 {renderTabs()} ) + if (!profile) { + return + {profileError ? '个人资料加载失败,请重试' : '个人资料加载中…'} + {profileError && } + {renderTabs()} + } + if (mode === 'element') { return renderElement() } return ( - - + {previewNote} + go('/pages/profile-edit/index')}> + + {profile.avatarUrl + ? + : } + {profile.nickname} {genderSymbol(profile.gender)} - + + + + + + + + + {formatBirthday(profile.birthday, birthdayVisible)} + { + event.stopPropagation() + setBirthdayVisible((visible) => !visible) + }} + > + + {birthdayVisible + ? + : ( + <> + + + + )} + + + - + › - @@ -193,17 +256,30 @@ export default function MyPage() { @@ -215,10 +291,42 @@ export default function MyPage() { setZodiacOpen(false)}> event.stopPropagation()}> 运 - 生肖周运 - ⌇ - 平稳 - 本周运势平稳,生活节奏舒缓,安心度日,享受静谧时光。 + {zodiac ? `${zodiac} · 生肖周运` : '我的生肖'} + + + + {weeklyFortune ? ( + <> + 属{zodiac} + 按农历出生年 · 春节换年 + {weeklyFortune.weekStart} — {weeklyFortune.weekEnd} + {weeklyFortune.status} + {weeklyFortune.summary} + + 助力日{weeklyFortune.supportiveDays.map((item) => `${item.label} ${item.weekday}`).join('、') || '本周无明显合助日'} + 留意日{weeklyFortune.cautiousDays.map((item) => `${item.label} ${item.weekday}`).join('、') || '本周无六冲、相害日'} + + + {zodiacRulesOpen && + {ZODIAC_WEEKLY_RULES} + 本周合计:{weeklyFortune.score > 0 ? '+' : ''}{weeklyFortune.score}。逐日与出生年支“{weeklyFortune.branch}”比较: + {weeklyFortune.days.map((item) => 0 ? 'supportive' : item.score < 0 ? 'cautious' : ''}`}> + {item.label} {item.weekday} + {item.branch}日 · {item.relation === '三合' ? '三合同组' : item.relation} + {item.score > 0 ? '+' : ''}{item.score} + )} + 三合仅取同组两支的助力关系,不代表三合成局;不纳入刑、破或完整八字。 + } + 按北京时间周一至周日计算 · 合冲简化规则 v1{ '\n' }仅作传统文化娱乐参考,不作为现实决策依据 + + ) : ( + <> + {zodiac ? '周运日期无法计算,请检查设备日期后重新打开。' : '请先完善有效的出生日期,再查看你的生肖。'} + {!zodiac && } + + )} + + diff --git a/src/pages/profile-edit/index.scss b/src/pages/profile-edit/index.scss index 444ac8e..3abfd03 100644 --- a/src/pages/profile-edit/index.scss +++ b/src/pages/profile-edit/index.scss @@ -1,113 +1,364 @@ .edit-page { + min-height: 100vh; + box-sizing: border-box; + color: #211c3a; + background: + radial-gradient(circle at 14% 12%, rgba(179, 154, 255, 0.72), transparent 34%), + linear-gradient(180deg, #c2afff 0%, #e4d8ff 36%, #edf4ff 70%, #edf5ff 100%); + overflow-x: hidden; +} + +.edit-page .nav { + height: calc(112px + env(safe-area-inset-top)); + padding: 0 32px 24px; + align-items: flex-end; +} + +.edit-page .nav-title { + color: #211c3a; + font-size: 40px; + font-weight: 800; +} + +.edit-page .back { + left: 34px; + bottom: 18px; + color: #292244; + font-size: 60px; +} + +.profile-loading { + padding-top: calc(280px + env(safe-area-inset-top)); + text-align: center; + color: rgba(33, 28, 58, 0.58); + font-size: 28px; +} + +.edit-content { + width: 100%; + max-width: 750px; + min-height: 100vh; + margin: 0 auto; + padding: calc(164px + env(safe-area-inset-top)) 64px calc(78px + env(safe-area-inset-bottom)); + box-sizing: border-box; display: flex; flex-direction: column; align-items: center; - padding-top: 210px; } -.edit-avatar { - width: 150px; - height: 150px; - border-radius: 88px; - background: #fbffd7; - color: #17c9ea; - font-size: 60px; +.avatar-button { + width: 280px; + height: 280px; + flex: none; + border-radius: 50%; + background: rgba(255, 255, 237, 0.94); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: visible; + box-shadow: 0 4px 12px rgba(102, 83, 151, 0.08); +} + +.avatar-image { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.water-mascot { + width: 142px; + height: 178px; + position: relative; + border-radius: 70% 70% 56% 56% / 82% 82% 42% 42%; + background: linear-gradient(180deg, #a0f6ff 0%, #47d2ef 54%, #1fb7e4 100%); + box-shadow: inset 16px 10px 30px rgba(255, 255, 255, 0.28); +} + +.mascot-eye { + position: absolute; + top: 100px; + width: 10px; + height: 14px; + border-radius: 50%; + background: #3275ef; +} + +.mascot-eye.left { left: 48px; } +.mascot-eye.right { right: 48px; } + +.mascot-smile { + position: absolute; + left: 56px; + top: 116px; + width: 34px; + height: 22px; + border: 7px solid #3275ef; + border-top: 0; + border-radius: 0 0 28px 28px; + box-sizing: border-box; +} + +.avatar-add { + position: absolute; + right: 4px; + bottom: 4px; + width: 66px; + height: 66px; + border-radius: 50%; + background: linear-gradient(145deg, #ffb13b 0%, #ff8c1f 100%); + border: 5px solid rgba(255, 255, 255, 0.9); + box-sizing: border-box; + box-shadow: 0 7px 16px rgba(188, 100, 19, 0.24); +} + +.plus-line { + position: absolute; + left: 50%; + top: 50%; + width: 30px; + height: 6px; + border-radius: 6px; + background: #fff; + transform: translate(-50%, -50%); +} + +.plus-line.vertical { + transform: translate(-50%, -50%) rotate(90deg); +} + +.form-card { + width: 100%; + min-height: 94px; + border: 3px solid rgba(255, 255, 255, 0.72); + border-radius: 50px; + background: rgba(255, 255, 255, 0.68); + box-sizing: border-box; + display: flex; + align-items: center; + color: #28204e; +} + +.nickname-card { + margin-top: 58px; + padding: 0 30px; +} + +.field-label { + flex: none; + font-size: 30px; + font-weight: 800; +} + +.nickname-input { + height: 90px; + flex: 1; + min-width: 0; + margin-left: 36px; + color: #28204e; + font-size: 31px; + display: flex; + align-items: center; +} + +.nickname-input input { + height: 100%; + padding: 0; + line-height: 90px; + box-sizing: border-box; +} + +.nickname-count { + flex: none; + color: #746c9c; + font-size: 28px; +} + +.gender-row { + width: 100%; + margin: 34px 0; + display: flex; + justify-content: space-between; + gap: 76px; +} + +.gender-option { + height: 94px; + flex: 1; + border: 3px solid rgba(255, 255, 255, 0.7); + border-radius: 50px; + background: rgba(255, 255, 255, 0.66); + color: #332463; + font-size: 31px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 22px; +} + +.gender-option.active { + color: #fff; + background: linear-gradient(90deg, #65c8f9 0%, #51b8ef 100%); +} + +.gender-option + .gender-option { + margin-top: 0; +} + +.gender-symbol { + font-size: 52px; + font-weight: 500; + line-height: 1; +} + +.picker-card { + margin-bottom: 34px; + padding: 0 28px 0 31px; +} + +.form-picker { + width: 100%; + display: block; +} + +.field-value { + flex: 1; + min-width: 0; + margin-left: 36px; + font-size: 29px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.field-value.location { + font-size: 27px; +} + +.field-value.placeholder { + color: #8e88aa; +} + +.chevron { + flex: none; + margin-left: 14px; + font-size: 56px; + font-weight: 300; + line-height: 1; +} + +.save-button { + width: 456px; + height: 96px; + margin-top: 36px; + border-radius: 52px; + color: #fff; + background: #ff9828; + font-size: 34px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 20px rgba(222, 128, 32, 0.14); +} + +.save-button[disabled] { + opacity: 0.68; +} + +.edit-note { + margin-top: 28px; + color: #666275; + font-size: 27px; +} + +.edit-note text { + color: #f13d57; +} + +.avatar-mask { + position: fixed; + z-index: 30; + inset: 0; + background: rgba(21, 17, 39, 0.62); +} + +.avatar-sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding-bottom: env(safe-area-inset-bottom); + border-radius: 28px 28px 0 0; + overflow: hidden; + background: #fff; +} + +.avatar-action { + width: 100%; + height: 118px; + border-bottom: 1px solid #eeeef2; + border-radius: 0; + color: #202020; + background: #fff; + font-size: 33px; display: flex; align-items: center; justify-content: center; position: relative; } -.edit-avatar text { - position: absolute; - right: -34px; - bottom: 34px; - font-size: 28px; - font-weight: 700; -} - -.name-pill { - width: 420px; - height: 76px; - margin-top: 56px; - border-radius: 40px; - background: #fff7de; - display: flex; - align-items: center; - padding: 0 30px; - gap: 20px; - box-sizing: border-box; - color: #8f640d; - font-size: 38px; -} - -.name-pill input { - flex: 1; - text-align: center; - color: #8f640d; -} - -.gender-row { - margin-top: 58px; - display: flex; - gap: 86px; -} - -.gender { - color: #8f640d; - font-size: 34px; - display: flex; +.wechat-avatar-action { + height: 154px; + flex-direction: column; gap: 12px; - align-items: center; } -.section-title { - margin-top: 58px; - font-size: 34px; - font-weight: 700; - color: #8f640d; +.wechat-avatar-preview { + width: 44px; + height: 44px; + border-radius: 6px; } -.date-row, -.region-row { - margin-top: 34px; - display: flex; - gap: 18px; +.sheet-gap { + height: 16px; + background: #f5f5f7; } -.date-part { - width: 150px; - display: flex; - align-items: center; - justify-content: center; - font-size: 32px; +.cancel-action { + height: 126px; + border-bottom: 0; } -.hour { - width: 360px; - margin-top: 30px; - display: flex; - align-items: center; - justify-content: center; - font-size: 32px; -} +@media screen and (max-height: 700px) { + .edit-content { + padding-top: calc(140px + env(safe-area-inset-top)); + } -.region-row .input-pill { - width: 176px; - display: flex; - align-items: center; - justify-content: center; - font-size: 26px; - padding: 0 12px; -} + .avatar-button { + width: 220px; + height: 220px; + } -.start-button { - width: 500px; - margin-top: 110px; -} + .water-mascot { + transform: scale(0.82); + } -.edit-note { - margin-top: 28px; - color: #4b2d1a; - font-size: 28px; + .nickname-card { + margin-top: 38px; + } + + .gender-row, + .picker-card { + margin-bottom: 24px; + } + + .gender-row { + margin-top: 24px; + } + + .save-button { + margin-top: 18px; + } } diff --git a/src/pages/profile-edit/index.tsx b/src/pages/profile-edit/index.tsx index 524c6eb..6291f19 100644 --- a/src/pages/profile-edit/index.tsx +++ b/src/pages/profile-edit/index.tsx @@ -1,74 +1,332 @@ import { useEffect, useState } from 'react' import Taro from '@tarojs/taro' -import { Button, Input, Picker, Text, View } from '@tarojs/components' +import { Button, Image, Input, Picker, Text, View } from '@tarojs/components' +import areaData from 'china-area-data' import { NavBar } from '../../components/NavBar' -import { api, UserProfile } from '../../services/request' +import { api, resolveAssetUrl, UserProfile } from '../../services/request' import './index.scss' -const hours = ['子时', '丑时', '寅时', '卯时', '辰时', '巳时', '午时', '未时', '申时', '酉时', '戌时', '亥时'] -const regions = [['河南省', '北京市', '上海市'], ['洛阳市', '郑州市', '北京市'], ['老城区', '涧西区', '西工区']] +const hours = [ + { name: '子时', range: '23:00-01:00' }, + { name: '丑时', range: '01:00-03:00' }, + { name: '寅时', range: '03:00-05:00' }, + { name: '卯时', range: '05:00-07:00' }, + { name: '辰时', range: '07:00-09:00' }, + { name: '巳时', range: '09:00-11:00' }, + { name: '午时', range: '11:00-13:00' }, + { name: '未时', range: '13:00-15:00' }, + { name: '申时', range: '15:00-17:00' }, + { name: '酉时', range: '17:00-19:00' }, + { name: '戌时', range: '19:00-21:00' }, + { name: '亥时', range: '21:00-23:00' } +] + +type EditableProfile = Pick +type RegionEntry = [string, string] + +const emptyForm: EditableProfile = { + nickname: '', + gender: '', + birthday: '', + birthHour: '', + province: '', + city: '', + district: '' +} + +const previewProfile: UserProfile = { + id: 0, + nickname: 'You', + gender: '男', + birthday: '1999-11-06', + birthHour: '戌时', + province: '河南省', + city: '洛阳市', + district: '洛龙区', + points: 0, + remainingEdits: 5, + elementReport: { scores: [], favorable: '', unfavorable: '', dayMaster: '' } +} + +const toEditableProfile = (data: UserProfile): EditableProfile => ({ + nickname: data.nickname, + gender: data.gender, + birthday: data.birthday, + birthHour: data.birthHour, + province: data.province, + city: data.city, + district: data.district +}) + +const regionEntries = (parentCode: string): RegionEntry[] => Object.entries(areaData[parentCode] || {}) + +const findCodeByName = (entries: RegionEntry[], name: string) => entries.find(([, label]) => label === name)?.[0] + +const findRegionCodes = (profile: Pick): string[] => { + const provinces = regionEntries('86') + const provinceCode = findCodeByName(provinces, profile.province) || provinces[0]?.[0] || '' + const cities = regionEntries(provinceCode) + const cityCode = findCodeByName(cities, profile.city) + || (profile.city === profile.province && cities.length === 1 ? cities[0]?.[0] : '') + || cities[0]?.[0] + || '' + const districts = regionEntries(cityCode) + const districtCode = findCodeByName(districts, profile.district) || districts[0]?.[0] || '' + return [provinceCode, cityCode, districtCode] +} + +const errorMessage = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback export default function ProfileEditPage() { - const [form, setForm] = useState>({}) + const [profile, setProfile] = useState(null) + const [form, setForm] = useState(emptyForm) + const [avatarPreview, setAvatarPreview] = useState('') + const [pendingAvatar, setPendingAvatar] = useState('') + const [regionCodes, setRegionCodes] = useState(findRegionCodes(previewProfile)) + const [avatarSheetOpen, setAvatarSheetOpen] = useState(false) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) useEffect(() => { - api.profile().then(setForm) + api.profile() + .then((data) => { + setProfile(data) + setForm(toEditableProfile(data)) + setRegionCodes(findRegionCodes(data)) + setAvatarPreview(resolveAssetUrl(data.avatarUrl)) + }) + .catch((error) => { + if (process.env.TARO_ENV === 'h5') { + setProfile(previewProfile) + setForm(toEditableProfile(previewProfile)) + setRegionCodes(findRegionCodes(previewProfile)) + return + } + Taro.showToast({ title: errorMessage(error, '个人信息加载失败'), icon: 'none' }) + }) + .finally(() => setLoading(false)) }, []) + const useAvatar = (filePath: string) => { + if (!filePath) return + setAvatarPreview(filePath) + setPendingAvatar(filePath) + setAvatarSheetOpen(false) + } + + const chooseFrom = async (sourceType: 'album' | 'camera') => { + try { + const result = await Taro.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: [sourceType], + sizeType: ['compressed'] + }) + useAvatar(result.tempFiles[0]?.tempFilePath || '') + } catch (error) { + const message = errorMessage(error, '') + if (message && !message.toLowerCase().includes('cancel')) { + Taro.showToast({ title: '头像选择失败', icon: 'none' }) + } + } + } + + const handleWechatAvatar = (event: { detail: { avatarUrl: string } }) => { + useAvatar(event.detail.avatarUrl) + } + const save = async () => { - await api.updateProfile(form) - Taro.showToast({ title: '已开启吉运', icon: 'none' }) - Taro.navigateBack() + const nickname = form.nickname.trim() + if (!nickname) { + Taro.showToast({ title: '请输入昵称', icon: 'none' }) + return + } + if (!form.gender || !form.birthday || !form.birthHour || !form.province || !form.city || !form.district) { + Taro.showToast({ title: '请完整填写个人信息', icon: 'none' }) + return + } + setSaving(true) + try { + await api.updateProfile({ ...form, nickname }) + if (pendingAvatar) { + await api.uploadAvatar(pendingAvatar) + } + Taro.showToast({ title: '修改成功', icon: 'success' }) + setTimeout(() => Taro.navigateBack(), 500) + } catch (error) { + Taro.showToast({ title: errorMessage(error, '保存失败,请重试'), icon: 'none' }) + } finally { + setSaving(false) + } + } + + const birthHour = hours.find((item) => item.name === form.birthHour) || hours[0] + const dateParts = form.birthday ? form.birthday.split('-') : ['', '', ''] + const today = new Date().toISOString().slice(0, 10) + const provinceEntries = regionEntries('86') + const provinceCode = regionCodes[0] || provinceEntries[0]?.[0] || '' + const cityEntries = regionEntries(provinceCode) + const cityCode = regionCodes[1] || cityEntries[0]?.[0] || '' + const districtEntries = regionEntries(cityCode) + const districtCode = regionCodes[2] || districtEntries[0]?.[0] || '' + const regionPickerValue = [ + Math.max(0, provinceEntries.findIndex(([code]) => code === provinceCode)), + Math.max(0, cityEntries.findIndex(([code]) => code === cityCode)), + Math.max(0, districtEntries.findIndex(([code]) => code === districtCode)) + ] + + const changeRegionColumn = (column: number, value: number) => { + if (column === 0) { + const nextProvinceCode = provinceEntries[value]?.[0] || provinceEntries[0]?.[0] || '' + const nextCities = regionEntries(nextProvinceCode) + const nextCityCode = nextCities[0]?.[0] || '' + const nextDistrictCode = regionEntries(nextCityCode)[0]?.[0] || '' + setRegionCodes([nextProvinceCode, nextCityCode, nextDistrictCode]) + return + } + if (column === 1) { + const nextCityCode = cityEntries[value]?.[0] || cityEntries[0]?.[0] || '' + setRegionCodes([provinceCode, nextCityCode, regionEntries(nextCityCode)[0]?.[0] || '']) + return + } + setRegionCodes([provinceCode, cityCode, districtEntries[value]?.[0] || districtEntries[0]?.[0] || '']) + } + + const confirmRegion = (values: number[]) => { + const selectedProvince = provinceEntries[values[0]] || provinceEntries[0] + const selectedCities = regionEntries(selectedProvince?.[0] || '') + const selectedCity = selectedCities[values[1]] || selectedCities[0] + const selectedDistricts = regionEntries(selectedCity?.[0] || '') + const selectedDistrict = selectedDistricts[values[2]] || selectedDistricts[0] + if (!selectedProvince || !selectedCity || !selectedDistrict) return + const cityName = selectedCity[1] === '市辖区' ? selectedProvince[1] : selectedCity[1] + setRegionCodes([selectedProvince[0], selectedCity[0], selectedDistrict[0]]) + setForm({ + ...form, + province: selectedProvince[1], + city: cityName, + district: selectedDistrict[1] + }) } return ( - - - 💧水 - - setForm({ ...form, nickname: String(e.detail.value) })} /> - ✎ - - - {['男', '女'].map((gender) => ( - - ))} - - 选择你的出生日期(公历) - setForm({ ...form, birthday: String(e.detail.value) })}> - - {(form.birthday || '2008-12-28').split('-').map((part, idx) => ( - {part} - ))} + + + 昵称 + setForm({ ...form, nickname: String(event.detail.value) })} + /> + {form.nickname.length}/20 + + + + {[ + { value: '男', symbol: '♂' }, + { value: '女', symbol: '♀' } + ].map((item) => ( + + ))} + + + setForm({ ...form, birthday: String(event.detail.value) })}> + + 出生日期 + + {form.birthday ? `${dateParts[0]} 年 ${dateParts[1]} 月 ${dateParts[2]} 日` : '请选择'} + + › + + + + `${item.name}(${item.range})`)} + value={Math.max(0, hours.findIndex((item) => item.name === form.birthHour))} + onChange={(event) => setForm({ ...form, birthHour: hours[Number(event.detail.value)].name })} + > + + 出生时辰 + + {form.birthHour ? `${birthHour.name}(${birthHour.range})` : '请选择'} + + › + + + + name), + cityEntries.map(([, name]) => name === '市辖区' ? form.province || '市辖区' : name), + districtEntries.map(([, name]) => name) + ]} + value={regionPickerValue} + onColumnChange={(event) => changeRegionColumn(Number(event.detail.column), Number(event.detail.value))} + onCancel={() => setRegionCodes(findRegionCodes(form))} + onChange={(event) => confirmRegion(event.detail.value as number[])} + > + + 出生地点 + + {form.province ? `${form.province} - ${form.city} - ${form.district}` : '请选择'} + + › + + + + + 半年内只能修改{profile?.remainingEdits ?? 0}次哦 - - setForm({ ...form, birthHour: hours[Number(e.detail.value)] })}> - {form.birthHour || '寅时'} 时 - - 选择你的出生地 - { - const values = e.detail.value as number[] - setForm({ ...form, province: regions[0][values[0]], city: regions[1][values[1]], district: regions[2][values[2]] }) - }} - > - - {form.province || '河南省'} ▼ - {form.city || '洛阳市'} ▼ - {form.district || '老城区'} ▼ + )} + + {avatarSheetOpen && ( + setAvatarSheetOpen(false)}> + event.stopPropagation()}> + + + + + + - - - 半年内只能修改五次哦 + )} ) } diff --git a/src/services/frontendPreview.ts b/src/services/frontendPreview.ts new file mode 100644 index 0000000..a6670eb --- /dev/null +++ b/src/services/frontendPreview.ts @@ -0,0 +1,21 @@ +import type { UserProfile } from './request' + +export function isFrontendPreview(): boolean { + return process.env.TARO_ENV === 'h5' + && typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('preview') === '1' +} + +export const frontendPreviewProfile: UserProfile = { + id: 0, + nickname: 'You', + gender: '男', + birthday: '1998-06-18', + birthHour: '辰时', + province: '河南省', + city: '洛阳市', + district: '洛龙区', + points: 0, + remainingEdits: 5, + elementReport: { scores: [], favorable: '', unfavorable: '', dayMaster: '' } +} diff --git a/src/services/request.ts b/src/services/request.ts index d248a20..0dd7e72 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -1,4 +1,5 @@ import Taro from '@tarojs/taro' +import { frontendPreviewProfile, isFrontendPreview } from './frontendPreview' const BASE_URL = 'http://localhost:8080' @@ -11,6 +12,12 @@ type ApiResult = { type RequestOptions = Omit export async function request(url: string, options: RequestOptions = {}): Promise { + if (isFrontendPreview()) { + if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) { + return { ...frontendPreviewProfile, elementReport: { ...frontendPreviewProfile.elementReport, scores: [] } } as T + } + throw new Error('纯前端预览暂不请求后端或保存修改') + } const token = Taro.getStorageSync('token') const headers = { 'content-type': 'application/json', @@ -28,6 +35,34 @@ export async function request(url: string, options: RequestOptions = {}): Pro return res.data.data } +export function resolveAssetUrl(url?: string) { + if (!url) return '' + return /^https?:\/\//.test(url) || url.startsWith('wxfile://') || url.startsWith('http://tmp/') + ? url + : `${BASE_URL}${url.startsWith('/') ? url : `/${url}`}` +} + +async function uploadAvatar(filePath: string): Promise { + if (isFrontendPreview()) throw new Error('纯前端预览暂不上传头像或保存修改') + const token = Taro.getStorageSync('token') + const response = await Taro.uploadFile({ + url: `${BASE_URL}/api/user/avatar`, + filePath, + name: 'file', + header: token ? { Authorization: `Bearer ${token}` } : {} + }) + let result: ApiResult + try { + result = JSON.parse(response.data) as ApiResult + } catch (error) { + throw new Error('头像上传失败') + } + if (result.code !== 0) { + throw new Error(result.message || '头像上传失败') + } + return result.data +} + export const api = { wxLogin: (code: string) => request<{ sessionKey: string; phoneBound: boolean }>('/api/auth/wx-login', { method: 'POST', @@ -46,6 +81,7 @@ export const api = { method: 'PUT', data }), + uploadAvatar, exchangeStatus: () => request<{ points: number; available: boolean; message: string }>('/api/points/exchange-status'), agreement: (type: string) => request<{ title: string; body: string }>(`/api/content/agreement/${type}`), follow: () => request>('/api/content/follow'), @@ -66,6 +102,7 @@ export type ElementScore = { export type UserProfile = { id: number + avatarUrl?: string nickname: string phone?: string gender: string diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts index 89d74bf..9a46ffa 100644 --- a/src/types/assets.d.ts +++ b/src/types/assets.d.ts @@ -7,3 +7,8 @@ declare module '*.png' { const src: string export default src } + +declare module 'china-area-data' { + const data: Record> + export default data +} diff --git a/src/utils/fiveElements.ts b/src/utils/fiveElements.ts index aee6487..a6f6413 100644 --- a/src/utils/fiveElements.ts +++ b/src/utils/fiveElements.ts @@ -1,90 +1,158 @@ -import { Solar } from 'lunar-typescript' -import { ElementScore } from '../services/request' - -type ElementName = '木' | '火' | '土' | '金' | '水' +import { LunarUtil, Solar } from 'lunar-typescript' +import type { ElementScore } from '../services/request' +export type ElementName = '木' | '火' | '土' | '金' | '水' +export type Pillars = [string, string, string, string] export type FiveElementReport = { scores: ElementScore[] favorable: string unfavorable: string dayMaster: string + masterElement: ElementName + pillars: Pillars + strength: '偏弱' | '相对平衡' | '偏强' + strengthScore: number + analysis: { + season: { supported: boolean; score: number; description: string } + roots: { supported: boolean; score: number; description: string } + support: { supported: boolean; score: number; description: string } + } } -const stemElements: Record = { +const stems: Record = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' } - const branchElements: Record = { 子: '水', 丑: '土', 寅: '木', 卯: '木', 辰: '土', 巳: '火', 午: '火', 未: '土', 申: '金', 酉: '金', 戌: '土', 亥: '水' } - -const elementMeta: Record = { - 木: { color: '#36b85b' }, - 火: { color: '#ef5a58' }, - 土: { color: '#b46a18' }, - 金: { color: '#d59a05' }, - 水: { color: '#35bfe8' } +const colors: Record = { + 木: '#36b85b', 火: '#ef5a58', 土: '#b46a18', 金: '#d59a05', 水: '#35bfe8' } - const scoreOrder: ElementName[] = ['土', '金', '水', '木', '火'] -const generationOrder: ElementName[] = ['木', '火', '土', '金', '水'] +const cycle: ElementName[] = ['木', '火', '土', '金', '水'] +const emptyWeights = (): Record => ({ 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 }) -const hourFromBirthHour = (birthHour: string) => { - const numericHour = Number.parseInt(birthHour, 10) - if (Number.isFinite(numericHour)) return Math.max(0, Math.min(23, numericHour)) - - const branchHours: Record = { - 子: 23, 丑: 1, 寅: 3, 卯: 5, 辰: 7, 巳: 9, - 午: 11, 未: 13, 申: 15, 酉: 17, 戌: 19, 亥: 21 - } - return branchHours[birthHour.slice(0, 1)] ?? 12 +// 本项目普通扶抑规则 v1,权重为产品约定,不代表各命理流派的统一标准。 +// 月令:旺/相/休/囚/死分别取 100/80/50/30/15 分,辰戌丑未按土月简化。 +const seasonScore = (element: ElementName, season: ElementName) => { + const relation = (cycle.indexOf(element) - cycle.indexOf(season) + 5) % 5 + return [100, 80, 15, 30, 50][relation] } -export function calculateFiveElements(birthday: string, birthHour: string): FiveElementReport { - const date = new Date(`${birthday}T00:00:00`) - if (Number.isNaN(date.getTime())) { - return { - scores: scoreOrder.map((name) => ({ name, color: elementMeta[name].color, value: 20 })), - favorable: '金水', - unfavorable: '木火土', - dayMaster: '壬' - } +const hiddenStems = (branch: string): Array<{ stem: string; share: number }> => { + const hidden = LunarUtil.ZHI_HIDE_GAN[branch] || [] + const shares = hidden.length === 1 ? [1] : hidden.length === 2 ? [0.7, 0.3] : [0.6, 0.3, 0.1] + return hidden.map((stem, index) => ({ stem, share: shares[index] })) +} + +// 最大余数法保证展示百分比之和为 100,零值仍保持为零。 +const toScores = (weights: Record): ElementScore[] => { + const total = Object.values(weights).reduce((sum, value) => sum + value, 0) + const entries = scoreOrder.map((name) => { + const exact = weights[name] / total * 100 + return { name, value: Math.floor(exact), remainder: exact - Math.floor(exact) } + }) + const remaining = 100 - entries.reduce((sum, item) => sum + item.value, 0) + const ranked = [...entries].sort((a, b) => b.remainder - a.remainder) + for (let index = 0; index < remaining; index++) ranked[index].value++ + return entries.map(({ name, value }) => ({ name, value, color: colors[name] })) +} + +export function analyzePillars(pillars: Pillars): FiveElementReport { + if (pillars.some((pillar) => pillar.length !== 2 || !stems[pillar[0]] || !branchElements[pillar[1]])) { + throw new Error('四柱数据不完整') } + const dayMaster = pillars[2][0] + const masterElement = stems[dayMaster] + const masterIndex = cycle.indexOf(masterElement) + const resource = cycle[(masterIndex + 4) % 5] + const season = branchElements[pillars[1][1]] + const weights = emptyWeights() + const surrounding = emptyWeights() + let rootPower = 0 + const rootBranches: string[] = [] - const solar = Solar.fromYmdHms( - date.getFullYear(), - date.getMonth() + 1, - date.getDate(), - hourFromBirthHour(birthHour), - 0, - 0 - ) - const eightChar = solar.getLunar().getEightChar() - const pillars = [eightChar.getYear(), eightChar.getMonth(), eightChar.getDay(), eightChar.getTime()] - const weights: Record = { 木: 0, 火: 0, 土: 0, 金: 0, 水: 0 } - - pillars.forEach((pillar) => { - const [stem, branch] = pillar - if (stemElements[stem]) weights[stemElements[stem]] += 2 - if (branchElements[branch]) weights[branchElements[branch]] += 1 + pillars.forEach((pillar, index) => { + const stemElement = stems[pillar[0]] + weights[stemElement] += 10 + // 得势不把日干自身计入生扶比例,避免自己支持自己的循环判断。 + if (index !== 2) surrounding[stemElement] += 10 + const branchWeight = index === 1 ? 18 : 10 + const hidden = hiddenStems(pillar[1]) + const roots = hidden.filter(({ stem }) => stems[stem] === masterElement) + if (roots.length) rootBranches.push(pillar[1]) + rootPower += roots.reduce((sum, { share }) => sum + share, 0) + hidden.forEach(({ stem, share }) => { + weights[stems[stem]] += branchWeight * share + surrounding[stems[stem]] += branchWeight * share + }) }) - const total = Object.values(weights).reduce((sum, value) => sum + value, 0) - const scores = scoreOrder.map((name) => ({ - name, - color: elementMeta[name].color, - value: Math.round((weights[name] / total) * 100) - })) - const dayMaster = eightChar.getDay().slice(0, 1) - const masterElement = stemElements[dayMaster] || '水' - const masterIndex = generationOrder.indexOf(masterElement) - const resourceElement = generationOrder[(masterIndex + generationOrder.length - 1) % generationOrder.length] - const unfavorable = generationOrder.filter((element) => element !== resourceElement && element !== masterElement).join('') + const seasonValue = seasonScore(masterElement, season) + // 两个完整同类根视为根气充分;藏干按 1、7:3 或 6:3:1 分配根气。 + const rootValue = Math.min(100, rootPower / 2 * 100) + const surroundingTotal = Object.values(surrounding).reduce((sum, value) => sum + value, 0) + const supportValue = (surrounding[masterElement] + surrounding[resource]) / surroundingTotal * 100 + const strengthScore = Math.round(seasonValue * 0.4 + rootValue * 0.3 + supportValue * 0.3) + const strength = strengthScore < 45 ? '偏弱' : strengthScore > 55 ? '偏强' : '相对平衡' + const favorable = strength === '偏弱' + ? `${resource}${masterElement}` + : strength === '偏强' + ? [cycle[(masterIndex + 1) % 5], cycle[(masterIndex + 2) % 5], cycle[(masterIndex + 3) % 5]].join('') + : '' + const unfavorable = strength === '相对平衡' ? '' : cycle.filter((element) => !favorable.includes(element)).join('') return { - scores, - favorable: `${resourceElement}${masterElement}`, - unfavorable, - dayMaster + scores: toScores(weights), favorable, unfavorable, dayMaster, masterElement, pillars, + strength, strengthScore, + analysis: { + season: { + supported: seasonValue >= 80, + score: seasonValue, + description: `${pillars[1][1]}月按${season}气判断,${masterElement}${seasonValue >= 80 ? '得到月令支持' : '月令支持较少'}。` + }, + roots: { + supported: rootPower > 0, + score: Math.round(rootValue), + description: rootBranches.length + ? `四支中${rootBranches.join('、')}的藏干有${masterElement}根,根气评分${Math.round(rootValue)}。` + : `四支藏干未见${masterElement}同类根。` + }, + support: { + supported: supportValue >= 50, + score: Math.round(supportValue), + description: `除日干外,${resource}与${masterElement}的生扶力量占${Math.round(supportValue)}%。` + } + } + } +} + +const hourFromBirthHour = (birthHour: string): number | null => { + const branchHours: Record = { + 子: 23, 丑: 1, 寅: 3, 卯: 5, 辰: 7, 巳: 9, 午: 11, 未: 13, 申: 15, 酉: 17, 戌: 19, 亥: 21 + } + if (Object.prototype.hasOwnProperty.call(branchHours, birthHour)) return branchHours[birthHour] + if (/^[子丑寅卯辰巳午未申酉戌亥]时$/.test(birthHour)) return branchHours[birthHour[0]] + if (/^\d{1,2}$/.test(birthHour)) { + const hour = Number(birthHour) + return hour <= 23 ? hour : null + } + return null +} + +export function calculateFiveElements(birthday: string, birthHour: string): FiveElementReport | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(birthday || '')) return null + const [year, month, day] = birthday.split('-').map(Number) + const hour = hourFromBirthHour(birthHour || '') + if (hour === null || year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1) return null + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() + if (day > daysInMonth) return null + try { + // 直接使用公历字段,避免浏览器时区/日期字符串解析影响结果。 + const eightChar = Solar.fromYmdHms(year, month, day, hour, 0, 0).getLunar().getEightChar() + eightChar.setSect(2) // 沿用历法库默认:子初不换日,日柱按零点换日。 + return analyzePillars([eightChar.getYear(), eightChar.getMonth(), eightChar.getDay(), eightChar.getTime()]) + } catch { + return null } } diff --git a/src/utils/homeOverview.ts b/src/utils/homeOverview.ts new file mode 100644 index 0000000..a23bb0f --- /dev/null +++ b/src/utils/homeOverview.ts @@ -0,0 +1,140 @@ +import { Solar } from 'lunar-typescript' +import type { DimensionItem, HomeOverview, UserProfile } from '../services/request' +import { calculateFiveElements, ElementName, FiveElementReport } from './fiveElements' +import { calculateZodiac, zodiacNames } from './zodiac' +import { getZodiacRelation } from './zodiacWeekly' + +type DimensionId = 'emotion' | 'health' | 'life' | 'wealth' | 'social' | 'study' | 'career' | 'travel' +type DimensionDefinition = { id: DimensionId; title: string; icon: string; element: ElementName; activities: string[]; positive: [string, string][]; cautious: [string, string][] } +export type GeneratedDimension = DimensionItem & { score: number } +export type GeneratedHomeOverview = Omit & { dimensions: GeneratedDimension[] } +const cycle: ElementName[] = ['木', '火', '土', '金', '水'] +const stems: Record = { 甲: '木', 乙: '木', 丙: '火', 丁: '火', 戊: '土', 己: '土', 庚: '金', 辛: '金', 壬: '水', 癸: '水' } +const branches = [...'子丑寅卯辰巳午未申酉戌亥'] +const relationPoints = { 六合: 2, 三合: 1, 六冲: -2, 相害: -1, 平常: 0 } +const definitions: DimensionDefinition[] = [ + { id: 'emotion', title: '情感', icon: 'heart', element: '火', activities: ['嫁娶', '订盟', '纳采'], + positive: [['默契收集,认真倾听', '留一点时间给在意的人,认真听完对方的话,也表达自己的感受。'], ['温柔表达,分享日常', '从一件小事聊起,让轻松的分享成为彼此理解的机会。']], + cautious: [['放慢表达,少些猜测', '遇到分歧先确认对方的意思,避免用猜测代替沟通。'], ['留出空间,不急回应', '不必急着为关系下结论,先整理感受,再选择合适的表达方式。']] }, + { id: 'health', title: '健康', icon: 'gourd', element: '木', activities: ['沐浴', '解除'], + positive: [['留白休息,照顾节奏', '给日程留出休息空隙,选择让自己感到舒适的活动。'], ['规律作息,轻松生活', '合理安排工作与休息,用平常心照顾自己的日常节奏。']], + cautious: [['减少透支,适度休息', '行程尽量安排宽松一些,避免为了赶进度不断压缩休息。'], ['放下紧绷,量力而行', '适当减少不必要的忙碌,不用勉强自己完成所有安排。']] }, + { id: 'life', title: '生活', icon: 'vase', element: '土', activities: ['入宅', '移徙', '安床', '扫舍', '修造'], + positive: [['整理日常,留住从容', '从一处小空间开始整理,为生活腾出一点轻松与秩序。'], ['收纳旧物,珍惜日常', '梳理身边物品,留下真正需要和喜欢的部分,不必一次完成。']], + cautious: [['少些堆叠,做好取舍', '先处理手头的小事,避免同时开启太多整理或采购计划。'], ['简化安排,稳住节奏', '把复杂的家务拆成小步骤,逐项完成比匆忙赶工更从容。']] }, + { id: 'wealth', title: '财富', icon: 'coin', element: '金', activities: ['交易', '纳财', '开市', '立券'], + positive: [['梳理预算,按需安排', '核对日常收支和购物清单,让消费围绕实际需求展开。'], ['清点账目,有序消费', '整理订阅与账单,关注自己真正会使用的物品和服务。']], + cautious: [['勿乱囤货,按需消费', '面对促销先看清需求与总价,给非必要购买留一点考虑时间。'], ['核对清单,避免冲动', '先确认已有物品和预算,再决定是否添置,不急于跟随热度。']] }, + { id: 'social', title: '社交', icon: 'fan', element: '火', activities: ['会亲友', '宴会', '订盟'], + positive: [['友好交流,真诚回应', '把注意力放在真诚的交流上,适时表达感谢与关心。'], ['主动问候,轻松相聚', '选择让双方都舒适的话题和时间,为相处保留自然的空间。']], + cautious: [['勿涉纷争,尊重边界', '不必急着评判他人的是非,先确认事实,保持清楚的边界。'], ['少些争辩,多些理解', '意见不同时可以暂缓讨论,不让一时的情绪替自己作答。']] }, + { id: 'study', title: '学业', icon: 'scroll', element: '水', activities: ['入学', '习艺'], + positive: [['专注积累,温故知新', '挑选一个清晰的小目标,结合复习和练习检验理解。'], ['循序探索,整理笔记', '把新知识与已有内容连起来,留下自己的问题与总结。']], + cautious: [['回到基础,不急跳级', '遇到难点先检查基础概念,不用靠加快进度掩盖疑问。'], ['减少分心,逐项练习', '收起暂时无关的信息,把注意力留给眼前的一项学习任务。']] }, + { id: 'career', title: '事业', icon: 'bamboo', element: '木', activities: ['赴任', '开市', '立券'], + positive: [['清晰协作,稳步推进', '先对齐目标和分工,再推进关键任务,为沟通留足时间。'], ['认真落实,积累进展', '聚焦一项可以交付的小成果,让行动形成清晰的进度。']], + cautious: [['认真核对,避免敷衍', '提交前检查要求和细节,遇到不明确的地方及时确认。'], ['收拢任务,先稳后快', '把任务按轻重缓急排好,避免为了同时推进而忽略细节。']] }, + { id: 'travel', title: '出行', icon: 'horse', element: '水', activities: ['出行', '移徙'], + positive: [['轻装安排,从容出发', '提前整理路线和随身物品,为换乘与休息留出余地。'], ['规划路线,享受沿途', '出发前确认时间与目的地,选择适合自己节奏的行程。']], + cautious: [['勿负重行,检查安排', '精简随身物品,出发前再次确认路线、时间和必要证件。'], ['留足余量,不必赶路', '为临时调整留出时间,行程有变化时先重新确认安排。']] } +] +export const HOME_DIMENSION_IDS = definitions.map((item) => item.id) + +export function normalizeDimensionOrder(value: unknown): string[] { + const saved = Array.isArray(value) ? value.filter((id): id is DimensionId => HOME_DIMENSION_IDS.includes(id)) : [] + return [...new Set([...saved, ...HOME_DIMENSION_IDS])] +} + +const palette: Record = { + 木: { colors: [{ name: '青竹绿', value: '#9fbb81' }, { name: '绿茶色', value: '#a7ad42' }], numbers: ['3', '8'] }, + 火: { colors: [{ name: '珊瑚粉', value: '#ef9ca1' }, { name: '暖橘色', value: '#edaf73' }], numbers: ['2', '7'] }, + 土: { colors: [{ name: '暖杏色', value: '#eac58e' }, { name: '麦芽色', value: '#d6b887' }], numbers: ['5', '0'] }, + 金: { colors: [{ name: '月白色', value: '#f4eede' }, { name: '浅金色', value: '#e8d5a7' }], numbers: ['4', '9'] }, + 水: { colors: [{ name: '瀑布蓝', value: '#58bfce' }, { name: '雾霭蓝', value: '#8eb4d6' }], numbers: ['1', '6'] } +} + +function elementSupport(source: ElementName, target: ElementName): number { + const delta = (cycle.indexOf(target) - cycle.indexOf(source) + 5) % 5 + return delta === 0 || delta === 1 ? 6 : delta === 2 ? -6 : 0 +} +const preference = (report: FiveElementReport, element: ElementName, weight: number) => { + const share = report.scores.find((score) => score.name === element)?.value || 0 + // 先由扶抑确定方向,再在该方向内按个人五行份额小幅调整;不是缺什么就补什么。 + if (report.favorable.includes(element)) return weight + Math.round(Math.max(0, 20 - share) / 5) + if (report.unfavorable.includes(element)) return -weight - Math.round(Math.max(0, share - 20) / 5) + return 0 +} +const clamp = (value: number) => Math.min(90, Math.max(15, value)) +const statusFor = (score: number) => score >= 80 ? '大吉' : score >= 65 ? '小吉' : score >= 50 ? '平日' : score >= 35 ? '小逆' : '谨慎' +const formatHourEnd = (end: string) => { + const [hour, minute] = end.split(':').map(Number) + const total = hour * 60 + minute + 1 + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}` +} + +function calculateDay(solar: Solar, report: FiveElementReport, birthBranch: string) { + const lunar = solar.getLunar() + const dayElement = stems[lunar.getDayGan()] + const relation = getZodiacRelation(birthBranch, lunar.getDayZhi()) + const commonScore = 50 + preference(report, dayElement, 10) + relationPoints[relation] * 5 + const yi = lunar.getDayYi() + const ji = lunar.getDayJi() + const dimensions: GeneratedDimension[] = definitions.map((definition) => { + const discouraged = definition.activities.some((activity) => ji.includes(activity)) + const encouraged = definition.activities.some((activity) => yi.includes(activity)) + const calendarPoints = discouraged ? -10 : encouraged ? 10 : 0 + const score = clamp(commonScore + preference(report, definition.element, 8) + elementSupport(dayElement, definition.element) + calendarPoints) + const suitable = score >= 50 + const variants = suitable ? definition.positive : definition.cautious + const [headline, detail] = variants[lunar.getDayGanIndex() % variants.length] + return { id: definition.id, title: definition.title, icon: definition.icon, headline, detail, suitable, score } + }) + const overallIndex = Math.round(dimensions.reduce((sum, item) => sum + item.score, 0) / dimensions.length) + const ranked = [...dimensions].sort((a, b) => b.score - a.score) + const status = statusFor(overallIndex) + const summary = ranked[0].score === ranked[ranked.length - 1].score + ? `${status}之日,节奏相对均衡,按计划推进,也给自己留出余地。` + : `${status}之日,可关注${ranked[0].title}安排,${ranked[ranked.length - 1].title}方面多留意细节。` + + const preferred = report.favorable + ? [...report.favorable] as ElementName[] + : [...report.scores].sort((a, b) => a.value - b.value).slice(0, 2).map((score) => score.name as ElementName) + const luckyElements = [...preferred].sort((a, b) => elementSupport(dayElement, b) - elementSupport(dayElement, a)).slice(0, 2) + const alternate = lunar.getDayGanIndex() % 2 + const luckyColors = luckyElements.map((element) => palette[element].colors[alternate]) + const luckyNumbers = luckyElements.map((element) => palette[element].numbers[lunar.getDayZhiIndex() % 2]) + const hours = lunar.getTimes() + .map((hour, index) => ({ hour, index, score: preference(report, stems[hour.getGan()], 2) + relationPoints[getZodiacRelation(birthBranch, hour.getZhi())] })) + .filter(({ hour }) => hour.getTianShenLuck() === '吉') + .sort((a, b) => b.score - a.score || a.index - b.index) + const seen = new Set() + const luckyHours = hours.filter(({ hour }) => { + if (seen.has(hour.getZhi())) return false + seen.add(hour.getZhi()) + return true + }).slice(0, 2).map(({ hour }) => ({ branch: hour.getZhi(), time: `${hour.getMinHm()}–${formatHourEnd(hour.getMaxHm())}` })) + return { + date: solar.toYmd(), weekName: `星期${'日一二三四五六'[solar.getWeek()]}`, + lunarDate: `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`, + status, summary, overallIndex, luckyColors, luckyNumbers, luckyDirection: lunar.getDayPositionXiDesc(), luckyHours, dimensions + } +} + +export function generateHomeOverview(profile: Pick, today: string, offset: -1 | 0 | 1 = 0): GeneratedHomeOverview | null { + const report = calculateFiveElements(profile.birthday, profile.birthHour) + const zodiac = calculateZodiac(profile.birthday) + if (!report || !zodiac || !/^\d{4}-\d{2}-\d{2}$/.test(today)) return null + const [year, month, day] = today.split('-').map(Number) + if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return null + if (![-1, 0, 1].includes(offset)) return null + try { + const base = Solar.fromYmd(year, month, day) + const birthBranch = branches[zodiacNames.indexOf(zodiac)] + const reports = [-1, 0, 1].map((index) => calculateDay(base.next(index), report, birthBranch)) + const labels = ['昨日', '今日', '明日'] as const + const selected = reports[offset + 1] + return { ...selected, activeDay: labels[offset + 1], days: reports.map((item, index) => ({ label: labels[index], date: item.date, weekName: item.weekName, lunarDate: item.lunarDate, status: item.status })) } + } catch { + return null + } +} diff --git a/src/utils/zodiac.ts b/src/utils/zodiac.ts new file mode 100644 index 0000000..c8f69bd --- /dev/null +++ b/src/utils/zodiac.ts @@ -0,0 +1,18 @@ +import { Solar } from 'lunar-typescript' + +export const zodiacNames = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'] as const +export type ZodiacName = typeof zodiacNames[number] + +// 日常生肖按农历正月初一换年,不能使用公历年份或立春年柱代替。 +export function calculateZodiac(birthday: string): ZodiacName | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(birthday || '')) return null + const [year, month, day] = birthday.split('-').map(Number) + if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1) return null + if (day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return null + try { + const zodiac = Solar.fromYmd(year, month, day).getLunar().getYearShengXiao() + return zodiacNames.includes(zodiac as ZodiacName) ? zodiac as ZodiacName : null + } catch { + return null + } +} diff --git a/src/utils/zodiacWeekly.ts b/src/utils/zodiacWeekly.ts new file mode 100644 index 0000000..295435c --- /dev/null +++ b/src/utils/zodiacWeekly.ts @@ -0,0 +1,89 @@ +import { LunarUtil, Solar } from 'lunar-typescript' +import { calculateZodiac, zodiacNames, ZodiacName } from './zodiac' + +export type ZodiacRelation = '六合' | '三合' | '六冲' | '相害' | '平常' +export type ZodiacWeeklyDay = { + date: string + label: string + weekday: string + branch: string + relation: ZodiacRelation + score: number +} +export type ZodiacWeeklyReport = { + zodiac: ZodiacName + branch: string + weekStart: string + weekEnd: string + score: number + status: '顺势' | '平稳' | '谨慎' + tone: 'positive' | 'neutral' | 'cautious' + summary: string + days: ZodiacWeeklyDay[] + supportiveDays: ZodiacWeeklyDay[] + cautiousDays: ZodiacWeeklyDay[] +} + +export const ZODIAC_WEEKLY_RULES = '六合 +2,三合同组 +1,六冲 −2,相害 −1,其余 0;七日合计 ≥2 为顺势,≤−2 为谨慎,其余为平稳。' +const BEIJING_OFFSET = 8 * 60 * 60 * 1000 +const branches = LunarUtil.ZHI.slice(1) +const threeHarmonyGroups = ['申子辰', '亥卯未', '寅午戌', '巳酉丑'] +const harmPairs = ['子未', '丑午', '寅巳', '卯辰', '申亥', '酉戌'] +const relationScores: Record = { 六合: 2, 三合: 1, 六冲: -2, 相害: -1, 平常: 0 } +const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] + +// 用UTC字段读取UTC+8日期,不依赖设备时区,也不依赖浏览器Intl能力。 +export function getBeijingDate(now = new Date()): string { + if (!Number.isFinite(now.getTime())) return '' + return new Date(now.getTime() + BEIJING_OFFSET).toISOString().slice(0, 10) +} + +export function getZodiacRelation(birthBranch: string, dayBranch: string): ZodiacRelation { + const index = branches.indexOf(birthBranch) + if (index < 0 || !branches.includes(dayBranch)) throw new Error('无效的地支') + if (LunarUtil.HE_ZHI_6[index] === dayBranch) return '六合' + if (LunarUtil.CHONG[index] === dayBranch) return '六冲' + if (harmPairs.some((pair) => pair.includes(birthBranch) && pair.includes(dayBranch)) && birthBranch !== dayBranch) return '相害' + // 只表示同组三合的助力关系,两个地支不等于完整三合成局。 + if (birthBranch !== dayBranch && threeHarmonyGroups.some((group) => group.includes(birthBranch) && group.includes(dayBranch))) return '三合' + return '平常' +} + +export function calculateWeeklyZodiacFortune(birthday: string, beijingDate = getBeijingDate()): ZodiacWeeklyReport | null { + const zodiac = calculateZodiac(birthday) + if (!zodiac || !/^\d{4}-\d{2}-\d{2}$/.test(beijingDate)) return null + const [year, month, day] = beijingDate.split('-').map(Number) + if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) return null + try { + const current = Solar.fromYmd(year, month, day) + const monday = current.next(-((current.getWeek() + 6) % 7)) + const branch = branches[zodiacNames.indexOf(zodiac)] + const days = weekdays.map((weekday, index): ZodiacWeeklyDay => { + const solar = monday.next(index) + const dayBranch = solar.getLunar().getDayZhi() + const relation = getZodiacRelation(branch, dayBranch) + return { + date: solar.toYmd(), + label: `${solar.getMonth()}/${solar.getDay()}`, + weekday, branch: dayBranch, relation, score: relationScores[relation] + } + }) + const score = days.reduce((sum, item) => sum + item.score, 0) + const supportiveDays = days.filter((item) => item.score > 0) + const cautiousDays = days.filter((item) => item.score < 0) + const status = score >= 2 ? '顺势' : score <= -2 ? '谨慎' : '平稳' + const recommendation = score >= 2 + ? '助力关系相对较多,可优先在助力日安排沟通与协作;遇到留意日仍宜放慢节奏。' + : score <= -2 + ? '需要留意的关系相对较多,宜提前确认安排、耐心沟通;助力日可用于协调与收尾。' + : '合冲影响整体接近平衡,宜按计划推进,在助力日多做协调,在留意日注意细节。' + return { + zodiac, branch, weekStart: days[0].date, weekEnd: days[6].date, score, status, + tone: score >= 2 ? 'positive' : score <= -2 ? 'cautious' : 'neutral', + summary: `本周有${supportiveDays.length}个助力日、${cautiousDays.length}个留意日。${recommendation}`, + days, supportiveDays, cautiousDays + } + } catch { + return null + } +}