From 19d3cd5945b9bd102e169c88c90b5f314af254cf Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Wed, 5 Aug 2026 17:50:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ECR-001):=20=E6=8E=A5=E5=85=A5=20ESS?= =?UTF-8?q?=20=E5=B9=B6=E5=AE=8C=E6=88=90=E7=BB=93=E6=9E=84=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20Phase=20A=E2=80=93E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务, 并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。 Co-authored-by: Cursor --- .ai/adr/0007-ess-ai-dual-track.md | 32 + .ai/architecture/go-services.md | 34 +- .gitignore | 4 + AGENTS.md | 16 + CLAUDE.md | 23 +- LEGACY.md | 13 +- apps/api/internal/handler/report.go | 10 +- apps/api/internal/httpserver/router.go | 4 +- apps/api/internal/relation/copy.go | 136 ++ apps/api/internal/relation/engine.go | 316 ----- apps/api/internal/relation/helpers.go | 66 + apps/api/internal/relation/scores.go | 123 ++ .../internal/service/membership/service.go | 68 + apps/api/internal/service/report/service.go | 51 - apps/api/internal/star/engine.go | 179 +-- apps/api/internal/star/helpers.go | 75 ++ .../fortune.go => outlook/outlook.go} | 4 +- .../outlook_test.go} | 2 +- apps/api/internal/star/packs.go | 96 ++ apps/user-h5/src/api/client.ts | 1 + .../src/components/home/HomeArchiveStrip.vue | 151 +++ .../src/components/home/HomeFeedSection.vue | 172 +++ .../src/components/home/HomePromoSection.vue | 109 ++ .../src/components/home/HomeSelfCard.vue | 215 ++++ .../src/components/home/HomeToolGrid.vue | 133 ++ .../src/components/home/HomeTopBar.vue | 251 ++++ .../synastry/SynastryFeatureCards.vue | 59 + .../components/synastry/SynastryLanding.vue | 396 ++++++ .../components/synastry/SynastryResult.vue | 284 +++++ apps/user-h5/src/composables/useHomeMood.ts | 39 + apps/user-h5/src/composables/useHomePage.ts | 48 + .../src/composables/useSynastryPage.ts | 390 ++++++ apps/user-h5/src/lib/homeCatalog.spec.ts | 17 + apps/user-h5/src/lib/homeCatalog.ts | 94 ++ apps/user-h5/src/pages/HomePage.vue | 1038 +--------------- apps/user-h5/src/pages/SynastryPage.vue | 1092 ++--------------- apps/user-h5/vite.config.ts | 7 + docs/ADR/.gitkeep | 0 docs/CHANGELOG.md | 11 + docs/CODE_REVIEW/ECR-001-phaseA-E.md | 65 + docs/CODE_REVIEW/ECR-001-phaseB.md | 49 + docs/DECISIONS/.gitkeep | 0 docs/ECR/.gitkeep | 0 docs/ECR/ECR-001-structural-realignment.md | 80 ++ docs/ECR/ECR-002-fortune-hygiene.md | 41 + docs/ENGINEERING_SPEC/.gitkeep | 0 .../ECR-001-structural-realignment.md | 124 ++ docs/EXPERIMENT/.gitkeep | 0 docs/HANDOFF/.gitkeep | 0 docs/HANDOFF/ECR-001-architect-to-engineer.md | 45 + docs/HANDOFF/ECR-001-engineer-to-reviewer.md | 29 + docs/HANDOFF/ECR-001-implementation-plan.md | 70 ++ .../ECR-001-phaseA-E-engineer-to-reviewer.md | 24 + .../ECR-001-phaseB-implementation-report.md | 25 + docs/PRD/.gitkeep | 0 docs/PRODUCT_SPEC/.gitkeep | 0 .../ECR-001-structural-realignment.md | 32 + docs/PROJECT_PROFILE.md | 60 + docs/PROJECT_RULES.md | 35 + docs/RISK_REVIEW/.gitkeep | 0 docs/STATE/.gitkeep | 0 docs/STATE/ECR-001.md | 19 + docs/TASKS/.gitkeep | 0 docs/TASKS/TASK-20260805-ECR001-phaseA-E.yaml | 27 + docs/TASKS/TASK-20260805-ECR001-phaseB.yaml | 31 + docs/TASKS/TASK-20260805-ECR001.yaml | 30 + docs/TECH_STACK.md | 32 + docs/TEST_REPORT/ECR-001-phaseA-E.md | 31 + docs/TEST_REPORT/ECR-001-phaseB.md | 32 + docs/TRACEABILITY.md | 9 + scripts/deploy-ess.py | 11 + scripts/ess-gate-check.py | 11 + scripts/ess-runner.py | 11 + scripts/ess-validate.py | 11 + scripts/version-control-check.py | 11 + 75 files changed, 4199 insertions(+), 2505 deletions(-) create mode 100644 .ai/adr/0007-ess-ai-dual-track.md create mode 100644 apps/api/internal/relation/copy.go create mode 100644 apps/api/internal/relation/helpers.go create mode 100644 apps/api/internal/relation/scores.go create mode 100644 apps/api/internal/service/membership/service.go create mode 100644 apps/api/internal/star/helpers.go rename apps/api/internal/star/{fortune/fortune.go => outlook/outlook.go} (99%) rename apps/api/internal/star/{fortune/fortune_test.go => outlook/outlook_test.go} (98%) create mode 100644 apps/api/internal/star/packs.go create mode 100644 apps/user-h5/src/components/home/HomeArchiveStrip.vue create mode 100644 apps/user-h5/src/components/home/HomeFeedSection.vue create mode 100644 apps/user-h5/src/components/home/HomePromoSection.vue create mode 100644 apps/user-h5/src/components/home/HomeSelfCard.vue create mode 100644 apps/user-h5/src/components/home/HomeToolGrid.vue create mode 100644 apps/user-h5/src/components/home/HomeTopBar.vue create mode 100644 apps/user-h5/src/components/synastry/SynastryFeatureCards.vue create mode 100644 apps/user-h5/src/components/synastry/SynastryLanding.vue create mode 100644 apps/user-h5/src/components/synastry/SynastryResult.vue create mode 100644 apps/user-h5/src/composables/useHomeMood.ts create mode 100644 apps/user-h5/src/composables/useHomePage.ts create mode 100644 apps/user-h5/src/composables/useSynastryPage.ts create mode 100644 apps/user-h5/src/lib/homeCatalog.spec.ts create mode 100644 apps/user-h5/src/lib/homeCatalog.ts create mode 100644 docs/ADR/.gitkeep create mode 100644 docs/CHANGELOG.md create mode 100644 docs/CODE_REVIEW/ECR-001-phaseA-E.md create mode 100644 docs/CODE_REVIEW/ECR-001-phaseB.md create mode 100644 docs/DECISIONS/.gitkeep create mode 100644 docs/ECR/.gitkeep create mode 100644 docs/ECR/ECR-001-structural-realignment.md create mode 100644 docs/ECR/ECR-002-fortune-hygiene.md create mode 100644 docs/ENGINEERING_SPEC/.gitkeep create mode 100644 docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md create mode 100644 docs/EXPERIMENT/.gitkeep create mode 100644 docs/HANDOFF/.gitkeep create mode 100644 docs/HANDOFF/ECR-001-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-001-engineer-to-reviewer.md create mode 100644 docs/HANDOFF/ECR-001-implementation-plan.md create mode 100644 docs/HANDOFF/ECR-001-phaseA-E-engineer-to-reviewer.md create mode 100644 docs/HANDOFF/ECR-001-phaseB-implementation-report.md create mode 100644 docs/PRD/.gitkeep create mode 100644 docs/PRODUCT_SPEC/.gitkeep create mode 100644 docs/PRODUCT_SPEC/ECR-001-structural-realignment.md create mode 100644 docs/PROJECT_PROFILE.md create mode 100644 docs/PROJECT_RULES.md create mode 100644 docs/RISK_REVIEW/.gitkeep create mode 100644 docs/STATE/.gitkeep create mode 100644 docs/STATE/ECR-001.md create mode 100644 docs/TASKS/.gitkeep create mode 100644 docs/TASKS/TASK-20260805-ECR001-phaseA-E.yaml create mode 100644 docs/TASKS/TASK-20260805-ECR001-phaseB.yaml create mode 100644 docs/TASKS/TASK-20260805-ECR001.yaml create mode 100644 docs/TECH_STACK.md create mode 100644 docs/TEST_REPORT/ECR-001-phaseA-E.md create mode 100644 docs/TEST_REPORT/ECR-001-phaseB.md create mode 100644 docs/TRACEABILITY.md create mode 100755 scripts/deploy-ess.py create mode 100755 scripts/ess-gate-check.py create mode 100755 scripts/ess-runner.py create mode 100755 scripts/ess-validate.py create mode 100755 scripts/version-control-check.py diff --git a/.ai/adr/0007-ess-ai-dual-track.md b/.ai/adr/0007-ess-ai-dual-track.md new file mode 100644 index 0000000..addfac8 --- /dev/null +++ b/.ai/adr/0007-ess-ai-dual-track.md @@ -0,0 +1,32 @@ +# ADR-0007 — ESS 与 `.ai/` 双轨治理 + +- Status: Accepted +- Date: 2026-08-05 +- Tags: process, ess, agents + +## Context + +仓库已有成熟的 `.ai/` AI Engineering System(领域、Feature Spec、DoD、架构冻结)。 +2026-08-05 绑定 **engineering-spec-system v1.0**,引入 `docs/ECR|TASKS|HANDOFF|STATE` 与角色门禁。 +若两套文档并行且无优先级,Agent 会冲突或重复发明规则。 + +## Decision + +1. **产品 / 领域 / Lexicon / Feature Spec / DoD / 架构冻结 / 安全编码**:以 **`.ai/`** 为唯一权威。 +2. **变更分级 / 角色(Architect·Engineer·Reviewer·Release)/ ECR·Task·Handoff·validate**:以 **ESS + 根目录 `docs/`** 为权威。 +3. **L2+ 行为或结构重构**:必须同时满足 + - Active Feature Spec(若涉及产品行为;纯结构重构可用 PRODUCT_SPEC「行为冻结」) + - Approved ECR under `docs/ECR/` +4. **L3 栈或包边界**:另需 `.ai/adr/` Accepted;ESS ENGINEERING_SPEC 引用该 ADR。 +5. **禁止**将 ESS 整树复制进本仓;仅保留 Profile / wrappers / 过程工件。 + +## Consequences + +- Agent 会话:先 `docs/PROJECT_PROFILE.md`,再按 `AGENTS.md` 加载 `.ai/`。 +- 冲突时:用户可见语义与完成标准听 `.ai/`;能否开工听 ECR/Role。 +- 后续重构按 ECR-001 分 Phase,禁止无合同大爆炸重写。 + +## Alternatives considered + +- 用 ESS 替换 `.ai/`:丢弃已验证的 lexicon/DoD/feature-spec,成本高,否决。 +- 只用 `.ai/` 忽略 ESS:无法满足「用 ESS 治理重构」诉求,否决。 diff --git a/.ai/architecture/go-services.md b/.ai/architecture/go-services.md index 0d40d4c..45c2361 100644 --- a/.ai/architecture/go-services.md +++ b/.ai/architecture/go-services.md @@ -7,22 +7,32 @@ ## Packages under `internal/service/` -| Package | 职责 | P1 | -|---|---|---| -| `user` | 注册/游客升级/Session | Yes | -| `profile` | 个人档案 CRUD、切换 | Yes | -| `portrait` | 生成个人画像 → GrowthReport | Yes | -| `scale` | 探索测试定义与计分 | Yes | -| `relation` | RelationInsight | Yes **必做** | -| `report` | GrowthReport 读取 + **权益裁剪** | Yes | -| `ask` | Thread/Message;P1 规则引擎 + 额度 | Yes(Demo) | -| `companion` | SolarTerm 读、Mood 写 | Shell | -| `membership` | 成长会员状态与额度 | Yes | -| `order` | Order + pay-mock + DeepAccess 发放 | Yes | +| Package | 职责 | P1 | 实现态(2026-08) | +|---|---|---|---| +| `user` | 注册/游客升级/Session | Yes | 多在 profile/auth 路径;无独立包名时勿重复造轮 | +| `profile` | 个人档案 CRUD、切换 | Yes | `service/profile` | +| `portrait` | 生成个人画像 → GrowthReport | Yes | 计算在 `internal/portrait`;用例经 report/handler 路径 | +| `scale` | 探索测试定义与计分 | Yes | `service/scale` + `internal/scale` | +| `relation` | RelationInsight | Yes **必做** | `service/relation` + `internal/relation` | +| `report` | GrowthReport 读取 + **权益裁剪** | Yes | `service/report` | +| `ask` | Thread/Message;P1 规则引擎 + 额度 | Yes(Demo) | `service/ask` + `internal/ask` | +| `companion` | SolarTerm 读、Mood 写 | Shell | `service/companion` | +| `membership` | 成长会员状态与额度 | Yes | `service/membership`(ECR-001 Phase C 已从 report 抽出) | +| `order` | Order + pay-mock + DeepAccess 发放 | Yes | 用例在 `service/membership`(同 Phase C;未单独拆包) | +| `imagecard` | 意象卡片 | P2 | `service/imagecard` | + +### Engine vs Service + +| Kind | Path | Can | Cannot | +|---|---|---|---| +| Engine | `internal/{portrait,relation,star,ask,scale,rhythm,…}` | 纯计算 / 组装报告结构 | 引用 `gin.Context`;做权益解锁 | +| Service | `internal/service/*` | 用例编排、鉴权后业务、调 repo/engine | SQL 直写;跳过归属校验 | `internal/handler/`:每域一组 handler,只做 bind/validate/调用 service。 `internal/repository/`:SQL;无 business unlock 规则(规则在 service/report + membership)。 +星座周期展望引擎包:`internal/star/outlook`(原 `fortune`,ECR-002)。对外 JSON 暂双写 `fortune`/`outlook` 键以兼容旧客户端。 + --- ## 跨域规则 diff --git a/.gitignore b/.gitignore index 934c87e..7e41e54 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ __pycache__/ .env .DS_Store +# ESS — machine-local root pointer(仓库内用 scripts 包装器) +.ess-root + # Node node_modules/ dist/ @@ -33,3 +36,4 @@ tools/android-adb-docs # OS Thumbs.db +.gstack/ diff --git a/AGENTS.md b/AGENTS.md index 70dd206..770ccd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,22 @@ This file is for AI agents. 15. Before claiming Done: `.ai/definition-of-done.md` + `.ai/review.md` + `.ai/checklists/*` → 输出 **Review Report** 16. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB) 17. `prompts/` are optional helpers — not a substitute for rules above +18. **ESS process (when L2+ / architecture / release):** `docs/PROJECT_PROFILE.md` → role from `$ESS_ROOT/agents/` → `docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/`;勿把 ESS 整树复制进仓 + +## ESS dual-track + +| Concern | Source of truth | +|---------|-----------------| +| Lexicon · Feature Spec · DoD · domain · architecture freeze | `.ai/` | +| Roles · ECR · Task Contract · Handoff · validate gates | ESS + `docs/` | + +```text +Load Agent Profile: ARCHITECT | ENGINEER | REVIEWER | RELEASE_MANAGER +``` + +- Cursor → ARCHITECT / REVIEWER(禁改生产实现) +- Engineer 实现前:Active Feature Spec +(L2+)Approved ECR + HANDOFF +- `python scripts/ess-validate.py --phase --ecr ECR-xxx` ## Hard constraints diff --git a/CLAUDE.md b/CLAUDE.md index d66ea50..66fe129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,27 @@ | `apps/mini-program` | Mini-program scaffold | | `packages/*` | sdk / types / utils / ui | | `apps/docs/` | Human PRD / business docs (not a substitute for `.ai/`) | +| `docs/` | ESS process artifacts(ECR / TASK / HANDOFF / PROFILE) | + +## ESS(process overlay) + +Bound to **engineering-spec-system v1.0**. Product rules stay in `.ai/`; change control uses ESS roles + `docs/`. + +```text +Load Agent Profile: ARCHITECT | ENGINEER | REVIEWER | RELEASE_MANAGER +``` + +1. `$ESS_ROOT/SKILL.md` + `$ESS_ROOT/agents/.md` +2. `docs/PROJECT_PROFILE.md` · `PROJECT_RULES.md` · `TECH_STACK.md` +3. Active `docs/TASKS/` · `docs/ECR/` · `docs/HANDOFF/` · `docs/STATE/` +4. Still obey [AGENTS.md](AGENTS.md) `.ai/` load order for domain / Feature Spec / DoD + +```bash +python scripts/ess-validate.py --phase design --ecr ECR-xxx +python scripts/ess-gate-check.py --ecr ECR-xxx +``` + +Do **not** copy the ESS tree into this repo. Architect must not edit `apps/` / `packages/`. ## Commands @@ -31,7 +52,7 @@ npm install && npm run dev:h5 ## Legacy -Root `yuxingu.html`, `pages/`, `server.py` = prototype. Do not extend. See [LEGACY.md](LEGACY.md). +Static prototype (`yuxingu.html` / `pages/` / `css/` / `js/`) **removed** from tree — do not recreate. `server.py` must not grow product APIs. See [LEGACY.md](LEGACY.md). ## Skill routing diff --git a/LEGACY.md b/LEGACY.md index b0cff52..95f8760 100644 --- a/LEGACY.md +++ b/LEGACY.md @@ -1,9 +1,16 @@ # Legacy 原型说明 -以下路径为重构前的静态 H5 原型,**默认只读**,新功能请在 `apps/user-h5` 与 `apps/api` 开发: +以下路径为重构前的静态 H5 原型,**默认只读 / 已删除勿恢复扩展**。新功能请在 `apps/user-h5` 与 `apps/api` 开发。 + +## 已删除(约 2026-08 · `main` `9f65c11`) - `yuxingu.html`、`index.html` - `pages/`、`css/`、`js/` -- `server.py`(Python 静态站 + 量表 API) -可对照交互与文案,迁移完成后移入 `archive/legacy-h5/`。 +对照交互与文案请查 git 历史或 `archive/`(若后续归档),**不要**在仓库根重建静态站作为产品面。 + +## 仍可能存在 + +- `server.py` — 历史静态/辅助脚本;**禁止**增长新的产品 API(产品 API 只在 `apps/api`)。 + +迁移原则见 `.ai/adr/0006-monorepo-vue-h5.md` · ADR-0001。 diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go index 23bd3dd..117d994 100644 --- a/apps/api/internal/handler/report.go +++ b/apps/api/internal/handler/report.go @@ -8,13 +8,15 @@ import ( "github.com/google/uuid" "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/membership" "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) // ReportHandler exposes portrait reports and commerce mock. type ReportHandler struct { - Svc *report.Service + Svc *report.Service + Membership *membership.Service } // Register mounts report/commerce routes. @@ -200,7 +202,7 @@ func (h *ReportHandler) GetMembership(c *gin.Context) { response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") return } - me, err := h.Svc.GetMembership(c.Request.Context(), userID) + me, err := h.Membership.Get(c.Request.Context(), userID) if err != nil { response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) return @@ -233,7 +235,7 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) { } rid = &id } - oid, err := h.Svc.CreateOrder(c.Request.Context(), userID, report.CreateOrderInput{ + oid, err := h.Membership.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{ Kind: req.Kind, Plan: req.Plan, ReportID: rid, }) if err != nil { @@ -255,7 +257,7 @@ func (h *ReportHandler) PayMock(c *gin.Context) { response.Fail(c, http.StatusBadRequest, 10000, "invalid id") return } - if err := h.Svc.PayMock(c.Request.Context(), userID, oid); err != nil { + if err := h.Membership.PayMock(c.Request.Context(), userID, oid); err != nil { response.Fail(c, http.StatusBadRequest, 30004, err.Error()) return } diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index 27a675f..dc78423 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -13,6 +13,7 @@ import ( "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask" companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/membership" "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" @@ -38,6 +39,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { Reports: reportRepo, Invites: &repository.SynastryInviteRepo{Pool: pool}, } + membershipSvc := &membership.Service{Reports: reportRepo} relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo} scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo} askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm} @@ -64,7 +66,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { authed := api.Group("") authed.Use(middleware.DeviceAuth(pool)) (&handler.ProfileHandler{Svc: profileSvc}).Register(authed) - (&handler.ReportHandler{Svc: reportSvc}).Register(authed) + (&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(authed) (&handler.SynastryHandler{Svc: reportSvc}).Register(authed) (&handler.RelationHandler{Svc: relationSvc}).Register(authed) (&handler.ScaleHandler{Svc: scaleSvc}).Register(authed) diff --git a/apps/api/internal/relation/copy.go b/apps/api/internal/relation/copy.go new file mode 100644 index 0000000..408f115 --- /dev/null +++ b/apps/api/internal/relation/copy.go @@ -0,0 +1,136 @@ +package relation + +import "fmt" + +func complementarityKey(a, b string) string { + if a == b { + return "same" + } + // simple buckets by trait family + drive := map[string]string{ + "稳进探索者": "steady", "细腻分析者": "steady", "守护担当者": "steady", "洞察策略者": "steady", + "敏锐连接者": "warm", "温和协调者": "warm", "热忱鼓舞者": "warm", + "果断行动派": "drive", "自由创造者": "drive", + } + ak, bk := drive[a], drive[b] + if ak == "" || bk == "" { + return "mix" + } + if ak == bk { + return "same_family" + } + if (ak == "steady" && bk == "warm") || (ak == "warm" && bk == "steady") { + return "steady_warm" + } + if (ak == "drive" && bk == "steady") || (ak == "steady" && bk == "drive") { + return "drive_steady" + } + if (ak == "drive" && bk == "warm") || (ak == "warm" && bk == "drive") { + return "drive_warm" + } + return "mix" +} + +type compCopy struct { + OneLiner, Overview, Chemistry, DeepOverview, CommDeep, ConflictDeep, IntimacyDeep, GrowthDeep string + ChemistryPoints, Watchouts, CommTips, ConflictTips, IntimacyTips, GrowthTips, Weekly, Scripts []string +} + +func complementarityCopy(key, aName, bName, aLabel, bLabel string) compCopy { + base := compCopy{ + OneLiner: fmt.Sprintf("%s偏「%s」,%s偏「%s」——差异可以写成相处说明书。", aName, aLabel, bName, bLabel), + Overview: fmt.Sprintf("双方在表达、节奏与需求上并不相同。把差异看清楚,比急着证明「谁更对」更有用。下面从沟通、冲突、亲密与共同成长几个维度展开。"), + Chemistry: "互补往往出现在:一方给结构,另一方给温度;或一方推进,另一方稳住质量。", + Weekly: []string{ + "本周进行一次 20 分钟「非解决问题」闲聊或散步。", + "各自写三件「我需要你这样支持我」的具体行为,互换阅读。", + "约定一个冲突停火词,任一方说出即暂停 15 分钟。", + }, + Scripts: []string{ + fmt.Sprintf("%s可以说:我需要先把事实说清楚,再谈感受。", aName), + fmt.Sprintf("%s可以说:我希望你先听到我的感受,再给方案。", bName), + "我们可以先复述对方一句,再表达自己的需要。", + }, + } + + switch key { + case "same": + base.OneLiner = fmt.Sprintf("你们风格接近(都偏「%s」),默契来得快,也要防止一起陷入同样的盲区。", aLabel) + base.Chemistry = "同类相吸:理解成本低,推进或回避也可能同步发生。" + base.DeepOverview = "风格相近意味着你们很容易「懂对方在想什么」,但也可能同时逃避冲突,或同时过度冲刺。建议定期引入外部视角(朋友建议、清单复盘),打破镜像盲区。" + base.CommDeep = "沟通效率高,但要刻意练习提出不同意见。安排「唱反调」轮值:每周一人专门提出风险点。" + base.ConflictDeep = "冲突可能被快速和好掩盖,问题未真正处理。用「问题清单」追踪未完成议题。" + base.IntimacyDeep = "熟悉感强,新鲜感需主动创造:共同学习或小旅行比重复日常更能充电。" + base.GrowthDeep = "一起设定一个共同小目标,并互相做问责伙伴。" + base.ChemistryPoints = []string{"理解成本低", "节奏容易对齐", "共同语言多"} + base.Watchouts = []string{"共享同一盲区", "缺少外部校正", "意见过于一致缺少张力"} + base.CommTips = []string{"鼓励提出异议", "重要决定写利弊表", "避免默认对方已懂"} + base.ConflictTips = []string{"追踪未完成议题", "避免假性和好", "冷静后再做决定"} + base.IntimacyTips = []string{"主动制造新鲜体验", "表达感谢要具体", "保留个人空间"} + base.GrowthTips = []string{"共同目标 + 问责", "每月复盘一次关系", "引入可信第三方建议"} + case "steady_warm": + base.Chemistry = "稳与暖互补:一方提供结构与可靠,另一方提供连接与温度。" + base.DeepOverview = fmt.Sprintf("%s与%s之间,最常见的张力是「要先讲清楚」还是「要先被看见」。若能轮流满足这两种需求,关系会既安全又有温度。", aName, bName) + base.CommDeep = "沟通协议:情绪话题先共鸣 2 分钟,再进入事实与方案;事务话题先结论,再补感受。" + base.ConflictDeep = "稳的一方别用沉默当结束;暖的一方别用追问升级压力。停火后用「我需要…」重开。" + base.IntimacyDeep = "暖的一方需要回应频率;稳的一方需要可预期的独处。把两者写进约定。" + base.GrowthDeep = "把互补写成分工:谁更擅长安抚,谁更擅长推进落地。" + base.ChemistryPoints = []string{"结构 × 温度", "可靠 × 连接", "可形成完整支持系统"} + base.Watchouts = []string{"一方觉得被冷落", "一方觉得被情绪淹没", "节奏错位积累委屈"} + base.CommTips = []string{"情绪先共鸣再方案", "事务先结论再感受", "用文字确认关键约定"} + base.ConflictTips = []string{"禁止用沉默结束话题", "追问前先问是否方便", "停火词机制"} + base.IntimacyTips = []string{"约定回应窗口", "尊重独处不被解读为冷淡", "每周一次深度连接"} + base.GrowthTips = []string{"按优势分工", "互相学习对方语言", "月度关系复盘"} + case "drive_steady": + base.Chemistry = "推与稳互补:一方破局加速,另一方把关质量与可持续。" + base.DeepOverview = "行动派容易嫌分析派慢;稳健派容易嫌行动派莽。把「速度」用在试验,「稳健」用在关键承诺,冲突会下降。" + base.CommDeep = "行动方给时间盒与最小方案;稳健方在时限内给风险清单,而不是无限延期。" + base.ConflictDeep = "冲突焦点常是节奏。先对齐「这是可逆试验还是重大决定」,再选速度。" + base.IntimacyDeep = "行动方用陪伴质量弥补碎片时间;稳健方减少用担忧浇灭热情,改用「我支持你试,我们设检查点」。" + base.GrowthDeep = "共同项目里明确角色:谁启动、谁验收、何时复盘。" + base.ChemistryPoints = []string{"破局 × 把关", "速度 × 质量", "试验与承诺可分工"} + base.Watchouts = []string{"节奏互斥", "一方压抑热情", "一方焦虑失控"} + base.CommTips = []string{"先定义可逆/不可逆", "时间盒决策", "风险清单限时"} + base.ConflictTips = []string{"争论节奏前先分类问题", "避免人格化指责", "用检查点代替否决"} + base.IntimacyTips = []string{"质量陪伴", "支持试验+检查点", "庆祝小进展"} + base.GrowthTips = []string{"项目角色清晰", "复盘节奏", "互相翻译动机"} + case "drive_warm": + base.Chemistry = "驱动与连接互补:一方带节奏,另一方维系人心与氛围。" + base.DeepOverview = "热情与效率碰到一起很有火花,也容易在「推进」与「照顾感受」之间拉扯。约定场景切换:冲刺模式 / 连接模式。" + base.CommDeep = "冲刺时短讯同步进度;连接时关掉任务话题。不要用效率语言处理情绪时刻。" + base.ConflictDeep = "驱动方避免「你想太多」;连接方避免「你只在乎结果」。改说具体需求。" + base.IntimacyDeep = "用共同体验(运动、活动)同时满足推进感与连接感。" + base.GrowthDeep = "轮流做「本周关系主理人」,负责安排一次连接或一次共同目标。" + base.ChemistryPoints = []string{"推进力 × 氛围", "行动号召力强", "共同体验易充电"} + base.Watchouts = []string{"情绪被效率压过", "承诺过多难兑现", "连接变任务化"} + base.CommTips = []string{"模式切换:冲刺/连接", "情绪时刻禁用效率话术", "进度短讯化"} + base.ConflictTips = []string{"禁止否定感受", "需求具体化", "修复后再推进"} + base.IntimacyTips = []string{"共同体验", "兑现小承诺", "非任务陪伴"} + base.GrowthTips = []string{"轮值关系主理人", "控制并行承诺", "庆祝与复盘并重"} + case "same_family": + base.DeepOverview = "你们属于相近气质族,容易互相理解,也要主动制造一点建设性差异,避免舒适区停滞。" + base.CommDeep = "沟通顺畅时更要确认细节,防止「好像说好了」其实理解不同。" + base.ConflictDeep = "冲突可能被淡化。强制做一次「最担心的三件事」互换。" + base.IntimacyDeep = "在舒适之外增加挑战性共同任务,刷新关系动能。" + base.GrowthDeep = "互相指出对方一个盲区,并约定本月各改一项小行为。" + base.ChemistryPoints = []string{"气质相近", "理解门槛低", "协作起步快"} + base.Watchouts = []string{"舒适区停滞", "细节默认错误", "回避尖锐议题"} + base.CommTips = []string{"确认细节", "书面关键约定", "鼓励异议"} + base.ConflictTips = []string{"互换担忧清单", "不假性和好", "设讨论截止"} + base.IntimacyTips = []string{"共同挑战任务", "新鲜体验", "具体感谢"} + base.GrowthTips = []string{"互指一个盲区", "月改一小行为", "外部输入"} + default: + base.DeepOverview = fmt.Sprintf("%s与%s风格路径不同,说明书价值更高。先承认差异合法,再谈协作规则。", aName, bName) + base.CommDeep = "建立双通道:事实通道与感受通道,讨论前先声明走哪一条。" + base.ConflictDeep = "冲突时回到共同目标句:我们都希望关系更好/事情做成。" + base.IntimacyDeep = "用定期同步取代猜测;空间与连接都要有配额。" + base.GrowthDeep = "把差异写成「我擅长 / 我需要」对照表,贴在看得见的地方。" + base.ChemistryPoints = []string{"视角多样", "可互补决策", "扩展彼此舒适区"} + base.Watchouts = []string{"误解成本高", "价值观冲突需早谈", "节奏长期错位"} + base.CommTips = []string{"声明沟通通道", "复述再回应", "关键约定书面化"} + base.ConflictTips = []string{"回到共同目标", "停火机制", "一次只谈一个议题"} + base.IntimacyTips = []string{"定期同步", "空间与连接配额", "具体肯定"} + base.GrowthTips = []string{"擅长/需要对照表", "月度复盘", "小步共同目标"} + } + return base +} diff --git a/apps/api/internal/relation/engine.go b/apps/api/internal/relation/engine.go index ba6c04a..7961522 100644 --- a/apps/api/internal/relation/engine.go +++ b/apps/api/internal/relation/engine.go @@ -145,319 +145,3 @@ func BuildFull(aBirth, bBirth time.Time, aTime, bTime, aPlace, bPlace *string, a } return Output{Summary: summary, Detail: detail} } - -func relationTypePack(t, aName, bName string) (label, body string, bullets []string) { - switch t { - case "partner", "恋人", "伴侣": - return "伴侣", - fmt.Sprintf("%s与%s更适合把差异写成「亲密说明书」:欲望、节奏与安全感都说清楚。", aName, bName), - []string{"每周一次情绪复盘,不谈对错", "亲密请求用「我需要」句式", "边界:疲惫时先暂停再继续"} - case "family", "家人", "父母", "亲子": - return "家人", - fmt.Sprintf("家人关系里,%s与%s容易把旧角色带进新对话。试着把对方当「现在的人」而不是旧剧本。", aName, bName), - []string{"少用「你总是」句式", "大事拆成可协商的小请求", "保留各自的私人空间"} - case "friend", "朋友": - return "朋友", - fmt.Sprintf("友情里%s与%s可以更轻松地互补:约会期待与回应频率说开即可。", aName, bName), - []string{"约见用明确时间,减少猜测", "忙时用短消息保持连结", "冲突后用玩笑或直接道歉都行,别冷处理太久"} - default: - return "", "", nil - } -} - -type dimScore struct { - Score int - Teaser string -} - -func dimMap(v any) map[string]dimScore { - out := map[string]dimScore{} - arr, ok := v.([]map[string]any) - if !ok { - // Build() uses []map[string]any — also tolerate []any - raw, ok2 := v.([]any) - if !ok2 { - return out - } - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - key := str(m["key"]) - out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} - } - return out - } - for _, m := range arr { - key := str(m["key"]) - out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} - } - return out -} - -func asInt(v any) int { - switch n := v.(type) { - case int: - return n - case float64: - return int(n) - default: - return 0 - } -} - -func dimTitle(key string) string { - switch key { - case "personality": - return "性格特点" - case "communication": - return "沟通方式" - case "relation": - return "关系模式" - case "career": - return "事业节奏" - case "emotion": - return "情绪调节" - case "lifestyle": - return "生活节奏" - default: - return key - } -} - -func dimNote(key string, gap int, aName, bName string) string { - if gap > 12 { - return fmt.Sprintf("在「%s」上,%s 分值更高,适合由 %s 多给结构,%s 多给弹性。", dimTitle(key), aName, aName, bName) - } - if gap < -12 { - return fmt.Sprintf("在「%s」上,%s 分值更高,相处时可多尊重 %s 的节奏。", dimTitle(key), bName, bName) - } - return fmt.Sprintf("在「%s」上双方接近,容易形成默契,也要防止都默认对方「应该懂」。", dimTitle(key)) -} - -func firstTeaser(m map[string]dimScore, key string) string { - if d, ok := m[key]; ok && d.Teaser != "" { - return d.Teaser - } - return "表达方式各有节奏" -} - -func complementarityKey(a, b string) string { - if a == b { - return "same" - } - // simple buckets by trait family - drive := map[string]string{ - "稳进探索者": "steady", "细腻分析者": "steady", "守护担当者": "steady", "洞察策略者": "steady", - "敏锐连接者": "warm", "温和协调者": "warm", "热忱鼓舞者": "warm", - "果断行动派": "drive", "自由创造者": "drive", - } - ak, bk := drive[a], drive[b] - if ak == "" || bk == "" { - return "mix" - } - if ak == bk { - return "same_family" - } - if (ak == "steady" && bk == "warm") || (ak == "warm" && bk == "steady") { - return "steady_warm" - } - if (ak == "drive" && bk == "steady") || (ak == "steady" && bk == "drive") { - return "drive_steady" - } - if (ak == "drive" && bk == "warm") || (ak == "warm" && bk == "drive") { - return "drive_warm" - } - return "mix" -} - -type compCopy struct { - OneLiner, Overview, Chemistry, DeepOverview, CommDeep, ConflictDeep, IntimacyDeep, GrowthDeep string - ChemistryPoints, Watchouts, CommTips, ConflictTips, IntimacyTips, GrowthTips, Weekly, Scripts []string -} - -func complementarityCopy(key, aName, bName, aLabel, bLabel string) compCopy { - base := compCopy{ - OneLiner: fmt.Sprintf("%s偏「%s」,%s偏「%s」——差异可以写成相处说明书。", aName, aLabel, bName, bLabel), - Overview: fmt.Sprintf("双方在表达、节奏与需求上并不相同。把差异看清楚,比急着证明「谁更对」更有用。下面从沟通、冲突、亲密与共同成长几个维度展开。"), - Chemistry: "互补往往出现在:一方给结构,另一方给温度;或一方推进,另一方稳住质量。", - Weekly: []string{ - "本周进行一次 20 分钟「非解决问题」闲聊或散步。", - "各自写三件「我需要你这样支持我」的具体行为,互换阅读。", - "约定一个冲突停火词,任一方说出即暂停 15 分钟。", - }, - Scripts: []string{ - fmt.Sprintf("%s可以说:我需要先把事实说清楚,再谈感受。", aName), - fmt.Sprintf("%s可以说:我希望你先听到我的感受,再给方案。", bName), - "我们可以先复述对方一句,再表达自己的需要。", - }, - } - - switch key { - case "same": - base.OneLiner = fmt.Sprintf("你们风格接近(都偏「%s」),默契来得快,也要防止一起陷入同样的盲区。", aLabel) - base.Chemistry = "同类相吸:理解成本低,推进或回避也可能同步发生。" - base.DeepOverview = "风格相近意味着你们很容易「懂对方在想什么」,但也可能同时逃避冲突,或同时过度冲刺。建议定期引入外部视角(朋友建议、清单复盘),打破镜像盲区。" - base.CommDeep = "沟通效率高,但要刻意练习提出不同意见。安排「唱反调」轮值:每周一人专门提出风险点。" - base.ConflictDeep = "冲突可能被快速和好掩盖,问题未真正处理。用「问题清单」追踪未完成议题。" - base.IntimacyDeep = "熟悉感强,新鲜感需主动创造:共同学习或小旅行比重复日常更能充电。" - base.GrowthDeep = "一起设定一个共同小目标,并互相做问责伙伴。" - base.ChemistryPoints = []string{"理解成本低", "节奏容易对齐", "共同语言多"} - base.Watchouts = []string{"共享同一盲区", "缺少外部校正", "意见过于一致缺少张力"} - base.CommTips = []string{"鼓励提出异议", "重要决定写利弊表", "避免默认对方已懂"} - base.ConflictTips = []string{"追踪未完成议题", "避免假性和好", "冷静后再做决定"} - base.IntimacyTips = []string{"主动制造新鲜体验", "表达感谢要具体", "保留个人空间"} - base.GrowthTips = []string{"共同目标 + 问责", "每月复盘一次关系", "引入可信第三方建议"} - case "steady_warm": - base.Chemistry = "稳与暖互补:一方提供结构与可靠,另一方提供连接与温度。" - base.DeepOverview = fmt.Sprintf("%s与%s之间,最常见的张力是「要先讲清楚」还是「要先被看见」。若能轮流满足这两种需求,关系会既安全又有温度。", aName, bName) - base.CommDeep = "沟通协议:情绪话题先共鸣 2 分钟,再进入事实与方案;事务话题先结论,再补感受。" - base.ConflictDeep = "稳的一方别用沉默当结束;暖的一方别用追问升级压力。停火后用「我需要…」重开。" - base.IntimacyDeep = "暖的一方需要回应频率;稳的一方需要可预期的独处。把两者写进约定。" - base.GrowthDeep = "把互补写成分工:谁更擅长安抚,谁更擅长推进落地。" - base.ChemistryPoints = []string{"结构 × 温度", "可靠 × 连接", "可形成完整支持系统"} - base.Watchouts = []string{"一方觉得被冷落", "一方觉得被情绪淹没", "节奏错位积累委屈"} - base.CommTips = []string{"情绪先共鸣再方案", "事务先结论再感受", "用文字确认关键约定"} - base.ConflictTips = []string{"禁止用沉默结束话题", "追问前先问是否方便", "停火词机制"} - base.IntimacyTips = []string{"约定回应窗口", "尊重独处不被解读为冷淡", "每周一次深度连接"} - base.GrowthTips = []string{"按优势分工", "互相学习对方语言", "月度关系复盘"} - case "drive_steady": - base.Chemistry = "推与稳互补:一方破局加速,另一方把关质量与可持续。" - base.DeepOverview = "行动派容易嫌分析派慢;稳健派容易嫌行动派莽。把「速度」用在试验,「稳健」用在关键承诺,冲突会下降。" - base.CommDeep = "行动方给时间盒与最小方案;稳健方在时限内给风险清单,而不是无限延期。" - base.ConflictDeep = "冲突焦点常是节奏。先对齐「这是可逆试验还是重大决定」,再选速度。" - base.IntimacyDeep = "行动方用陪伴质量弥补碎片时间;稳健方减少用担忧浇灭热情,改用「我支持你试,我们设检查点」。" - base.GrowthDeep = "共同项目里明确角色:谁启动、谁验收、何时复盘。" - base.ChemistryPoints = []string{"破局 × 把关", "速度 × 质量", "试验与承诺可分工"} - base.Watchouts = []string{"节奏互斥", "一方压抑热情", "一方焦虑失控"} - base.CommTips = []string{"先定义可逆/不可逆", "时间盒决策", "风险清单限时"} - base.ConflictTips = []string{"争论节奏前先分类问题", "避免人格化指责", "用检查点代替否决"} - base.IntimacyTips = []string{"质量陪伴", "支持试验+检查点", "庆祝小进展"} - base.GrowthTips = []string{"项目角色清晰", "复盘节奏", "互相翻译动机"} - case "drive_warm": - base.Chemistry = "驱动与连接互补:一方带节奏,另一方维系人心与氛围。" - base.DeepOverview = "热情与效率碰到一起很有火花,也容易在「推进」与「照顾感受」之间拉扯。约定场景切换:冲刺模式 / 连接模式。" - base.CommDeep = "冲刺时短讯同步进度;连接时关掉任务话题。不要用效率语言处理情绪时刻。" - base.ConflictDeep = "驱动方避免「你想太多」;连接方避免「你只在乎结果」。改说具体需求。" - base.IntimacyDeep = "用共同体验(运动、活动)同时满足推进感与连接感。" - base.GrowthDeep = "轮流做「本周关系主理人」,负责安排一次连接或一次共同目标。" - base.ChemistryPoints = []string{"推进力 × 氛围", "行动号召力强", "共同体验易充电"} - base.Watchouts = []string{"情绪被效率压过", "承诺过多难兑现", "连接变任务化"} - base.CommTips = []string{"模式切换:冲刺/连接", "情绪时刻禁用效率话术", "进度短讯化"} - base.ConflictTips = []string{"禁止否定感受", "需求具体化", "修复后再推进"} - base.IntimacyTips = []string{"共同体验", "兑现小承诺", "非任务陪伴"} - base.GrowthTips = []string{"轮值关系主理人", "控制并行承诺", "庆祝与复盘并重"} - case "same_family": - base.DeepOverview = "你们属于相近气质族,容易互相理解,也要主动制造一点建设性差异,避免舒适区停滞。" - base.CommDeep = "沟通顺畅时更要确认细节,防止「好像说好了」其实理解不同。" - base.ConflictDeep = "冲突可能被淡化。强制做一次「最担心的三件事」互换。" - base.IntimacyDeep = "在舒适之外增加挑战性共同任务,刷新关系动能。" - base.GrowthDeep = "互相指出对方一个盲区,并约定本月各改一项小行为。" - base.ChemistryPoints = []string{"气质相近", "理解门槛低", "协作起步快"} - base.Watchouts = []string{"舒适区停滞", "细节默认错误", "回避尖锐议题"} - base.CommTips = []string{"确认细节", "书面关键约定", "鼓励异议"} - base.ConflictTips = []string{"互换担忧清单", "不假性和好", "设讨论截止"} - base.IntimacyTips = []string{"共同挑战任务", "新鲜体验", "具体感谢"} - base.GrowthTips = []string{"互指一个盲区", "月改一小行为", "外部输入"} - default: - base.DeepOverview = fmt.Sprintf("%s与%s风格路径不同,说明书价值更高。先承认差异合法,再谈协作规则。", aName, bName) - base.CommDeep = "建立双通道:事实通道与感受通道,讨论前先声明走哪一条。" - base.ConflictDeep = "冲突时回到共同目标句:我们都希望关系更好/事情做成。" - base.IntimacyDeep = "用定期同步取代猜测;空间与连接都要有配额。" - base.GrowthDeep = "把差异写成「我擅长 / 我需要」对照表,贴在看得见的地方。" - base.ChemistryPoints = []string{"视角多样", "可互补决策", "扩展彼此舒适区"} - base.Watchouts = []string{"误解成本高", "价值观冲突需早谈", "节奏长期错位"} - base.CommTips = []string{"声明沟通通道", "复述再回应", "关键约定书面化"} - base.ConflictTips = []string{"回到共同目标", "停火机制", "一次只谈一个议题"} - base.IntimacyTips = []string{"定期同步", "空间与连接配额", "具体肯定"} - base.GrowthTips = []string{"擅长/需要对照表", "月度复盘", "小步共同目标"} - } - return base -} - -func harmonyIndex(dims []map[string]any) int { - if len(dims) == 0 { - return 72 - } - sum := 0 - for _, d := range dims { - gap := asInt(d["gap"]) - sum += 100 - gap*4 - } - avg := sum / len(dims) - if avg < 45 { - return 45 - } - if avg > 96 { - return 96 - } - return avg -} - -func fitFromHarmony(score int, aLabel, bLabel string) (string, []string) { - switch { - case score >= 82: - return "默契互补型", []string{ - fmt.Sprintf("%s与%s节奏接近,适合共同推进小事。", aLabel, bLabel), - "把欣赏说出口,默契会更稳。", - "每周留一次轻松同步,不必每次谈大事。", - } - case score >= 68: - return "磨合成长型", []string{ - "差异可见,正好写成相处说明书。", - "冲突时先复述再提方案。", - "共同目标写清楚,减少猜忌。", - } - default: - return "反差探索型", []string{ - "反差大不等于不合,关键是边界与节奏。", - "重要约定尽量具体、可检查。", - "给彼此独处充电的空间。", - } - } -} - -func starPairNote(a, b string) string { - if a == b { - return fmt.Sprintf("同为%s:容易共鸣,也要避免同质盲区。", a) - } - return fmt.Sprintf("%s × %s:节奏不同,适合「我负责启动 / 你负责收尾」式分工。", a, b) -} - -func str(v any) string { - s, _ := v.(string) - return s -} - -func strSlice(v any) []string { - arr, ok := v.([]string) - if ok { - return arr - } - raw, ok := v.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(raw)) - for _, x := range raw { - if s, ok := x.(string); ok { - out = append(out, s) - } - } - return out -} - -func firstNonEmpty(a, b string) string { - if a != "" { - return a - } - return b -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/apps/api/internal/relation/helpers.go b/apps/api/internal/relation/helpers.go new file mode 100644 index 0000000..b9d9425 --- /dev/null +++ b/apps/api/internal/relation/helpers.go @@ -0,0 +1,66 @@ +package relation + +import "fmt" + +func relationTypePack(t, aName, bName string) (label, body string, bullets []string) { + switch t { + case "partner", "恋人", "伴侣": + return "伴侣", + fmt.Sprintf("%s与%s更适合把差异写成「亲密说明书」:欲望、节奏与安全感都说清楚。", aName, bName), + []string{"每周一次情绪复盘,不谈对错", "亲密请求用「我需要」句式", "边界:疲惫时先暂停再继续"} + case "family", "家人", "父母", "亲子": + return "家人", + fmt.Sprintf("家人关系里,%s与%s容易把旧角色带进新对话。试着把对方当「现在的人」而不是旧剧本。", aName, bName), + []string{"少用「你总是」句式", "大事拆成可协商的小请求", "保留各自的私人空间"} + case "friend", "朋友": + return "朋友", + fmt.Sprintf("友情里%s与%s可以更轻松地互补:约会期待与回应频率说开即可。", aName, bName), + []string{"约见用明确时间,减少猜测", "忙时用短消息保持连结", "冲突后用玩笑或直接道歉都行,别冷处理太久"} + default: + return "", "", nil + } +} + +func starPairNote(a, b string) string { + if a == b { + return fmt.Sprintf("同为%s:容易共鸣,也要避免同质盲区。", a) + } + return fmt.Sprintf("%s × %s:节奏不同,适合「我负责启动 / 你负责收尾」式分工。", a, b) +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func strSlice(v any) []string { + arr, ok := v.([]string) + if ok { + return arr + } + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, x := range raw { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/apps/api/internal/relation/scores.go b/apps/api/internal/relation/scores.go new file mode 100644 index 0000000..32b6519 --- /dev/null +++ b/apps/api/internal/relation/scores.go @@ -0,0 +1,123 @@ +package relation + +import "fmt" + +type dimScore struct { + Score int + Teaser string +} + +func dimMap(v any) map[string]dimScore { + out := map[string]dimScore{} + arr, ok := v.([]map[string]any) + if !ok { + // Build() uses []map[string]any — also tolerate []any + raw, ok2 := v.([]any) + if !ok2 { + return out + } + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + key := str(m["key"]) + out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} + } + return out + } + for _, m := range arr { + key := str(m["key"]) + out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])} + } + return out +} + +func asInt(v any) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + default: + return 0 + } +} + +func dimTitle(key string) string { + switch key { + case "personality": + return "性格特点" + case "communication": + return "沟通方式" + case "relation": + return "关系模式" + case "career": + return "事业节奏" + case "emotion": + return "情绪调节" + case "lifestyle": + return "生活节奏" + default: + return key + } +} + +func dimNote(key string, gap int, aName, bName string) string { + if gap > 12 { + return fmt.Sprintf("在「%s」上,%s 分值更高,适合由 %s 多给结构,%s 多给弹性。", dimTitle(key), aName, aName, bName) + } + if gap < -12 { + return fmt.Sprintf("在「%s」上,%s 分值更高,相处时可多尊重 %s 的节奏。", dimTitle(key), bName, bName) + } + return fmt.Sprintf("在「%s」上双方接近,容易形成默契,也要防止都默认对方「应该懂」。", dimTitle(key)) +} + +func firstTeaser(m map[string]dimScore, key string) string { + if d, ok := m[key]; ok && d.Teaser != "" { + return d.Teaser + } + return "表达方式各有节奏" +} + +func harmonyIndex(dims []map[string]any) int { + if len(dims) == 0 { + return 72 + } + sum := 0 + for _, d := range dims { + gap := asInt(d["gap"]) + sum += 100 - gap*4 + } + avg := sum / len(dims) + if avg < 45 { + return 45 + } + if avg > 96 { + return 96 + } + return avg +} + +func fitFromHarmony(score int, aLabel, bLabel string) (string, []string) { + switch { + case score >= 82: + return "默契互补型", []string{ + fmt.Sprintf("%s与%s节奏接近,适合共同推进小事。", aLabel, bLabel), + "把欣赏说出口,默契会更稳。", + "每周留一次轻松同步,不必每次谈大事。", + } + case score >= 68: + return "磨合成长型", []string{ + "差异可见,正好写成相处说明书。", + "冲突时先复述再提方案。", + "共同目标写清楚,减少猜忌。", + } + default: + return "反差探索型", []string{ + "反差大不等于不合,关键是边界与节奏。", + "重要约定尽量具体、可检查。", + "给彼此独处充电的空间。", + } + } +} diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go new file mode 100644 index 0000000..bda8b84 --- /dev/null +++ b/apps/api/internal/service/membership/service.go @@ -0,0 +1,68 @@ +// Package membership handles growth membership status and mock commerce orders. +package membership + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service is membership + order use-cases (extracted from report service). +type Service struct { + Reports *repository.ReportRepo +} + +// CreateOrderInput for commerce. +type CreateOrderInput struct { + Kind string + Plan string + ReportID *uuid.UUID +} + +// CreateOrder starts membership or deep_access order. +func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) { + if in.Kind != "membership" && in.Kind != "deep_access" { + return uuid.Nil, errors.New("invalid kind") + } + if in.Kind == "deep_access" && in.ReportID == nil { + return uuid.Nil, errors.New("report_id required") + } + amount := 990 + if in.Kind == "membership" { + amount = 2500 + } + return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount) +} + +// PayMock completes mock payment. +func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { + return s.Reports.PayMock(ctx, userID, orderID) +} + +// Me is the public membership snapshot. +type Me struct { + Active bool `json:"active"` + Plan string `json:"plan,omitempty"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AskQuotaLeft int `json:"ask_quota_left,omitempty"` +} + +// Get returns current growth membership for the user. +func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*Me, error) { + row, err := s.Reports.GetMembership(ctx, userID) + if err != nil { + return nil, err + } + return &Me{ + Active: row.Active, + Plan: row.Plan, + Status: row.Status, + ExpiresAt: row.ExpiresAt, + AskQuotaLeft: row.AskQuotaLeft, + }, nil +} diff --git a/apps/api/internal/service/report/service.go b/apps/api/internal/service/report/service.go index 2ca617f..b6c8682 100644 --- a/apps/api/internal/service/report/service.go +++ b/apps/api/internal/service/report/service.go @@ -257,54 +257,3 @@ func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *m } return rep, nil } - -// CreateOrderInput for commerce. -type CreateOrderInput struct { - Kind string - Plan string - ReportID *uuid.UUID -} - -// CreateOrder starts membership or deep_access order. -func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) { - if in.Kind != "membership" && in.Kind != "deep_access" { - return uuid.Nil, errors.New("invalid kind") - } - if in.Kind == "deep_access" && in.ReportID == nil { - return uuid.Nil, errors.New("report_id required") - } - amount := 990 - if in.Kind == "membership" { - amount = 2500 - } - return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount) -} - -// PayMock completes mock payment. -func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { - return s.Reports.PayMock(ctx, userID, orderID) -} - -// MembershipMe is the public membership snapshot. -type MembershipMe struct { - Active bool `json:"active"` - Plan string `json:"plan,omitempty"` - Status string `json:"status"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - AskQuotaLeft int `json:"ask_quota_left,omitempty"` -} - -// GetMembership returns current growth membership for the user. -func (s *Service) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipMe, error) { - row, err := s.Reports.GetMembership(ctx, userID) - if err != nil { - return nil, err - } - return &MembershipMe{ - Active: row.Active, - Plan: row.Plan, - Status: row.Status, - ExpiresAt: row.ExpiresAt, - AskQuotaLeft: row.AskQuotaLeft, - }, nil -} diff --git a/apps/api/internal/star/engine.go b/apps/api/internal/star/engine.go index 14ef1c7..2447a60 100644 --- a/apps/api/internal/star/engine.go +++ b/apps/api/internal/star/engine.go @@ -1,12 +1,12 @@ -// Package star builds 星座 reports (natal chart · fortune · deep copy). +// Package star builds 星座 reports (natal chart · period outlook · deep copy). package star import ( "fmt" "time" - "github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune" "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" + "github.com/yuxingu/digital-psychology/apps/api/internal/star/outlook" ) // Output is free summary + gated detail. @@ -21,7 +21,7 @@ type BuildOpts struct { BirthTime *string BirthPlace *string Name string - AsOf time.Time // fortune anchor; zero = now + AsOf time.Time // period outlook anchor; zero = now } // Build generates StarProfile from birth date (compat wrapper). @@ -68,7 +68,7 @@ func BuildWith(opts BuildOpts) (Output, error) { if asOf.IsZero() { asOf = time.Now() } - fort := fortune.Build(chart, asOf) + fort := outlook.Build(chart, asOf) daily := fort.Daily planetsOut := make([]map[string]any, 0, len(chart.Planets)) @@ -114,8 +114,10 @@ func BuildWith(opts BuildOpts) (Output, error) { }, "planets": planetsOut, "aspects_preview": aspectPreview, - "fortune": fort.AsMap(), - "transits": fort.AsMap()["transits"], + // "fortune" kept for client compat; prefer "outlook" (ECR-002). + "fortune": fort.AsMap(), + "outlook": fort.AsMap(), + "transits": fort.AsMap()["transits"], "daily_soft": map[string]any{ "title": daily.Title, "focus": daily.Focus, "tip": daily.Tip, "energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label, @@ -169,169 +171,8 @@ func BuildWith(opts BuildOpts) (Output, error) { "behavior_pattern": pack.SunDeep, "relation_style": pack.RelationDeep, "growth_direction": pack.GrowthDeep, - "fortune_detail": fort.AsMap(), + "fortune_detail": fort.AsMap(), // compat + "outlook_detail": fort.AsMap(), } return Output{Summary: summary, Detail: detail}, nil } - -func signIndexOf(key string) int { - for i, s := range []string{ - "aries", "taurus", "gemini", "cancer", "leo", "virgo", - "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", - } { - if s == key { - return i - } - } - return 0 -} - -func planetBullets(chart natal.Chart) []string { - out := make([]string, 0, 6) - for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} { - for _, p := range chart.Planets { - if p.Key == key { - out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House)) - break - } - } - } - return out -} - -func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any { - ks := keywords - if len(ks) > 3 { - ks = ks[:3] - } - return map[string]any{ - "key": key, "title": title, "label": b.Sign, - "element": b.Element, "modality": b.Modality, - "teaser": teaser, "keywords": ks, - } -} - -func section(title, body string, bullets []string) map[string]any { - return map[string]any{"title": title, "body": body, "bullets": bullets} -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// SignLabels returns sun/moon/rise labels (uses natal engine). -func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) { - return SignLabelsPlace(birth, birthTime, nil) -} - -// SignLabelsPlace includes birth place for rising accuracy. -func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) { - c, err := natal.Compute(birth, birthTime, place) - if err != nil { - return "", "", "" - } - return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign -} - -// NatalChart exposes chart for relation/synastry. -func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) { - return natal.Compute(birth, birthTime, place) -} - -type signMeta struct { - Key, Label, Element, Modality string -} - -type pack struct { - Label, OneLiner, Overview, LifeTip string - Keywords []string - Scores map[string]int - SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string - SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string - SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string - Strengths, BlindSpots, Scripts []string - PlanWeek, PlanMonth, PlanLong string - FAQ []map[string]string -} - -var packs = map[string]pack{} - -func init() { - for _, s := range []signMeta{ - {"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"}, - {"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"}, - {"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"}, - {"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"}, - } { - packs[s.Key] = defaultPack(s) - } - packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。", - "你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。") - packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。", - "你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。") - packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。", - "信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。") - packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。", - "你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。") - packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。", - "热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。") - packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。", - "把「足够好」纳入标准,你会轻松很多,交付也会更快。") - packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。", - "协调是天赋。重要决定里请给自己一票,而不只是各方折中。") - packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。", - "信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。") - packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。", - "探索欲强。给自由一点结构,热情才能变成持续作品。") - packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。", - "担当是优势。学会求助与休息,攀登才可持续。") - packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。", - "独特视角是礼物。把想法翻译成别人跟得上的一步行动。") - packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。", - "共情是天赋。区分「理解」与「承包」,你会更稳。") -} - -func defaultPack(s signMeta) pack { - return pack{ - Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。", - Overview: "在星座框架里,你的表达与需求有独特侧重。", - LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。", - Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"}, - Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75}, - SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。", - CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。", - SunDeep: "太阳星座描述你的核心动机与自我表达。", - MoonDeep: "月亮星座指向情绪调节与安全感来源。", - RiseDeep: "上升星座影响别人对你的第一印象。", - RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。", - CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。", - GrowthDeep: "成长是认识模式后做可验证的小调整。", - SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"}, - MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"}, - RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"}, - RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"}, - CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"}, - GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"}, - Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"}, - BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"}, - Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"}, - PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。", - PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。", - PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。", - FAQ: []map[string]string{ - {"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"}, - {"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"}, - }, - } -} - -func enrich(base pack, label, one, overview string) pack { - base.Label = label - base.OneLiner = one - base.Overview = overview - base.Keywords = []string{label, "星座", "运势"} - return base -} diff --git a/apps/api/internal/star/helpers.go b/apps/api/internal/star/helpers.go new file mode 100644 index 0000000..37c067e --- /dev/null +++ b/apps/api/internal/star/helpers.go @@ -0,0 +1,75 @@ +package star + +import ( + "fmt" + "time" + + "github.com/yuxingu/digital-psychology/apps/api/internal/star/natal" +) + +func signIndexOf(key string) int { + for i, s := range []string{ + "aries", "taurus", "gemini", "cancer", "leo", "virgo", + "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", + } { + if s == key { + return i + } + } + return 0 +} + +func planetBullets(chart natal.Chart) []string { + out := make([]string, 0, 6) + for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} { + for _, p := range chart.Planets { + if p.Key == key { + out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House)) + break + } + } + } + return out +} + +func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any { + ks := keywords + if len(ks) > 3 { + ks = ks[:3] + } + return map[string]any{ + "key": key, "title": title, "label": b.Sign, + "element": b.Element, "modality": b.Modality, + "teaser": teaser, "keywords": ks, + } +} + +func section(title, body string, bullets []string) map[string]any { + return map[string]any{"title": title, "body": body, "bullets": bullets} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// SignLabels returns sun/moon/rise labels (uses natal engine). +func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) { + return SignLabelsPlace(birth, birthTime, nil) +} + +// SignLabelsPlace includes birth place for rising accuracy. +func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) { + c, err := natal.Compute(birth, birthTime, place) + if err != nil { + return "", "", "" + } + return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign +} + +// NatalChart exposes chart for relation/synastry. +func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) { + return natal.Compute(birth, birthTime, place) +} diff --git a/apps/api/internal/star/fortune/fortune.go b/apps/api/internal/star/outlook/outlook.go similarity index 99% rename from apps/api/internal/star/fortune/fortune.go rename to apps/api/internal/star/outlook/outlook.go index add12cc..5444e7c 100644 --- a/apps/api/internal/star/fortune/fortune.go +++ b/apps/api/internal/star/outlook/outlook.go @@ -1,5 +1,5 @@ -// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits. -package fortune +// Package outlook synthesizes daily/weekly/monthly/yearly/lifetime scores and transits. +package outlook import ( "fmt" diff --git a/apps/api/internal/star/fortune/fortune_test.go b/apps/api/internal/star/outlook/outlook_test.go similarity index 98% rename from apps/api/internal/star/fortune/fortune_test.go rename to apps/api/internal/star/outlook/outlook_test.go index ce0570c..679ceec 100644 --- a/apps/api/internal/star/fortune/fortune_test.go +++ b/apps/api/internal/star/outlook/outlook_test.go @@ -1,4 +1,4 @@ -package fortune +package outlook import ( "testing" diff --git a/apps/api/internal/star/packs.go b/apps/api/internal/star/packs.go new file mode 100644 index 0000000..c55724c --- /dev/null +++ b/apps/api/internal/star/packs.go @@ -0,0 +1,96 @@ +package star + +type signMeta struct { + Key, Label, Element, Modality string +} + +type pack struct { + Label, OneLiner, Overview, LifeTip string + Keywords []string + Scores map[string]int + SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string + SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string + SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string + Strengths, BlindSpots, Scripts []string + PlanWeek, PlanMonth, PlanLong string + FAQ []map[string]string +} + +var packs = map[string]pack{} + +func init() { + for _, s := range []signMeta{ + {"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"}, + {"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"}, + {"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"}, + {"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"}, + } { + packs[s.Key] = defaultPack(s) + } + packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。", + "你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。") + packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。", + "你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。") + packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。", + "信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。") + packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。", + "你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。") + packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。", + "热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。") + packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。", + "把「足够好」纳入标准,你会轻松很多,交付也会更快。") + packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。", + "协调是天赋。重要决定里请给自己一票,而不只是各方折中。") + packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。", + "信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。") + packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。", + "探索欲强。给自由一点结构,热情才能变成持续作品。") + packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。", + "担当是优势。学会求助与休息,攀登才可持续。") + packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。", + "独特视角是礼物。把想法翻译成别人跟得上的一步行动。") + packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。", + "共情是天赋。区分「理解」与「承包」,你会更稳。") +} + +func defaultPack(s signMeta) pack { + return pack{ + Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。", + Overview: "在星座框架里,你的表达与需求有独特侧重。", + LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。", + Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"}, + Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75}, + SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。", + CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。", + SunDeep: "太阳星座描述你的核心动机与自我表达。", + MoonDeep: "月亮星座指向情绪调节与安全感来源。", + RiseDeep: "上升星座影响别人对你的第一印象。", + RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。", + CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。", + GrowthDeep: "成长是认识模式后做可验证的小调整。", + SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"}, + MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"}, + RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"}, + RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"}, + CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"}, + GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"}, + Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"}, + BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"}, + Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"}, + PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。", + PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。", + PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。", + FAQ: []map[string]string{ + {"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"}, + {"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"}, + }, + } +} + +func enrich(base pack, label, one, overview string) pack { + base.Label = label + base.OneLiner = one + base.Overview = overview + base.Keywords = []string{label, "星座", "运势"} + return base +} diff --git a/apps/user-h5/src/api/client.ts b/apps/user-h5/src/api/client.ts index 6d78ae8..7adccbe 100644 --- a/apps/user-h5/src/api/client.ts +++ b/apps/user-h5/src/api/client.ts @@ -2,6 +2,7 @@ import { createClient, createBrowserAdapters } from '@yuxingu/sdk' /** Shared API client for user-h5 (proxied to Go in dev). */ export const api = createClient({ + // SDK paths already include `/api/v1/...`; with SPA base `/psy/` → `/psy/api/v1/...` baseURL: '/psy', adapters: createBrowserAdapters(), }) diff --git a/apps/user-h5/src/components/home/HomeArchiveStrip.vue b/apps/user-h5/src/components/home/HomeArchiveStrip.vue new file mode 100644 index 0000000..0b6373d --- /dev/null +++ b/apps/user-h5/src/components/home/HomeArchiveStrip.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/apps/user-h5/src/components/home/HomeFeedSection.vue b/apps/user-h5/src/components/home/HomeFeedSection.vue new file mode 100644 index 0000000..b6e5a86 --- /dev/null +++ b/apps/user-h5/src/components/home/HomeFeedSection.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/apps/user-h5/src/components/home/HomePromoSection.vue b/apps/user-h5/src/components/home/HomePromoSection.vue new file mode 100644 index 0000000..673e9a6 --- /dev/null +++ b/apps/user-h5/src/components/home/HomePromoSection.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/user-h5/src/components/home/HomeSelfCard.vue b/apps/user-h5/src/components/home/HomeSelfCard.vue new file mode 100644 index 0000000..f96d00e --- /dev/null +++ b/apps/user-h5/src/components/home/HomeSelfCard.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/apps/user-h5/src/components/home/HomeToolGrid.vue b/apps/user-h5/src/components/home/HomeToolGrid.vue new file mode 100644 index 0000000..5e5f434 --- /dev/null +++ b/apps/user-h5/src/components/home/HomeToolGrid.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/apps/user-h5/src/components/home/HomeTopBar.vue b/apps/user-h5/src/components/home/HomeTopBar.vue new file mode 100644 index 0000000..147d511 --- /dev/null +++ b/apps/user-h5/src/components/home/HomeTopBar.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryFeatureCards.vue b/apps/user-h5/src/components/synastry/SynastryFeatureCards.vue new file mode 100644 index 0000000..aa43691 --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryFeatureCards.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryLanding.vue b/apps/user-h5/src/components/synastry/SynastryLanding.vue new file mode 100644 index 0000000..3d71931 --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryLanding.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/apps/user-h5/src/components/synastry/SynastryResult.vue b/apps/user-h5/src/components/synastry/SynastryResult.vue new file mode 100644 index 0000000..7473842 --- /dev/null +++ b/apps/user-h5/src/components/synastry/SynastryResult.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/apps/user-h5/src/composables/useHomeMood.ts b/apps/user-h5/src/composables/useHomeMood.ts new file mode 100644 index 0000000..d920197 --- /dev/null +++ b/apps/user-h5/src/composables/useHomeMood.ts @@ -0,0 +1,39 @@ +import { computed } from 'vue' + +const moodTexts = [ + '今天心态平稳,遇到小波折也能慢慢化解,适合把一件小事做完。', + '精力在回升,适合温和推进计划,不必一次做完所有事。', + '情绪有起伏很正常,给自己一点空隙,会更清楚下一步。', + '今天利于沟通与整理思路,可以从身边亲近的人开始。', + '节奏偏慢也没关系,把注意力放在身体感受上会更踏实。', +] + +const dimColors = ['#ff7a9a', '#ff9a5c', '#5b9cff', '#3ecfcf', '#a78bfa'] + +function daySeed(): number { + const n = new Date() + return n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate() +} + +function clamp(n: number) { + return Math.max(40, Math.min(95, n)) +} + +function moodFromSeed(seed: number) { + const score = 58 + (seed % 37) + const text = moodTexts[seed % moodTexts.length] + const base = [70, 62, 68, 64, 66] + const dims = [ + { key: 'love', label: '爱情', score: clamp(base[0] + (seed % 17) - 8), color: dimColors[0] }, + { key: 'wealth', label: '财富', score: clamp(base[1] + ((seed >> 2) % 19) - 9), color: dimColors[1] }, + { key: 'career', label: '事业', score: clamp(base[2] + ((seed >> 3) % 15) - 7), color: dimColors[2] }, + { key: 'learn', label: '学习', score: clamp(base[3] + ((seed >> 4) % 21) - 10), color: dimColors[3] }, + { key: 'social', label: '人际', score: clamp(base[4] + ((seed >> 5) % 13) - 6), color: dimColors[4] }, + ] + return { score, text, dims } +} + +/** Deterministic daily mood card for home self-card. */ +export function useHomeMood() { + return computed(() => moodFromSeed(daySeed())) +} diff --git a/apps/user-h5/src/composables/useHomePage.ts b/apps/user-h5/src/composables/useHomePage.ts new file mode 100644 index 0000000..869379d --- /dev/null +++ b/apps/user-h5/src/composables/useHomePage.ts @@ -0,0 +1,48 @@ +import { computed, ref } from 'vue' +import { useRouter } from 'vue-router' +import { AnalyticsEvent, track } from '../lib/analytics' +import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog' +import { useHomeMood } from './useHomeMood' + +export function useHomePage() { + const router = useRouter() + const profileLabel = ref('自己') + const plusOpen = ref(false) + const mood = useHomeMood() + + const searchHint = computed(() => { + const i = new Date().getHours() % homeSearchHints.length + return homeSearchHints[i] + }) + + function goPlus(kind: 'inviteFill' | 'add' | 'synastry') { + plusOpen.value = false + track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_plus', kind }) + if (kind === 'inviteFill') { + router.push({ path: '/profile', query: { inviteFill: '1' } }) + return + } + if (kind === 'add') { + router.push({ path: '/profile', query: { add: '1' } }) + return + } + router.push({ path: '/synastry', query: { invite: '1' } }) + } + + function trackGrid(label: string) { + track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_grid', label }) + } + + return { + router, + profileLabel, + plusOpen, + mood, + searchHint, + gridRow1: homeGridRow1, + gridRow2: homeGridRow2, + feeds: homeFeeds, + goPlus, + trackGrid, + } +} diff --git a/apps/user-h5/src/composables/useSynastryPage.ts b/apps/user-h5/src/composables/useSynastryPage.ts new file mode 100644 index 0000000..a493822 --- /dev/null +++ b/apps/user-h5/src/composables/useSynastryPage.ts @@ -0,0 +1,390 @@ +import { computed, onMounted, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import type { GrowthReport, Profile } from '@yuxingu/types' +import { validateBirth } from '@yuxingu/utils' +import { api } from '../api/client' +import type { WheelPlanet } from '../components/NatalWheel.vue' +import { AnalyticsEvent, track } from '../lib/analytics' +import type { RelationSharePayload } from '../lib/shareLink' + +export type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay' + +export const relationTypes = ['伴侣', '朋友', '家人', '其他'] + +export const mainTabs: { key: MainTab; label: string }[] = [ + { key: 'compare', label: '比较' }, + { key: 'composite', label: '组合' }, + { key: 'davison', label: '时空' }, + { key: 'marks', label: '马克斯' }, + { key: 'overlay', label: '配对' }, +] + +export function useSynastryPage() { + const route = useRoute() + const router = useRouter() + + const loading = ref(false) + const adding = ref(false) + const paying = ref(false) + const inviting = ref(false) + const nearbyLoading = ref(false) + const error = ref('') + const nearbyHint = ref('') + const invitePath = ref('') + const inviteHighlight = ref(false) + const profiles = ref([]) + const nearby = ref<{ profile: Profile; distance_km: number }[]>([]) + const profileA = ref('') + const profileB = ref('') + const asOf = ref(new Date().toISOString().slice(0, 10)) + const report = ref(null) + const shareOpen = ref(false) + const ty = ref('') + const tm = ref('') + const td = ref('') + const tName = ref('TA') + const mainTab = ref('compare') + const subTab = ref<'natal' | 'prog'>('natal') + const marksWho = ref<'me' | 'other'>('me') + const relationType = ref('伴侣') + const showClassicSelects = ref(false) + const showQuickAdd = ref(false) + + const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value)) + + const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value)) + + const profileAName = computed(() => { + const p = profiles.value.find((x) => x.id === profileA.value) + return p?.display_name || '' + }) + + const selfInitial = computed(() => { + const name = profileAName.value || '我' + return name.slice(0, 1) + }) + + function profileInitial(p: Profile) { + return (p.display_name || 'TA').slice(0, 1) + } + + const summary = computed(() => (report.value?.summary || {}) as Record) + const detail = computed(() => (report.value?.detail || null) as Record | null) + const charts = computed(() => (summary.value.charts || {}) as Record) + const headline = computed(() => String(summary.value.headline || '')) + const oneLiner = computed(() => String(summary.value.one_liner || '')) + const love = computed(() => Number(summary.value.love_index || 0)) + const friend = computed(() => Number(summary.value.friend_index || 0)) + const marriage = computed(() => Number(summary.value.marriage_index || 0)) + const loveNote = computed(() => String(summary.value.love_note || '')) + const asOfLabel = computed(() => String(summary.value.as_of || asOf.value)) + + function asPlanets(raw: unknown): WheelPlanet[] { + if (!Array.isArray(raw)) return [] + return raw.map((p) => { + const o = p as Record + return { + key: String(o.key), + title: String(o.title), + sign: String(o.sign), + degree: String(o.degree), + house: Number(o.house), + lon: Number(o.lon), + element: o.element != null ? String(o.element) : undefined, + modality: o.modality != null ? String(o.modality) : undefined, + } + }) + } + + function chartPlanets(key: 'chart_a' | 'chart_b'): WheelPlanet[] { + const c = summary.value[key] + if (!c || typeof c !== 'object') return [] + return asPlanets((c as { planets?: unknown }).planets) + } + + const planetsA = computed(() => chartPlanets('chart_a')) + const planetsB = computed(() => chartPlanets('chart_b')) + const ascA = computed(() => { + const c = summary.value.chart_a as { asc_lon?: number } | undefined + return typeof c?.asc_lon === 'number' ? c.asc_lon : null + }) + const ascB = computed(() => { + const c = summary.value.chart_b as { asc_lon?: number } | undefined + return typeof c?.asc_lon === 'number' ? c.asc_lon : null + }) + const aspectPreview = computed(() => { + const raw = summary.value.aspects_preview + return Array.isArray(raw) ? (raw as { label: string }[]) : [] + }) + + function chartBlock(key: string): Record | null { + const c = charts.value[key] + if (!c || typeof c !== 'object') return null + return c as Record + } + + function chartTip(key: string): string { + const c = chartBlock(key) + return String(c?.tip || '') + } + + const activeChartKey = computed(() => { + if (mainTab.value === 'composite') { + return subTab.value === 'prog' ? 'composite_progressed' : 'composite' + } + if (mainTab.value === 'davison') { + return subTab.value === 'prog' ? 'davison_progressed' : 'davison' + } + if (mainTab.value === 'marks') { + if (subTab.value === 'prog') return 'marks_progressed' + return marksWho.value === 'other' ? 'marks_other' : 'marks_me' + } + return '' + }) + + const activePlanets = computed(() => { + const c = chartBlock(activeChartKey.value) + return asPlanets(c?.planets) + }) + const activeAsc = computed(() => { + const c = chartBlock(activeChartKey.value) + return typeof c?.asc_lon === 'number' ? (c.asc_lon as number) : null + }) + const activeAspectPreview = computed(() => { + const c = chartBlock(activeChartKey.value) + const raw = c?.aspects_preview + return Array.isArray(raw) ? (raw as { label: string }[]) : [] + }) + const activeChartTip = computed(() => chartTip(activeChartKey.value)) + + const overlayTip = computed(() => String(chartBlock('overlay')?.tip || '')) + const overlayEntries = computed(() => { + const raw = chartBlock('overlay')?.entries + return Array.isArray(raw) + ? (raw as { planet: string; sign: string; house: number; house_tip: string }[]) + : [] + }) + + const fullAspects = computed(() => { + const raw = detail.value?.aspects + return Array.isArray(raw) ? (raw as { label: string }[]) : [] + }) + const sections = computed(() => { + const raw = detail.value?.sections + return Array.isArray(raw) ? (raw as { title: string; body: string }[]) : [] + }) + const sharePayload = computed(() => { + if (!report.value) return null + return { + type: 'relation', + me: String(summary.value.me_name || '我'), + other: String(summary.value.other_name || 'TA'), + diff: headline.value, + keywords: [`恋爱${love.value}`, `友情${friend.value}`, `婚姻${marriage.value}`], + } + }) + + function birthLabel(d?: string) { + if (!d) return '' + return String(d).slice(0, 10) + } + + async function loadProfiles() { + try { + const res = await api.listProfiles() + profiles.value = res.items || [] + const self = profiles.value.find((p) => p.relation === 'self') + if (self && !profileA.value) profileA.value = self.id + const other = profiles.value.find((p) => p.id !== profileA.value) + if (other && !profileB.value) profileB.value = other.id + } catch (e) { + error.value = e instanceof Error ? e.message : '加载档案失败' + } + } + + async function addTemp() { + const y = Number(ty.value) + const m = Number(tm.value) + const d = Number(td.value) + const msg = validateBirth(y, m, d) + if (msg) { + error.value = msg + return + } + adding.value = true + error.value = '' + try { + const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` + const p = await api.createProfile({ + relation: 'other', + birth_date: birth, + display_name: tName.value || 'TA', + }) + await loadProfiles() + profileB.value = p.id + showQuickAdd.value = false + } catch (e) { + error.value = e instanceof Error ? e.message : '添加失败' + } finally { + adding.value = false + } + } + + async function generate() { + if (!profileA.value || !profileB.value) return + loading.value = true + error.value = '' + try { + report.value = await api.createSynastry(profileA.value, profileB.value, asOf.value) + mainTab.value = 'compare' + track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' }) + } catch (e) { + error.value = e instanceof Error ? e.message : '合盘失败' + } finally { + loading.value = false + } + } + + async function createInvite() { + if (!profileA.value) return + inviting.value = true + error.value = '' + try { + const res = await api.createSynastryInvite(profileA.value) + invitePath.value = res.path + track(AnalyticsEvent.SynastryInviteCreated, { token: res.token }) + } catch (e) { + error.value = e instanceof Error ? e.message : '邀请失败' + } finally { + inviting.value = false + } + } + + async function loadNearby() { + nearbyLoading.value = true + nearbyHint.value = '' + error.value = '' + try { + const pos = await new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('当前环境不支持定位')) + return + } + navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 }) + }) + const lat = pos.coords.latitude + const lng = pos.coords.longitude + const self = profiles.value.find((p) => p.relation === 'self') + if (self) { + await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng }) + } + const res = await api.listSynastryNearby(lat, lng, 50) + nearby.value = res.items || [] + nearbyHint.value = nearby.value.length + ? `找到 ${nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。` + : '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。' + track(AnalyticsEvent.SynastryNearbyOpened, { count: nearby.value.length }) + } catch (e) { + nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置' + } finally { + nearbyLoading.value = false + } + } + + async function buyDeep() { + if (!report.value) return + paying.value = true + track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' }) + try { + const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id }) + await api.payMock(order_id) + report.value = await api.getReport(report.value.id) + track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' }) + } catch (e) { + error.value = e instanceof Error ? e.message : '支付失败' + } finally { + paying.value = false + } + } + + function reset() { + report.value = null + } + + onMounted(async () => { + await loadProfiles() + if (route.query.invite === '1') { + inviteHighlight.value = true + if (profileA.value) { + await createInvite() + } else { + error.value = '请先完成自己的档案,再邀请好友合盘' + } + void router.replace({ path: '/synastry', query: {} }) + } + }) + + return { + loading, + adding, + paying, + inviting, + nearbyLoading, + error, + nearbyHint, + invitePath, + inviteHighlight, + profiles, + nearby, + profileA, + profileB, + asOf, + report, + shareOpen, + ty, + tm, + td, + tName, + mainTab, + subTab, + marksWho, + relationType, + showClassicSelects, + showQuickAdd, + needsSubTab, + pickableProfiles, + profileAName, + selfInitial, + profileInitial, + headline, + oneLiner, + love, + friend, + marriage, + loveNote, + asOfLabel, + planetsA, + planetsB, + ascA, + ascB, + aspectPreview, + chartTip, + activePlanets, + activeAsc, + activeAspectPreview, + activeChartTip, + overlayTip, + overlayEntries, + fullAspects, + sections, + sharePayload, + detail, + birthLabel, + addTemp, + generate, + createInvite, + loadNearby, + buyDeep, + reset, + } +} diff --git a/apps/user-h5/src/lib/homeCatalog.spec.ts b/apps/user-h5/src/lib/homeCatalog.spec.ts new file mode 100644 index 0000000..6632e96 --- /dev/null +++ b/apps/user-h5/src/lib/homeCatalog.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { homeFeeds, homeGridRow1, homeGridRow2 } from '../lib/homeCatalog' + +describe('homeCatalog', () => { + it('keeps 12-grid rows', () => { + expect(homeGridRow1).toHaveLength(6) + expect(homeGridRow2).toHaveLength(6) + }) + + it('keeps feed entries with routes', () => { + expect(homeFeeds.length).toBeGreaterThanOrEqual(4) + for (const f of homeFeeds) { + expect(f.to.startsWith('/')).toBe(true) + expect(f.title.length).toBeGreaterThan(0) + } + }) +}) diff --git a/apps/user-h5/src/lib/homeCatalog.ts b/apps/user-h5/src/lib/homeCatalog.ts new file mode 100644 index 0000000..a40fb9f --- /dev/null +++ b/apps/user-h5/src/lib/homeCatalog.ts @@ -0,0 +1,94 @@ +import type { HomeToolIconName } from '../components/HomeToolIcon.vue' + +export type HomeTool = { + to: string + icon: HomeToolIconName + label: string + badge?: string + badgeTone?: 'hot' | 'new' +} + +export type HomeFeed = { + to: string + icon: HomeToolIconName + title: string + meta: string + stat: string + tone: string + tag?: string +} + +/** 对标测测 12 宫格 · 软立体图标 */ +export const homeGridRow1: HomeTool[] = [ + { to: '/scales/mbti-lite', icon: 'mbti', label: '人格测试' }, + { to: '/star', icon: 'star', label: '星座' }, + { to: '/portrait', icon: 'portrait', label: '愈心解码', badge: '热', badgeTone: 'hot' }, + { to: '/rhythm', icon: 'rhythm', label: '身心节律' }, + { to: '/synastry', icon: 'synastry', label: '合盘', badge: '新', badgeTone: 'new' }, + { to: '/star', icon: 'astro', label: '星象性格' }, +] + +export const homeGridRow2: HomeTool[] = [ + { to: '/companion', icon: 'companion', label: '节气陪伴' }, + { to: '/ask', icon: 'ask', label: 'AI问答' }, + { to: '/cards', icon: 'cards', label: '意象卡片' }, + { to: '/reports', icon: 'reports', label: '成长报告', badge: '新', badgeTone: 'new' }, + { to: '/growth-plan', icon: 'growth', label: '成长计划' }, + { to: '/relation', icon: 'relation', label: '人格匹配' }, +] + +export const homeFeeds: HomeFeed[] = [ + { + to: '/portrait', + icon: 'portrait', + title: '愈心解码', + meta: '一个生日,读懂性格与身心节奏', + stat: '核心入口', + tone: 'fc-e', + tag: '热', + }, + { + to: '/ask', + icon: 'ask', + title: 'AI 成长助手', + meta: '结合档案聊聊卡住的事', + stat: '随时可问', + tone: 'fc-a', + tag: 'AI', + }, + { + to: '/star', + icon: 'star', + title: '星座排盘', + meta: '本命盘 · 相位 · 日周月运势', + stat: '本周热门', + tone: 'fc-b', + tag: '新', + }, + { + to: '/synastry', + icon: 'synastry', + title: '合盘', + meta: '恋爱 / 友情 / 婚姻指数', + stat: '了解彼此', + tone: 'fc-c', + }, + { + to: '/scales/mbti-lite', + icon: 'mbti', + title: '人格测试', + meta: '16 型人格,看见自己的相处模式', + stat: '热门测评', + tone: 'fc-a', + }, + { + to: '/membership', + icon: 'growth', + title: '成长会员', + meta: '深度报告与全年节气陪伴', + stat: '解锁更多', + tone: 'fc-e', + }, +] + +export const homeSearchHints = ['探索今日心情', '愈心解码', '合盘了解彼此', 'AI 成长助手'] diff --git a/apps/user-h5/src/pages/HomePage.vue b/apps/user-h5/src/pages/HomePage.vue index e9a4414..b095e64 100644 --- a/apps/user-h5/src/pages/HomePage.vue +++ b/apps/user-h5/src/pages/HomePage.vue @@ -5,349 +5,49 @@