From 50975e062706760cff6508bb7b8af9375f9fbb92 Mon Sep 17 00:00:00 2001 From: gouki Date: Sun, 9 Aug 2026 23:21:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(bazi):=20=E5=85=AB=E5=AD=97=E9=A1=B5?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E2=80=94=E2=80=94=E7=A9=BA=E6=80=81=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E6=8E=92=E7=9B=98+=E5=AD=97=E6=AE=B5=E6=9C=AF?= =?UTF-8?q?=E8=AF=AD=E8=A1=A8+=E6=96=87=E5=8C=96=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=EF=BC=8C=E7=94=9F=E8=BE=B0=E6=A1=A3=E6=A1=88=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 八字页:未绑定生辰时展示虚构示例排盘(1990-08-08 10:30男)与15条字段说明, 附传统文化声明文案;已绑定时支持多档案切换,排盘增强(十神/藏干/纳音/五行配色/命盘概览) - 个人中心:新增生辰管理卡片,每用户免费绑定3个出生年月;登录改为真实微信登录 - 服务端:新增 BirthProfile 模型与 /api/user/birth-profiles 增删查接口,AutoMigrate 建表 - 前端:新增 birth-profile.js 本地存储优先+登录后云端双向同步 - 修复:个人中心 tabBar 页面入口误用 navigateTo 改为 switchTab --- mini/pages/bazi/bazi.js | 185 ++++++++++++++------- mini/pages/bazi/bazi.wxml | 247 ++++++++++++++++++----------- mini/pages/bazi/bazi.wxss | 223 +++++++++++++++++++++++--- mini/pages/settings/settings.js | 178 +++++++++++++++++++-- mini/pages/settings/settings.wxml | 83 +++++++++- mini/pages/settings/settings.wxss | 45 ++++++ mini/utils/birth-profile.js | 147 +++++++++++++++++ server/cmd/main.go | 4 + server/internal/config/database.go | 1 + server/internal/handler/user.go | 106 +++++++++++++ server/internal/model/user.go | 15 ++ server/internal/service/user.go | 30 ++++ 12 files changed, 1068 insertions(+), 196 deletions(-) create mode 100644 mini/utils/birth-profile.js diff --git a/mini/pages/bazi/bazi.js b/mini/pages/bazi/bazi.js index 2ad1093..f6a76ea 100644 --- a/mini/pages/bazi/bazi.js +++ b/mini/pages/bazi/bazi.js @@ -1,75 +1,150 @@ const { birthInfoToBazi, analyzeElementBalance } = require('../../utils/core/index.js'); +const birthProfile = require('../../utils/birth-profile.js'); + +// 空态演示用的示例生辰(虚构,仅作排盘样式展示) +const DEMO_BIRTH = { year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male', ziSect: 'lateZiNextDay' }; + +// 五行 → 样式类名 +const ELEMENT_CLASS = { '木': 'el-wood', '火': 'el-fire', '土': 'el-earth', '金': 'el-metal', '水': 'el-water' }; + +// 八字知识词条:逐字段详细说明 +const GLOSSARY = [ + { term: '四柱八字', desc: '用天干地支记录出生年、月、日、时的方法,年、月、日、时各一对干支,合称"四柱",共八个字,故称"八字"。它源于古代干支纪时体系,是传统历法文化的一部分。' }, + { term: '天干地支', desc: '天干共十个(甲乙丙丁戊己庚辛壬癸),地支共十二个(子丑寅卯辰巳午未申酉戌亥),是古人用于纪年、纪月、纪日、纪时的符号系统,也是传统历法的基础。' }, + { term: '年柱', desc: '出生年份的干支。传统上以立春为界划分年份(立春前出生按上一年)。年柱在民俗说法中对应祖辈与少年时期。' }, + { term: '月柱', desc: '出生月份的干支。月份以节气划分(如立春至惊蛰为正月),并非公历或农历的自然月。民俗说法中对应父母与青年时期。' }, + { term: '日柱', desc: '出生当日的干支,按六十甲子循环纪日。日柱的天干代表本人(日主),是整个命盘分析的核心。' }, + { term: '时柱', desc: '出生时辰的干支。古人将一昼夜分为十二个时辰,每个时辰合今两小时(如子时为 23:00-01:00)。' }, + { term: '日主', desc: '日柱的天干,代表命主本人。日主的五行属性(木火土金水)是分析命盘五行强弱与十神关系的出发点。' }, + { term: '十神', desc: '以日主为参照,按五行生克关系对其他干支的十种称谓:比肩、劫财、食神、伤官、偏财、正财、七杀、正官、偏印、正印。它是古代的一种关系分类符号,用于描述干支之间的生克比和。' }, + { term: '五行', desc: '木、火、土、金、水五种基本元素,源于古代哲学,认为万物由五行构成并相生相克(木生火、火生土、土生金、金生水、水生木;木克土、土克水、水克火、火克金、金克木)。五行分析统计八字中各元素的数量与强弱。' }, + { term: '藏干', desc: '又称"人元",指每个地支内部按传统规则所含的一个至三个天干。例如寅中藏甲、丙、戊。排盘时作为辅助信息列出。' }, + { term: '纳音', desc: '将六十甲子两两配对归入五行所得的三十种称谓,如"甲子乙丑海中金"、"丙寅丁卯炉中火"。年命纳音(年柱纳音)在民俗中常被用来称呼人的"命",如"金命"、"火命"。' }, + { term: '空亡', desc: '六十甲子分为六旬,每旬十个干支配十二地支,余下两个地支无天干相配,称为该旬"空亡"。这是干支纪法本身的结构性概念,此处仅作历法知识展示。' }, + { term: '大运', desc: '传统上以月柱为基础、按十年一换的节奏推演出的干支序列,用来描述人生不同阶段的节律。属于传统民俗推演方法,供文化了解。' }, + { term: '流年', desc: '即每一年的干支(如 2026 年为丙午年)。传统做法将流年与本命干支对照,观察逐年的变化关系。' }, + { term: '子时流派', desc: '子时(23:00-01:00)横跨两天,晚子时(23:00-24:00)出生者的日柱按当天还是次日起算,历代流派说法不一。可在"我的-偏好设置"中选择。' } +]; + +/** 根据出生信息构建排盘展示数据 */ +function buildChart({ year, month, day, hour, minute, gender, ziSect }) { + const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect }); + const elementAnalysis = analyzeElementBalance(bazi.eightChar); + + const pillars = [ + { label: '年柱', ...bazi.eightChar.yearPillar }, + { label: '月柱', ...bazi.eightChar.monthPillar }, + { label: '日柱', ...bazi.eightChar.dayPillar }, + { label: '时柱', ...bazi.eightChar.hourPillar } + ].map(p => ({ + label: p.label, + ganzhi: p.ganzhi, + tenStar: p.tenStar || '日主', + heavenStem: p.heavenStem, + earthBranch: p.earthBranch, + stemClass: ELEMENT_CLASS[p.elementStem] || '', + branchClass: ELEMENT_CLASS[p.elementBranch] || '', + hideStems: (p.hideStems || []).map(h => h.stem + (h.tenStar ? `(${h.tenStar})` : '')).join(' '), + nayin: p.nayin + })); + + const total = elementAnalysis.total || 1; + const elements = [ + { name: '木', count: elementAnalysis.wood, percent: Math.round(elementAnalysis.wood / total * 100), cls: 'el-wood' }, + { name: '火', count: elementAnalysis.fire, percent: Math.round(elementAnalysis.fire / total * 100), cls: 'el-fire' }, + { name: '土', count: elementAnalysis.earth, percent: Math.round(elementAnalysis.earth / total * 100), cls: 'el-earth' }, + { name: '金', count: elementAnalysis.metal, percent: Math.round(elementAnalysis.metal / total * 100), cls: 'el-metal' }, + { name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100), cls: 'el-water' } + ]; + + return { bazi, pillars, elements, elementAnalysis }; +} Page({ data: { - gender: 'male', - birthDate: '1990-01-01', - birthTime: '12:00', - ziSect: 'lateZiNextDay', + isDemo: true, + profiles: [], + activeProfileId: '', + profile: null, bazi: null, pillars: [], elements: [], - elementAnalysis: {} + elementAnalysis: {}, + glossary: GLOSSARY, + showGlossary: false }, - onLoad() { - // 尝试从本地存储恢复 - const saved = wx.getStorageSync('lunar-bazi-input'); - if (saved) { - this.setData(saved); + onShow() { + this.refresh(); + }, + + refresh() { + const render = () => { + const profiles = birthProfile.getProfiles(); + if (profiles.length === 0) { + this.showDemo(); + } else { + this.showProfile(profiles, birthProfile.getActiveProfile()); + } + }; + // 已登录时先同步云端档案,失败静默回退本地 + if (birthProfile.isLoggedIn()) { + birthProfile.syncFromServer().then(render).catch(render); + } else { + render(); } }, - setGender(e) { - this.setData({ gender: e.currentTarget.dataset.g }); + /** 空态:展示示例排盘 */ + showDemo() { + const chart = buildChart(DEMO_BIRTH); + this.setData({ + isDemo: true, + profiles: [], + activeProfileId: '', + profile: null, + showGlossary: true, + ...chart + }); }, - setZiSect(e) { - this.setData({ ziSect: e.currentTarget.dataset.s }); - }, - - onDateChange(e) { - this.setData({ birthDate: e.detail.value }); - }, - - onTimeChange(e) { - this.setData({ birthTime: e.detail.value }); - }, - - calculate() { - const { gender, birthDate, birthTime, ziSect } = this.data; - const [year, month, day] = birthDate.split('-').map(Number); - const [hour, minute] = birthTime.split(':').map(Number); - - const bazi = birthInfoToBazi({ year, month, day, hour, minute, gender, ziSect }); - const elementAnalysis = analyzeElementBalance(bazi.eightChar); - - const pillars = [ - { label: '年柱', ...bazi.eightChar.yearPillar }, - { label: '月柱', ...bazi.eightChar.monthPillar }, - { label: '日柱', ...bazi.eightChar.dayPillar }, - { label: '时柱', ...bazi.eightChar.hourPillar } - ]; - - const total = elementAnalysis.total || 1; - const elements = [ - { name: '木', count: elementAnalysis.wood, percent: Math.round(elementAnalysis.wood / total * 100) }, - { name: '火', count: elementAnalysis.fire, percent: Math.round(elementAnalysis.fire / total * 100) }, - { name: '土', count: elementAnalysis.earth, percent: Math.round(elementAnalysis.earth / total * 100) }, - { name: '金', count: elementAnalysis.metal, percent: Math.round(elementAnalysis.metal / total * 100) }, - { name: '水', count: elementAnalysis.water, percent: Math.round(elementAnalysis.water / total * 100) } - ]; - - this.setData({ bazi, pillars, elements, elementAnalysis }); - - // 保存输入 - wx.setStorageSync('lunar-bazi-input', { gender, birthDate, birthTime, ziSect }); + /** 已绑定:展示所选档案的排盘 */ + showProfile(profiles, profile) { + if (!profile) return; + const [year, month, day] = profile.birthday.split('-').map(Number); + const [hour, minute] = profile.birthTime.split(':').map(Number); + const settings = wx.getStorageSync('lunar-settings') || {}; + const chart = buildChart({ + year, month, day, hour, minute, + gender: profile.gender, + ziSect: settings.ziHourSect || 'lateZiNextDay' + }); // 保存八字供运势页使用 - wx.setStorageSync('lunar-user-bazi', bazi); + wx.setStorageSync('lunar-user-bazi', chart.bazi); + this.setData({ + isDemo: false, + profiles, + activeProfileId: profile.id, + profile, + showGlossary: false, + ...chart + }); }, - reset() { - this.setData({ bazi: null }); + selectProfile(e) { + const id = e.currentTarget.dataset.id; + const profile = this.data.profiles.find(p => p.id === id); + if (!profile) return; + birthProfile.setActiveProfile(id); + this.showProfile(this.data.profiles, profile); + }, + + toggleGlossary() { + this.setData({ showGlossary: !this.data.showGlossary }); + }, + + goSettings() { + wx.switchTab({ url: '/pages/settings/settings' }); }, goFortune() { diff --git a/mini/pages/bazi/bazi.wxml b/mini/pages/bazi/bazi.wxml index a827639..ef01eb5 100644 --- a/mini/pages/bazi/bazi.wxml +++ b/mini/pages/bazi/bazi.wxml @@ -1,104 +1,157 @@ - - - 输入出生信息 - - - 性别 - - - - - - - - 出生日期 - - {{birthDate}} - - - - - 出生时间 - - {{birthTime}} - - - - - 子时流派 - - 晚子时日柱算次日 - 晚子时日柱算当日 - - - - 排盘 + + + 八字 · 传统历法文化 + 八字(四柱)是中国传统文化的重要组成部分,源自古人观测天象、制定历法的干支纪时体系与五行哲学思想,属于传统民俗文化与历法知识范畴,不是封建迷信。本页面仅按传统历法规则进行排盘展示与知识科普,内容供传统文化学习与娱乐参考,不构成任何人生决策建议。 - - - - - 四柱八字 - - - {{item.label}} - {{item.ganzhi}} - {{item.tenStar || '日主'}} - - + + + + 你还没有绑定生辰信息 + 下方是一份示例排盘,帮助你了解八字页面的内容。在个人中心可免费绑定 3 个出生年月(本人、家人),绑定后即可查看专属排盘。 + 去绑定我的生辰 - - - - 五行分析 - - - {{item.name}} - - - - {{item.count}} - - - - 日主:{{bazi.eightChar.dayMaster}} | dominant: {{elementAnalysis.dominant}} | weakest: {{elementAnalysis.weakest}} - - - - - - 大运 - - - - {{item.startAge}}-{{item.endAge}}岁 - {{item.ganzhi}} - {{item.startYear}}-{{item.endYear}} - - - - - - - - 流年 - - - - {{item.age}}岁 - {{item.ganzhi}} - {{item.year}} - - - - - - - - 重新排盘 - 看运势 + + 示例 + 以下排盘基于虚构生辰(1990-08-08 10:30 男),仅演示页面效果 + + + + 我的生辰 + + {{item.name}} + 管理 › + + + {{profile.name}} · {{profile.gender === 'female' ? '女' : '男'}} · 公历 {{profile.birthday}} {{profile.birthTime}} + + + + + + 四柱八字 + + + {{item.label}} + {{item.tenStar}} + {{item.heavenStem}} + {{item.earthBranch}} + 藏 {{item.hideStems}} + {{item.nayin}} + + + + + + + 命盘概览 + + + 日主 + {{bazi.eightChar.dayMaster}} + + + 五行旺 + {{elementAnalysis.dominant}} + + + 五行弱 + {{elementAnalysis.weakest}} + + + 空亡 + {{bazi.eightChar.emptyBranches[0]}}{{bazi.eightChar.emptyBranches[1]}} + + + 年柱纳音 + {{pillars[0].nayin}} + + + 五行均衡 + {{elementAnalysis.isBalanced ? '较均衡' : '有偏旺'}} + + + + + + + 五行分析 + + + {{item.name}} + + + + {{item.count}} + + + 按天干、地支计 1 分,藏干计 0.5 分;得分越高代表该五行在命盘中越突出。 + + + + + 大运(十年一步) + + + + {{item.startAge}}-{{item.endAge}}岁 + {{item.ganzhi}} + {{item.startYear}}-{{item.endYear}} + + + + + + + + 流年 + + + + {{item.age}}岁 + {{item.ganzhi}} + {{item.year}} + + + + + + + + + 这些字段是什么意思? + {{showGlossary ? '收起' : '展开'}} + + + + {{item.term}} + {{item.desc}} + + + + + + + + 绑定生辰,查看我的排盘 + + + 管理生辰 + 看今日运势 + + + + + + 八字排盘为传统历法文化展示,内容仅供学习参考,请理性看待,切勿迷信。 + diff --git a/mini/pages/bazi/bazi.wxss b/mini/pages/bazi/bazi.wxss index 4a3e466..25c6d49 100644 --- a/mini/pages/bazi/bazi.wxss +++ b/mini/pages/bazi/bazi.wxss @@ -9,35 +9,103 @@ color: #C41E3A; } -.form-item { - margin-bottom: 24rpx; +/* 文化声明卡片 */ +.culture-card { + background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%); + border-radius: 24rpx; + padding: 28rpx 24rpx; + margin-bottom: 16rpx; } -.form-label { +.culture-title { + color: #FFFFFF; + font-size: 32rpx; + font-weight: 700; + margin-bottom: 12rpx; +} + +.culture-text { + color: rgba(255, 255, 255, 0.92); + font-size: 24rpx; + line-height: 1.7; +} + +/* 空态卡片 */ +.empty-card { + text-align: center; +} + +.empty-title { + font-size: 32rpx; + font-weight: 600; + color: #1A1A1A; + margin-bottom: 12rpx; +} + +.empty-desc { font-size: 24rpx; color: #6B5E53; - margin-bottom: 8rpx; - display: block; + line-height: 1.7; } -.picker-input { - padding: 20rpx; - background: #FFFBF5; - border-radius: 12rpx; - border: 1rpx solid #E8D5C4; +/* 示例横幅 */ +.demo-banner { + display: flex; + align-items: center; + gap: 12rpx; + background: #FFF7E6; + border: 1rpx solid #F0D9A8; + border-radius: 16rpx; + padding: 16rpx 20rpx; + margin-bottom: 16rpx; } -.btn-outline.active { - background: #C41E3A; +.demo-tag { + flex-shrink: 0; + background: #D99A06; color: #FFFFFF; - border-color: #C41E3A; + font-size: 20rpx; + border-radius: 8rpx; + padding: 2rpx 12rpx; } +.demo-text { + font-size: 22rpx; + color: #8A6D1A; + line-height: 1.5; +} + +/* 档案选择 */ +.profile-chips { + flex-wrap: wrap; +} + +.profile-chip { + padding: 10rpx 28rpx; + border-radius: 999rpx; + border: 2rpx solid #E8D5C4; + font-size: 26rpx; + color: #6B5E53; + background: #FFFFFF; +} + +.profile-chip.active { + background: #C41E3A; + border-color: #C41E3A; + color: #FFFFFF; +} + +.add-chip { + border-style: dashed; + color: #9E8E7E; +} + +/* 四柱 */ .pillar { display: flex; flex-direction: column; align-items: center; - padding: 20rpx; + padding: 16rpx 8rpx; background: #FFFBF5; border-radius: 12rpx; } @@ -47,18 +115,69 @@ color: #9E8E7E; } -.pillar-ganzhi { - font-size: 36rpx; - font-weight: 700; - color: #C41E3A; - margin: 8rpx 0; -} - .pillar-ten { font-size: 20rpx; color: #6B5E53; + margin: 4rpx 0; } +.pillar-char { + font-size: 44rpx; + font-weight: 700; + line-height: 1.2; + color: #1A1A1A; +} + +.pillar-hide { + font-size: 18rpx; + color: #9E8E7E; + margin-top: 8rpx; + text-align: center; + word-break: break-all; +} + +.pillar-nayin { + font-size: 18rpx; + color: #C41E3A; + margin-top: 4rpx; +} + +/* 五行配色 */ +.el-wood { color: #2E7D32; } +.el-fire { color: #C41E3A; } +.el-earth { color: #8D6E63; } +.el-metal { color: #B8860B; } +.el-water { color: #1565C0; } + +/* 命盘概览 */ +.meta-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16rpx; +} + +.meta-item { + display: flex; + flex-direction: column; + align-items: center; + background: #FFFBF5; + border-radius: 12rpx; + padding: 16rpx 8rpx; +} + +.meta-label { + font-size: 20rpx; + color: #9E8E7E; +} + +.meta-value { + font-size: 28rpx; + font-weight: 600; + color: #1A1A1A; + margin-top: 4rpx; +} + +/* 五行分析 */ .element-bar { display: flex; flex-direction: column; @@ -79,23 +198,29 @@ .element-track { flex: 1; height: 16rpx; - background: #E8D5C4; + background: #F0E6DA; border-radius: 8rpx; overflow: hidden; } .element-fill { height: 100%; - background: #C41E3A; border-radius: 8rpx; } +.element-fill.el-wood { background: #2E7D32; } +.element-fill.el-fire { background: #C41E3A; } +.element-fill.el-earth { background: #8D6E63; } +.element-fill.el-metal { background: #B8860B; } +.element-fill.el-water { background: #1565C0; } + .element-count { width: 60rpx; text-align: right; font-size: 24rpx; } +/* 大运/流年 */ .fortune-item { display: flex; flex-direction: column; @@ -105,3 +230,55 @@ border-radius: 12rpx; min-width: 160rpx; } + +/* 字段说明 */ +.glossary-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.glossary-title { + margin-bottom: 0; +} + +.glossary-toggle { + font-size: 24rpx; + color: #9E8E7E; +} + +.glossary-item { + padding: 16rpx 0; + border-bottom: 1rpx solid #F0E6DA; +} + +.glossary-item:last-child { + border-bottom: none; +} + +.glossary-term { + display: block; + font-size: 26rpx; + font-weight: 600; + color: #C41E3A; + margin-bottom: 6rpx; +} + +.glossary-desc { + display: block; + font-size: 24rpx; + color: #6B5E53; + line-height: 1.7; +} + +/* 底部 */ +.bottom-actions { + margin-bottom: 16rpx; +} + +.disclaimer { + text-align: center; + font-size: 20rpx; + color: #9E8E7E; + padding: 8rpx 0 24rpx; +} diff --git a/mini/pages/settings/settings.js b/mini/pages/settings/settings.js index 2d24c42..23be23a 100644 --- a/mini/pages/settings/settings.js +++ b/mini/pages/settings/settings.js @@ -1,14 +1,28 @@ const versionInfo = require('../../utils/version.js'); +const { request } = require('../../utils/request.js'); +const birthProfile = require('../../utils/birth-profile.js'); Page({ data: { userInfo: null, + loggedIn: false, + logging: false, weekStartDay: 0, ziHourSect: 'lateZiNextDay', solarTime: true, showBuddhist: false, highlightLunar: true, bookmarks: [], + // 生辰档案 + profiles: [], + activeProfileId: '', + maxFree: birthProfile.MAX_PROFILES, + canAdd: true, + showAddForm: false, + formName: '', + formGender: 'male', + formDate: '1990-01-01', + formTime: '12:00', version: versionInfo.version, buildNumber: versionInfo.buildNumber, buildTime: versionInfo.buildTime, @@ -22,6 +36,7 @@ Page({ onShow() { this.loadBookmarks(); + this.loadProfiles(); }, loadSettings() { @@ -45,6 +60,146 @@ Page({ this.setData({ bookmarks }); }, + // ==================== 生辰档案 ==================== + + loadProfiles() { + const render = () => { + const profiles = birthProfile.getProfiles(); + const active = birthProfile.getActiveProfile(); + this.setData({ + profiles, + activeProfileId: active ? active.id : '', + canAdd: profiles.length < this.data.maxFree + }); + }; + // 已登录时先从服务端同步,失败静默回退本地 + if (birthProfile.isLoggedIn()) { + birthProfile.syncFromServer().then(render).catch(render); + } else { + render(); + } + }, + + toggleAddForm() { + if (!this.data.showAddForm && !this.data.canAdd) { + wx.showToast({ title: `最多可免费绑定 ${this.data.maxFree} 个生辰`, icon: 'none' }); + return; + } + this.setData({ showAddForm: !this.data.showAddForm }); + }, + + onFormName(e) { + this.setData({ formName: e.detail.value }); + }, + + setFormGender(e) { + this.setData({ formGender: e.currentTarget.dataset.g }); + }, + + onFormDate(e) { + this.setData({ formDate: e.detail.value }); + }, + + onFormTime(e) { + this.setData({ formTime: e.detail.value }); + }, + + submitProfile() { + const { formName, formGender, formDate, formTime } = this.data; + if (!formDate || !formTime) { + wx.showToast({ title: '请选择出生日期和时间', icon: 'none' }); + return; + } + const result = birthProfile.addProfile({ + name: formName.trim() || '未命名', + gender: formGender, + birthday: formDate, + birthTime: formTime + }); + if (!result.ok) { + wx.showToast({ title: result.msg, icon: 'none' }); + return; + } + // 已登录时推送到服务端(静默失败,不影响本地) + birthProfile.pushProfileToServer(result.profile); + this.setData({ showAddForm: false, formName: '' }); + wx.showToast({ title: '绑定成功', icon: 'success' }); + this.loadProfiles(); + }, + + removeProfile(e) { + const id = e.currentTarget.dataset.id; + const profile = this.data.profiles.find(p => p.id === id); + if (!profile) return; + wx.showModal({ + title: '删除生辰', + content: `确定删除「${profile.name}」的生辰信息吗?`, + success: (res) => { + if (!res.confirm) return; + birthProfile.removeProfile(id); + birthProfile.removeProfileFromServer(profile); + wx.showToast({ title: '已删除', icon: 'success' }); + this.loadProfiles(); + } + }); + }, + + // ==================== 登录 ==================== + + login() { + if (this.data.logging) return; + this.setData({ logging: true }); + wx.login({ + success: (loginRes) => { + if (!loginRes.code) { + this.setData({ logging: false }); + wx.showToast({ title: '微信登录失败', icon: 'none' }); + return; + } + request({ + url: '/api/user/login', + method: 'POST', + data: { code: loginRes.code } + }).then((res) => { + if (res && res.code === 0 && res.data && res.data.token) { + wx.setStorageSync('token', res.data.token); + wx.setStorageSync('lunar-user-info', res.data.user); + this.setData({ userInfo: res.data.user, loggedIn: true, logging: false }); + wx.showToast({ title: '登录成功', icon: 'success' }); + // 登录后同步服务端生辰档案 + this.loadProfiles(); + } else { + this.setData({ logging: false }); + wx.showToast({ title: (res && res.msg) || '登录失败', icon: 'none' }); + } + }).catch(() => { + this.setData({ logging: false }); + wx.showToast({ title: '登录失败,请稍后重试', icon: 'none' }); + }); + }, + fail: () => { + this.setData({ logging: false }); + wx.showToast({ title: '微信登录失败', icon: 'none' }); + } + }); + }, + + logout() { + wx.showModal({ + title: '退出登录', + content: '退出后生辰档案仅保存在本机,重新登录可恢复云端档案', + success: (res) => { + if (!res.confirm) return; + wx.removeStorageSync('token'); + wx.removeStorageSync('lunar-user-info'); + this.setData({ userInfo: null, loggedIn: false }); + wx.showToast({ title: '已退出', icon: 'success' }); + } + }); + }, + + // ==================== 偏好设置 ==================== + setWeekStart(e) { this.setData({ weekStartDay: Number(e.currentTarget.dataset.v) }); this.saveSettings(); @@ -79,20 +234,13 @@ Page({ }, navTo(e) { - wx.navigateTo({ url: e.currentTarget.dataset.url }); - }, - - login() { - wx.getUserProfile({ - desc: '用于完善用户资料', - success: (res) => { - this.setData({ userInfo: res.userInfo }); - wx.setStorageSync('lunar-user-info', res.userInfo); - wx.showToast({ title: '登录成功', icon: 'success' }); - }, - fail: () => { - wx.showToast({ title: '登录失败', icon: 'none' }); - } - }); + const url = e.currentTarget.dataset.url; + // tabBar 页面必须用 switchTab 打开 + const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings', '/pages/fortune/fortune']; + if (tabPages.includes(url)) { + wx.switchTab({ url }); + } else { + wx.navigateTo({ url }); + } } }); diff --git a/mini/pages/settings/settings.wxml b/mini/pages/settings/settings.wxml index 8ecf456..1ccc79b 100644 --- a/mini/pages/settings/settings.wxml +++ b/mini/pages/settings/settings.wxml @@ -2,14 +2,85 @@ 用户信息 - - - - {{userInfo.nickName}} - 已登录 + + + + + + + + {{userInfo.nickname || '微信用户'}} + 已登录,档案云端同步 + + + 退出 + + + 登录后可将生辰档案同步到云端,换设备不丢失 + {{logging ? '登录中…' : '微信登录'}} + + + + + + + 生辰管理 + {{profiles.length}}/{{maxFree}} + + 八字排盘基于生辰信息,每位用户可免费绑定 {{maxFree}} 个出生年月(如本人、家人)。 + + + + + + + {{item.name}} + 使用中 + + {{item.gender === 'female' ? '女' : '男'}} · {{item.birthday}} {{item.birthTime}} + + 删除 + - 微信登录 + + 暂未绑定生辰,请到下方添加 + + + + {{showAddForm ? '收起' : '+ 添加生辰'}} + + + 免费名额已用完,可删除旧档案后添加 + + + + + + 备注名(如:本人、爸爸) + + + + 性别 + + + + + + + 出生日期(公历) + + {{formDate}} + + + + 出生时间 + + {{formTime}} + + + 保存生辰 + diff --git a/mini/pages/settings/settings.wxss b/mini/pages/settings/settings.wxss index f804381..1daa7aa 100644 --- a/mini/pages/settings/settings.wxss +++ b/mini/pages/settings/settings.wxss @@ -15,6 +15,51 @@ border-radius: 50%; } +.avatar-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: #C41E3A; + color: #FFFFFF; + font-size: 36rpx; +} + +/* 生辰档案 */ +.profile-item { + padding: 16rpx 0; + border-bottom: 1rpx solid #E8D5C4; +} + +.profile-item:last-child { + border-bottom: none; +} + +/* 添加生辰表单 */ +.form-item { + margin-bottom: 24rpx; +} + +.form-label { + font-size: 24rpx; + color: #6B5E53; + margin-bottom: 8rpx; + display: block; +} + +.form-input { + padding: 20rpx; + background: #FFFBF5; + border-radius: 12rpx; + border: 1rpx solid #E8D5C4; +} + +.picker-input { + padding: 20rpx; + background: #FFFBF5; + border-radius: 12rpx; + border: 1rpx solid #E8D5C4; +} + .setting-item { display: flex; justify-content: space-between; diff --git a/mini/utils/birth-profile.js b/mini/utils/birth-profile.js new file mode 100644 index 0000000..82444f1 --- /dev/null +++ b/mini/utils/birth-profile.js @@ -0,0 +1,147 @@ +// 生辰档案管理工具 +// 本地存储优先;已登录(存在 token)时与服务端双向同步 +const { request } = require('./request.js'); + +const STORAGE_KEY = 'lunar-birth-profiles'; +// 每个用户可免费绑定的生辰数量上限 +const MAX_PROFILES = 3; + +/** 读取本地生辰档案列表 */ +function getProfiles() { + return wx.getStorageSync(STORAGE_KEY) || []; +} + +/** 保存本地生辰档案列表 */ +function saveProfiles(profiles) { + wx.setStorageSync(STORAGE_KEY, profiles); +} + +/** 获取当前激活的档案(第一个为默认) */ +function getActiveProfile() { + const profiles = getProfiles(); + if (profiles.length === 0) return null; + const activeId = wx.getStorageSync('lunar-birth-active-id'); + return profiles.find(p => p.id === activeId) || profiles[0]; +} + +/** 设置激活档案 */ +function setActiveProfile(id) { + wx.setStorageSync('lunar-birth-active-id', id); +} + +/** 新增生辰档案(本地),超出上限或重复时返回错误信息 */ +function addProfile(profile) { + const profiles = getProfiles(); + if (profiles.length >= MAX_PROFILES) { + return { ok: false, msg: `最多可免费绑定 ${MAX_PROFILES} 个生辰` }; + } + const dup = profiles.find(p => p.birthday === profile.birthday && p.birthTime === profile.birthTime); + if (dup) { + return { ok: false, msg: '该生辰已绑定,请勿重复添加' }; + } + const item = { + id: `local-${Date.now()}`, + name: profile.name || '未命名', + gender: profile.gender || 'male', + birthday: profile.birthday, + birthTime: profile.birthTime, + serverId: 0, + createdAt: Date.now() + }; + profiles.push(item); + saveProfiles(profiles); + if (!getActiveProfile()) setActiveProfile(item.id); + return { ok: true, profile: item }; +} + +/** 删除生辰档案(本地) */ +function removeProfile(id) { + const profiles = getProfiles().filter(p => p.id !== id); + saveProfiles(profiles); + if (wx.getStorageSync('lunar-birth-active-id') === id) { + setActiveProfile(profiles.length > 0 ? profiles[0].id : ''); + } +} + +/** 是否已登录(存在有效 token) */ +function isLoggedIn() { + return !!wx.getStorageSync('token'); +} + +/** 推送单个档案到服务端(静默失败,不影响本地) */ +function pushProfileToServer(profile) { + if (!isLoggedIn()) return Promise.resolve(null); + return request({ + url: '/api/user/birth-profiles', + method: 'POST', + data: { + name: profile.name, + gender: profile.gender === 'female' ? 2 : 1, + birthday: profile.birthday, + birthTime: profile.birthTime + } + }).then(res => { + if (res && res.code === 0 && res.data && res.data.profile) { + // 回填 serverId + const profiles = getProfiles(); + const target = profiles.find(p => p.id === profile.id); + if (target) { + target.serverId = res.data.profile.id; + saveProfiles(profiles); + } + return res.data.profile; + } + return null; + }).catch(() => null); +} + +/** 删除服务端档案(静默失败) */ +function removeProfileFromServer(profile) { + if (!isLoggedIn() || !profile.serverId) return Promise.resolve(); + return request({ + url: `/api/user/birth-profiles/${profile.serverId}`, + method: 'DELETE' + }).catch(() => null); +} + +/** 从服务端拉取档案并与本地合并(以服务端为准) */ +function syncFromServer() { + if (!isLoggedIn()) return Promise.resolve(getProfiles()); + return request({ url: '/api/user/birth-profiles' }) + .then(res => { + if (!res || res.code !== 0 || !Array.isArray(res.data && res.data.profiles)) { + return getProfiles(); + } + const serverList = res.data.profiles.map(sp => ({ + id: `server-${sp.id}`, + serverId: sp.id, + name: sp.name || '未命名', + gender: sp.gender === 2 ? 'female' : 'male', + birthday: sp.birthday, + birthTime: sp.birthTime, + createdAt: sp.createdAt ? new Date(sp.createdAt).getTime() : 0 + })); + // 本地尚未成功推送的档案保留,按 (birthday+birthTime) 去重 + const merged = [...serverList]; + getProfiles().forEach(lp => { + const exists = merged.some(m => m.birthday === lp.birthday && m.birthTime === lp.birthTime); + if (!exists) merged.push(lp); + }); + saveProfiles(merged.slice(0, MAX_PROFILES)); + return getProfiles(); + }) + .catch(() => getProfiles()); +} + +module.exports = { + MAX_PROFILES, + getProfiles, + getActiveProfile, + setActiveProfile, + addProfile, + removeProfile, + isLoggedIn, + pushProfileToServer, + removeProfileFromServer, + syncFromServer +}; diff --git a/server/cmd/main.go b/server/cmd/main.go index 1b85805..35f2e57 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -84,6 +84,10 @@ func main() { user.GET("/profile", middleware.Auth(), handler.GetUserProfile) user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile) user.POST("/auth", handler.WechatAuth) + // 生辰档案(八字绑定,每用户免费 3 个) + user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles) + user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile) + user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile) } // 支付相关 diff --git a/server/internal/config/database.go b/server/internal/config/database.go index 54a7df4..6e827a2 100644 --- a/server/internal/config/database.go +++ b/server/internal/config/database.go @@ -41,6 +41,7 @@ func autoMigrate() error { if err := DB.AutoMigrate( &model.User{}, &model.UserProfile{}, + &model.BirthProfile{}, &model.Order{}, &model.OrderItem{}, &model.WishTree{}, diff --git a/server/internal/handler/user.go b/server/internal/handler/user.go index 97cdb09..f99e0ba 100644 --- a/server/internal/handler/user.go +++ b/server/internal/handler/user.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "time" "github.com/gin-gonic/gin" @@ -283,6 +284,111 @@ func UpdateUserProfile(c *gin.Context) { }) } +// GetBirthProfiles 获取当前用户的生辰档案列表 +func GetBirthProfiles(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"}) + return + } + + userService := service.NewUserService() + profiles, err := userService.ListBirthProfiles(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取生辰档案失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{ + "profiles": profiles, + "maxFree": model.MaxFreeBirthProfiles, + }, + }) +} + +// CreateBirthProfile 新增生辰档案(每用户免费上限 MaxFreeBirthProfiles 个) +func CreateBirthProfile(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"}) + return + } + + var req struct { + Name string `json:"name"` + Gender int `json:"gender"` + Birthday string `json:"birthday" binding:"required"` + BirthTime string `json:"birthTime" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + + // 简单格式校验 + if _, err := time.Parse("2006-01-02", req.Birthday); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "出生日期格式错误"}) + return + } + if _, err := time.Parse("15:04", req.BirthTime); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "出生时间格式错误"}) + return + } + if req.Gender != 1 && req.Gender != 2 { + req.Gender = 1 + } + + profile := &model.BirthProfile{ + UserID: userID.(uint), + Name: req.Name, + Gender: req.Gender, + Birthday: req.Birthday, + BirthTime: req.BirthTime, + } + + userService := service.NewUserService() + if err := userService.CreateBirthProfile(profile); err != nil { + if err == service.ErrBirthProfileLimit { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "msg": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "新增生辰档案失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{"profile": profile}, + }) +} + +// DeleteBirthProfile 删除生辰档案(仅限本人) +func DeleteBirthProfile(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "未授权"}) + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"}) + return + } + + userService := service.NewUserService() + if err := userService.DeleteBirthProfile(uint(id), userID.(uint)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "删除生辰档案失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success"}) +} + // WechatAuth 微信授权(仅返回 openid,session_key 属敏感凭证不下发) func WechatAuth(c *gin.Context) { var req struct { diff --git a/server/internal/model/user.go b/server/internal/model/user.go index ed580ab..4d3ebf6 100644 --- a/server/internal/model/user.go +++ b/server/internal/model/user.go @@ -32,3 +32,18 @@ type UserProfile struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +// MaxFreeBirthProfiles 每个用户可免费绑定的生辰数量上限 +const MaxFreeBirthProfiles = 3 + +// BirthProfile 用户生辰档案(八字绑定),每个用户最多免费绑定 MaxFreeBirthProfiles 个 +type BirthProfile struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index" json:"userId"` + Name string `gorm:"size:32" json:"name"` // 备注名(如本人、家人等) + Gender int `gorm:"default:1" json:"gender"` // 1:男 2:女 + Birthday string `gorm:"size:10" json:"birthday"` // YYYY-MM-DD + BirthTime string `gorm:"size:8" json:"birthTime"` // HH:mm + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/server/internal/service/user.go b/server/internal/service/user.go index 13d2ce8..e7a0ad7 100644 --- a/server/internal/service/user.go +++ b/server/internal/service/user.go @@ -2,6 +2,7 @@ package service import ( "errors" + "fmt" "time" "github.com/golang-jwt/jwt/v5" @@ -70,6 +71,35 @@ func (s *UserService) GetUserProfile(userID uint) (*model.UserProfile, error) { return &profile, nil } +// ErrBirthProfileLimit 生辰档案数量达到免费上限 +var ErrBirthProfileLimit = fmt.Errorf("最多可免费绑定 %d 个生辰", model.MaxFreeBirthProfiles) + +// ListBirthProfiles 获取用户的全部生辰档案 +func (s *UserService) ListBirthProfiles(userID uint) ([]model.BirthProfile, error) { + var profiles []model.BirthProfile + if err := s.db.Where("user_id = ?", userID).Order("id ASC").Find(&profiles).Error; err != nil { + return nil, err + } + return profiles, nil +} + +// CreateBirthProfile 新增生辰档案,超过免费上限时返回 ErrBirthProfileLimit +func (s *UserService) CreateBirthProfile(profile *model.BirthProfile) error { + var count int64 + if err := s.db.Model(&model.BirthProfile{}).Where("user_id = ?", profile.UserID).Count(&count).Error; err != nil { + return err + } + if count >= model.MaxFreeBirthProfiles { + return ErrBirthProfileLimit + } + return s.db.Create(profile).Error +} + +// DeleteBirthProfile 删除生辰档案(仅限本人) +func (s *UserService) DeleteBirthProfile(id, userID uint) error { + return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.BirthProfile{}).Error +} + // GenerateToken 生成JWT Token func (s *UserService) GenerateToken(userID uint, secret string) (string, error) { claims := jwt.MapClaims{