refactor(ECR-001): 接入 ESS 并完成结构对齐 Phase A–E
绑定 ESS 双轨治理,拆分超大 H5 页与 Go 引擎,抽出 membership 服务, 并将 star/fortune 重命名为 outlook(JSON 双写兼容);同时修复 /psy API 代理与首页 + 菜单层级。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 治理重构」诉求,否决。
|
||||||
@@ -7,22 +7,32 @@
|
|||||||
|
|
||||||
## Packages under `internal/service/`
|
## Packages under `internal/service/`
|
||||||
|
|
||||||
| Package | 职责 | P1 |
|
| Package | 职责 | P1 | 实现态(2026-08) |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `user` | 注册/游客升级/Session | Yes |
|
| `user` | 注册/游客升级/Session | Yes | 多在 profile/auth 路径;无独立包名时勿重复造轮 |
|
||||||
| `profile` | 个人档案 CRUD、切换 | Yes |
|
| `profile` | 个人档案 CRUD、切换 | Yes | `service/profile` |
|
||||||
| `portrait` | 生成个人画像 → GrowthReport | Yes |
|
| `portrait` | 生成个人画像 → GrowthReport | Yes | 计算在 `internal/portrait`;用例经 report/handler 路径 |
|
||||||
| `scale` | 探索测试定义与计分 | Yes |
|
| `scale` | 探索测试定义与计分 | Yes | `service/scale` + `internal/scale` |
|
||||||
| `relation` | RelationInsight | Yes **必做** |
|
| `relation` | RelationInsight | Yes **必做** | `service/relation` + `internal/relation` |
|
||||||
| `report` | GrowthReport 读取 + **权益裁剪** | Yes |
|
| `report` | GrowthReport 读取 + **权益裁剪** | Yes | `service/report` |
|
||||||
| `ask` | Thread/Message;P1 规则引擎 + 额度 | Yes(Demo) |
|
| `ask` | Thread/Message;P1 规则引擎 + 额度 | Yes(Demo) | `service/ask` + `internal/ask` |
|
||||||
| `companion` | SolarTerm 读、Mood 写 | Shell |
|
| `companion` | SolarTerm 读、Mood 写 | Shell | `service/companion` |
|
||||||
| `membership` | 成长会员状态与额度 | Yes |
|
| `membership` | 成长会员状态与额度 | Yes | `service/membership`(ECR-001 Phase C 已从 report 抽出) |
|
||||||
| `order` | Order + pay-mock + DeepAccess 发放 | Yes |
|
| `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/handler/`:每域一组 handler,只做 bind/validate/调用 service。
|
||||||
`internal/repository/`:SQL;无 business unlock 规则(规则在 service/report + membership)。
|
`internal/repository/`:SQL;无 business unlock 规则(规则在 service/report + membership)。
|
||||||
|
|
||||||
|
星座周期展望引擎包:`internal/star/outlook`(原 `fortune`,ECR-002)。对外 JSON 暂双写 `fortune`/`outlook` 键以兼容旧客户端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 跨域规则
|
## 跨域规则
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# ESS — machine-local root pointer(仓库内用 scripts 包装器)
|
||||||
|
.ess-root
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
@@ -33,3 +36,4 @@ tools/android-adb-docs
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.gstack/
|
||||||
|
|||||||
@@ -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**
|
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)
|
16. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB)
|
||||||
17. `prompts/` are optional helpers — not a substitute for rules above
|
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 <phase> --ecr ECR-xxx`
|
||||||
|
|
||||||
## Hard constraints
|
## Hard constraints
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,27 @@
|
|||||||
| `apps/mini-program` | Mini-program scaffold |
|
| `apps/mini-program` | Mini-program scaffold |
|
||||||
| `packages/*` | sdk / types / utils / ui |
|
| `packages/*` | sdk / types / utils / ui |
|
||||||
| `apps/docs/` | Human PRD / business docs (not a substitute for `.ai/`) |
|
| `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/<PROFILE>.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
|
## Commands
|
||||||
|
|
||||||
@@ -31,7 +52,7 @@ npm install && npm run dev:h5
|
|||||||
|
|
||||||
## Legacy
|
## 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
|
## Skill routing
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# Legacy 原型说明
|
# Legacy 原型说明
|
||||||
|
|
||||||
以下路径为重构前的静态 H5 原型,**默认只读**,新功能请在 `apps/user-h5` 与 `apps/api` 开发:
|
以下路径为重构前的静态 H5 原型,**默认只读 / 已删除勿恢复扩展**。新功能请在 `apps/user-h5` 与 `apps/api` 开发。
|
||||||
|
|
||||||
|
## 已删除(约 2026-08 · `main` `9f65c11`)
|
||||||
|
|
||||||
- `yuxingu.html`、`index.html`
|
- `yuxingu.html`、`index.html`
|
||||||
- `pages/`、`css/`、`js/`
|
- `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。
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
"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/internal/service/report"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportHandler exposes portrait reports and commerce mock.
|
// ReportHandler exposes portrait reports and commerce mock.
|
||||||
type ReportHandler struct {
|
type ReportHandler struct {
|
||||||
Svc *report.Service
|
Svc *report.Service
|
||||||
|
Membership *membership.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register mounts report/commerce routes.
|
// Register mounts report/commerce routes.
|
||||||
@@ -200,7 +202,7 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
me, err := h.Svc.GetMembership(c.Request.Context(), userID)
|
me, err := h.Membership.Get(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
|
||||||
return
|
return
|
||||||
@@ -233,7 +235,7 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
rid = &id
|
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,
|
Kind: req.Kind, Plan: req.Plan, ReportID: rid,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -255,7 +257,7 @@ func (h *ReportHandler) PayMock(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
|
||||||
return
|
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())
|
response.Fail(c, http.StatusBadRequest, 30004, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||||
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
||||||
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
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/profile"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
|
"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,
|
Reports: reportRepo,
|
||||||
Invites: &repository.SynastryInviteRepo{Pool: pool},
|
Invites: &repository.SynastryInviteRepo{Pool: pool},
|
||||||
}
|
}
|
||||||
|
membershipSvc := &membership.Service{Reports: reportRepo}
|
||||||
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||||
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
|
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
|
||||||
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
|
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 := api.Group("")
|
||||||
authed.Use(middleware.DeviceAuth(pool))
|
authed.Use(middleware.DeviceAuth(pool))
|
||||||
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
|
(&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.SynastryHandler{Svc: reportSvc}).Register(authed)
|
||||||
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
||||||
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
|
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -145,319 +145,3 @@ func BuildFull(aBirth, bBirth time.Time, aTime, bTime, aPlace, bPlace *string, a
|
|||||||
}
|
}
|
||||||
return Output{Summary: summary, Detail: detail}
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
"反差大不等于不合,关键是边界与节奏。",
|
||||||
|
"重要约定尽量具体、可检查。",
|
||||||
|
"给彼此独处充电的空间。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -257,54 +257,3 @@ func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *m
|
|||||||
}
|
}
|
||||||
return rep, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
package star
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"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/natal"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/star/outlook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Output is free summary + gated detail.
|
// Output is free summary + gated detail.
|
||||||
@@ -21,7 +21,7 @@ type BuildOpts struct {
|
|||||||
BirthTime *string
|
BirthTime *string
|
||||||
BirthPlace *string
|
BirthPlace *string
|
||||||
Name 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).
|
// Build generates StarProfile from birth date (compat wrapper).
|
||||||
@@ -68,7 +68,7 @@ func BuildWith(opts BuildOpts) (Output, error) {
|
|||||||
if asOf.IsZero() {
|
if asOf.IsZero() {
|
||||||
asOf = time.Now()
|
asOf = time.Now()
|
||||||
}
|
}
|
||||||
fort := fortune.Build(chart, asOf)
|
fort := outlook.Build(chart, asOf)
|
||||||
daily := fort.Daily
|
daily := fort.Daily
|
||||||
|
|
||||||
planetsOut := make([]map[string]any, 0, len(chart.Planets))
|
planetsOut := make([]map[string]any, 0, len(chart.Planets))
|
||||||
@@ -114,8 +114,10 @@ func BuildWith(opts BuildOpts) (Output, error) {
|
|||||||
},
|
},
|
||||||
"planets": planetsOut,
|
"planets": planetsOut,
|
||||||
"aspects_preview": aspectPreview,
|
"aspects_preview": aspectPreview,
|
||||||
"fortune": fort.AsMap(),
|
// "fortune" kept for client compat; prefer "outlook" (ECR-002).
|
||||||
"transits": fort.AsMap()["transits"],
|
"fortune": fort.AsMap(),
|
||||||
|
"outlook": fort.AsMap(),
|
||||||
|
"transits": fort.AsMap()["transits"],
|
||||||
"daily_soft": map[string]any{
|
"daily_soft": map[string]any{
|
||||||
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
|
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
|
||||||
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
|
"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,
|
"behavior_pattern": pack.SunDeep,
|
||||||
"relation_style": pack.RelationDeep,
|
"relation_style": pack.RelationDeep,
|
||||||
"growth_direction": pack.GrowthDeep,
|
"growth_direction": pack.GrowthDeep,
|
||||||
"fortune_detail": fort.AsMap(),
|
"fortune_detail": fort.AsMap(), // compat
|
||||||
|
"outlook_detail": fort.AsMap(),
|
||||||
}
|
}
|
||||||
return Output{Summary: summary, Detail: detail}, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
|
// Package outlook synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
|
||||||
package fortune
|
package outlook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package fortune
|
package outlook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createClient, createBrowserAdapters } from '@yuxingu/sdk'
|
|||||||
|
|
||||||
/** Shared API client for user-h5 (proxied to Go in dev). */
|
/** Shared API client for user-h5 (proxied to Go in dev). */
|
||||||
export const api = createClient({
|
export const api = createClient({
|
||||||
|
// SDK paths already include `/api/v1/...`; with SPA base `/psy/` → `/psy/api/v1/...`
|
||||||
baseURL: '/psy',
|
baseURL: '/psy',
|
||||||
adapters: createBrowserAdapters(),
|
adapters: createBrowserAdapters(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="archive reveal" style="--d: 40ms">
|
||||||
|
<router-link to="/portrait" class="av-self" aria-label="自己的档案">
|
||||||
|
<span class="av" aria-hidden="true">
|
||||||
|
<BrandLogo size="card" class="av-logo" />
|
||||||
|
</span>
|
||||||
|
<span class="av-name">自己</span>
|
||||||
|
</router-link>
|
||||||
|
<button type="button" class="av-add" aria-label="添加档案" @click="$emit('add')">
|
||||||
|
<span class="plus-dot" aria-hidden="true">+</span>
|
||||||
|
<span class="av-name">添加</span>
|
||||||
|
</button>
|
||||||
|
<router-link to="/profile" class="archive-tail" aria-label="档案列表">
|
||||||
|
<span class="list-ico" aria-hidden="true" />
|
||||||
|
<span class="chev" aria-hidden="true">›</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BrandLogo from '../BrandLogo.vue'
|
||||||
|
|
||||||
|
defineEmits<{ add: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.archive {
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 6px 20px rgba(229, 77, 66, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.av-self,
|
||||||
|
.av-add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.av-self:active,
|
||||||
|
.av-add:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.av {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
border: 2px solid rgba(229, 77, 66, 0.35);
|
||||||
|
}
|
||||||
|
.av-logo {
|
||||||
|
width: 22px !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.av-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8a4a3a;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.plus-dot {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 236, 230, 0.95);
|
||||||
|
border: 1.5px dashed rgba(229, 77, 66, 0.35);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 37px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.archive-tail {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #c4a090;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
.list-ico {
|
||||||
|
width: 14px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.list-ico::before,
|
||||||
|
.list-ico::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
.list-ico::before {
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
.list-ico::after {
|
||||||
|
top: 6.5px;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.reveal {
|
||||||
|
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<section class="section">
|
||||||
|
<div class="sec-head">
|
||||||
|
<span class="title">今日推荐</span>
|
||||||
|
<router-link class="more" to="/explore">更多</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="feed-grid">
|
||||||
|
<router-link
|
||||||
|
v-for="f in feeds"
|
||||||
|
:key="f.to + f.title"
|
||||||
|
:to="f.to"
|
||||||
|
class="feed-card"
|
||||||
|
:class="f.tone"
|
||||||
|
>
|
||||||
|
<span v-if="f.tag" class="feed-tag">{{ f.tag }}</span>
|
||||||
|
<div class="feed-cover" aria-hidden="true">
|
||||||
|
<span class="cover-orb" />
|
||||||
|
<HomeToolIcon :name="f.icon" :size="48" />
|
||||||
|
</div>
|
||||||
|
<div class="feed-body">
|
||||||
|
<div class="name">{{ f.title }}</div>
|
||||||
|
<div class="meta">{{ f.meta }}</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat">{{ f.stat }}</span>
|
||||||
|
<span class="go" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||||
|
import type { HomeFeed } from '../../lib/homeCatalog'
|
||||||
|
|
||||||
|
defineProps<{ feeds: HomeFeed[] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.sec-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.sec-head .title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.sec-head .more {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.sec-head .more::after {
|
||||||
|
content: ' ›';
|
||||||
|
}
|
||||||
|
.feed-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.feed-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 168px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
transition: transform var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.feed-card:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.feed-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 86px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-orb {
|
||||||
|
position: absolute;
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
border-radius: 50%;
|
||||||
|
right: -18px;
|
||||||
|
bottom: -28px;
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.feed-body {
|
||||||
|
padding: 10px 11px 12px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.feed-body .name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.feed-body .meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.stat-row .stat {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #c4c4c4;
|
||||||
|
}
|
||||||
|
.stat-row .go {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.feed-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-primary);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 2px 6px rgba(229, 77, 66, 0.25);
|
||||||
|
}
|
||||||
|
.fc-a .feed-cover {
|
||||||
|
background: linear-gradient(155deg, #ffd8d0, #ffb4a8);
|
||||||
|
color: #c23b32;
|
||||||
|
}
|
||||||
|
.fc-b .feed-cover {
|
||||||
|
background: linear-gradient(155deg, #e8dff8, #cbb8f0);
|
||||||
|
color: #5b4a8a;
|
||||||
|
}
|
||||||
|
.fc-c .feed-cover {
|
||||||
|
background: linear-gradient(155deg, #d8eaff, #b4d2f5);
|
||||||
|
color: #3a6fb0;
|
||||||
|
}
|
||||||
|
.fc-e .feed-cover {
|
||||||
|
background: linear-gradient(155deg, #ffe9cc, #f5cc8a);
|
||||||
|
color: #b07a28;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<section class="section promo-sec">
|
||||||
|
<div class="promo-pair">
|
||||||
|
<router-link to="/explore" class="promo p-plaza">
|
||||||
|
<div class="p-copy">
|
||||||
|
<div class="p-title">探索广场</div>
|
||||||
|
<div class="p-sub">测评 · 工具 · 自我理解</div>
|
||||||
|
</div>
|
||||||
|
<span class="p-deco" aria-hidden="true" />
|
||||||
|
<span class="p-chip">热</span>
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/membership" class="promo p-vip">
|
||||||
|
<div class="p-copy">
|
||||||
|
<div class="p-title">成长会员</div>
|
||||||
|
<div class="p-sub">深度报告与全年陪伴</div>
|
||||||
|
</div>
|
||||||
|
<span class="p-deco" aria-hidden="true" />
|
||||||
|
<span class="p-chip soft">新</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.promo-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.12fr 0.88fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.promo {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 14px;
|
||||||
|
min-height: 96px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.promo:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
.p-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.promo .p-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.promo .p-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(0, 0, 0, 0.42);
|
||||||
|
margin-top: 5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.p-deco {
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
bottom: -18px;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.p-plaza {
|
||||||
|
background: linear-gradient(145deg, #ffe9e2 0%, #ffd4c8 55%, #ffc8ba 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
|
||||||
|
}
|
||||||
|
.p-plaza .p-deco {
|
||||||
|
background: radial-gradient(circle at 35% 35%, #fff 0%, #ffb0a0 55%, transparent 70%);
|
||||||
|
}
|
||||||
|
.p-vip {
|
||||||
|
background: linear-gradient(145deg, #fff4e0 0%, #ffe8b8 55%, #ffd98a 100%);
|
||||||
|
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
|
||||||
|
}
|
||||||
|
.p-vip .p-deco {
|
||||||
|
background: radial-gradient(circle at 35% 35%, #fff 0%, #f0c86a 55%, transparent 70%);
|
||||||
|
}
|
||||||
|
.p-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.p-chip.soft {
|
||||||
|
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
|
||||||
|
}
|
||||||
|
.ai-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #ccc;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<section class="self-card reveal" style="--d: 80ms" @click="$emit('open-portrait')">
|
||||||
|
<div class="self-head">
|
||||||
|
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
|
||||||
|
{{ profileLabel }}
|
||||||
|
<span class="caret" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
<router-link class="more" to="/profile" @click.stop>更多</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="self-body">
|
||||||
|
<div class="mood-col">
|
||||||
|
<div class="mood-label">
|
||||||
|
今日心情 <em>{{ mood.score }}</em><span>分</span>
|
||||||
|
</div>
|
||||||
|
<p class="mood-text">{{ mood.text }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dims" role="list">
|
||||||
|
<div
|
||||||
|
v-for="d in mood.dims"
|
||||||
|
:key="d.key"
|
||||||
|
class="dim"
|
||||||
|
role="listitem"
|
||||||
|
:style="{ '--bar': d.color, '--h': d.score + '%' }"
|
||||||
|
>
|
||||||
|
<b class="dim-score">{{ d.score }}</b>
|
||||||
|
<div class="dim-track">
|
||||||
|
<i class="dim-fill" />
|
||||||
|
</div>
|
||||||
|
<span class="dim-label">{{ d.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
profileLabel: string
|
||||||
|
mood: {
|
||||||
|
score: number
|
||||||
|
text: string
|
||||||
|
dims: { key: string; label: string; score: number; color: string }[]
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'open-portrait': []
|
||||||
|
'open-profile': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.self-card {
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 14px 14px 16px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-hero);
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.self-card:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
.self-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.who-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.caret {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.self-head .more {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.self-head .more::after {
|
||||||
|
content: ' ›';
|
||||||
|
}
|
||||||
|
.self-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.mood-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.mood-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.mood-label em {
|
||||||
|
font-style: normal;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0 1px 0 4px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
vertical-align: -4px;
|
||||||
|
}
|
||||||
|
.mood-label span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mood-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dims {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
width: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.dim-score {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.dim-track {
|
||||||
|
width: 12px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: #f3ece8;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.dim-fill {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--h);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: linear-gradient(180deg, color-mix(in srgb, var(--bar) 70%, #fff) 0%, var(--bar) 100%);
|
||||||
|
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
|
||||||
|
animation: fillUp 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--d, 80ms) + 120ms);
|
||||||
|
}
|
||||||
|
@keyframes fillUp {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dim-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.reveal {
|
||||||
|
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal,
|
||||||
|
.dim-fill {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<section class="section grid-sec">
|
||||||
|
<div class="tool-scroll" aria-label="功能入口">
|
||||||
|
<div class="tool-rows">
|
||||||
|
<div class="tool-row">
|
||||||
|
<router-link
|
||||||
|
v-for="t in row1"
|
||||||
|
:key="t.to + t.label"
|
||||||
|
:to="t.to"
|
||||||
|
class="tool-item"
|
||||||
|
@click="$emit('track', t.label)"
|
||||||
|
>
|
||||||
|
<div class="icon" aria-hidden="true">
|
||||||
|
<HomeToolIcon :name="t.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="label">{{ t.label }}</div>
|
||||||
|
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="tool-row">
|
||||||
|
<router-link
|
||||||
|
v-for="t in row2"
|
||||||
|
:key="t.to + t.label"
|
||||||
|
:to="t.to"
|
||||||
|
class="tool-item"
|
||||||
|
@click="$emit('track', t.label)"
|
||||||
|
>
|
||||||
|
<div class="icon" aria-hidden="true">
|
||||||
|
<HomeToolIcon :name="t.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="label">{{ t.label }}</div>
|
||||||
|
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||||
|
import type { HomeTool } from '../../lib/homeCatalog'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
row1: HomeTool[]
|
||||||
|
row2: HomeTool[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ track: [label: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.section {
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.grid-sec {
|
||||||
|
padding-right: 0;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.tool-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-right: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.tool-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tool-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: max-content;
|
||||||
|
padding: 4px 0 2px;
|
||||||
|
}
|
||||||
|
.tool-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.tool-item {
|
||||||
|
width: 74px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.tool-item:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.tool-item .icon {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.tool-item .label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 7px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 650;
|
||||||
|
box-shadow: 0 2px 6px rgba(229, 77, 66, 0.25);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.badge.b-hot {
|
||||||
|
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||||
|
}
|
||||||
|
.badge.b-new {
|
||||||
|
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
|
||||||
|
box-shadow: 0 2px 6px rgba(74, 144, 226, 0.28);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<header class="top reveal" :class="{ 'top--menu-open': plusOpen }" style="--d: 0ms">
|
||||||
|
<router-link to="/growth-plan" class="ico-btn" aria-label="每日打卡">
|
||||||
|
<span class="ico-mark">日</span>
|
||||||
|
</router-link>
|
||||||
|
<button type="button" class="search" @click="$emit('search')">
|
||||||
|
<span class="search-ico" aria-hidden="true" />
|
||||||
|
<span class="search-ph">{{ searchHint }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="plus-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ico-btn"
|
||||||
|
aria-label="添加档案"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="plusOpen"
|
||||||
|
@click="$emit('update:plusOpen', !plusOpen)"
|
||||||
|
>
|
||||||
|
<span class="ico-mark plus">+</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="plusOpen" class="plus-mask" @click="$emit('update:plusOpen', false)" />
|
||||||
|
<div v-if="plusOpen" class="plus-menu" role="menu">
|
||||||
|
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'inviteFill')">
|
||||||
|
<span class="plus-ico pi-invite" aria-hidden="true" />
|
||||||
|
<span>邀请好友填档案</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'add')">
|
||||||
|
<span class="plus-ico pi-add" aria-hidden="true" />
|
||||||
|
<span>添加档案</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plus-item" role="menuitem" @click="$emit('plus', 'synastry')">
|
||||||
|
<span class="plus-ico pi-heart" aria-hidden="true" />
|
||||||
|
<span>邀请好友合盘</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
searchHint: string
|
||||||
|
plusOpen: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
search: []
|
||||||
|
'update:plusOpen': [value: boolean]
|
||||||
|
plus: [kind: 'inviteFill' | 'add' | 'synastry']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr 40px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px 0;
|
||||||
|
position: relative;
|
||||||
|
/* Must sit above .sheet (z-index: 3) or the + menu is covered */
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.top--menu-open {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.ico-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 14px rgba(180, 90, 70, 0.08);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.ico-btn:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.ico-mark {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ico-mark.plus {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.plus-wrap {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.plus-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.plus-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 176px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 12px 32px rgba(120, 60, 50, 0.16);
|
||||||
|
animation: plusPop 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.plus-menu::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: 14px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: inherit;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
@keyframes plusPop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.plus-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.plus-item:active {
|
||||||
|
background: rgba(229, 77, 66, 0.06);
|
||||||
|
}
|
||||||
|
.plus-ico {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.pi-invite {
|
||||||
|
background: linear-gradient(145deg, #5ecf8a, #3bb56e);
|
||||||
|
}
|
||||||
|
.pi-invite::after {
|
||||||
|
content: '邀';
|
||||||
|
}
|
||||||
|
.pi-add {
|
||||||
|
background: linear-gradient(145deg, #ffb45c, #f08a3a);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pi-add::after {
|
||||||
|
content: '档';
|
||||||
|
}
|
||||||
|
.pi-heart {
|
||||||
|
background: linear-gradient(145deg, #ff8aa8, #e54d7a);
|
||||||
|
}
|
||||||
|
.pi-heart::after {
|
||||||
|
content: '合';
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
padding: 0 14px 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.07);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
transition: background var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.search:active {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
.search-ico {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1.5px solid rgba(0, 0, 0, 0.28);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-ico::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 6px;
|
||||||
|
height: 1.5px;
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
border-radius: 1px;
|
||||||
|
right: -3px;
|
||||||
|
bottom: -1px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
.search-ph {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.36);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.reveal {
|
||||||
|
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: var(--d, 0ms);
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feature-pair">
|
||||||
|
<div class="feature-card fc-a">
|
||||||
|
<HomeToolIcon name="synastry" :size="40" />
|
||||||
|
<div class="fc-body">
|
||||||
|
<strong>多类别关系分析</strong>
|
||||||
|
<p>恋爱、友情、婚姻指数一次看清,五主盘与次限推运同屏对比。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card fc-b">
|
||||||
|
<HomeToolIcon name="relation" :size="40" />
|
||||||
|
<div class="fc-body">
|
||||||
|
<strong>TA 的雷点与建议</strong>
|
||||||
|
<p>相处深析与相位解读,帮你理解彼此差异与磨合方向。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feature-pair {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 140px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
.fc-a {
|
||||||
|
background: linear-gradient(155deg, #ffd8d0, #ffb4a8);
|
||||||
|
}
|
||||||
|
.fc-b {
|
||||||
|
background: linear-gradient(155deg, #d8eaff, #b4d2f5);
|
||||||
|
}
|
||||||
|
.fc-body strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fc-body p {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(0, 0, 0, 0.52);
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<div class="synastry-landing">
|
||||||
|
<h2 class="hero-title">看看你和 TA 的默契指数</h2>
|
||||||
|
|
||||||
|
<div class="rel-chips" role="group" aria-label="关系类型">
|
||||||
|
<button
|
||||||
|
v-for="r in relationTypes"
|
||||||
|
:key="r"
|
||||||
|
type="button"
|
||||||
|
class="rel-chip"
|
||||||
|
:class="{ on: relationType === r }"
|
||||||
|
@click="$emit('update:relationType', r)"
|
||||||
|
>
|
||||||
|
{{ r }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-pick">
|
||||||
|
<div class="pick-side self-side">
|
||||||
|
<div class="av-ring on">
|
||||||
|
<span class="av-inner">{{ selfInitial }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="pick-label">自己</span>
|
||||||
|
<span v-if="profileAName" class="pick-name">{{ profileAName }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="pick-link" aria-hidden="true">×</span>
|
||||||
|
<div class="pick-side ta-side">
|
||||||
|
<div class="scroll-profiles" role="list">
|
||||||
|
<button
|
||||||
|
v-for="p in pickableProfiles"
|
||||||
|
:key="p.id"
|
||||||
|
type="button"
|
||||||
|
class="profile-chip"
|
||||||
|
:class="{ on: profileB === p.id }"
|
||||||
|
role="listitem"
|
||||||
|
@click="$emit('update:profileB', p.id)"
|
||||||
|
>
|
||||||
|
<span class="av-ring sm" :class="{ on: profileB === p.id }">
|
||||||
|
<span class="av-inner sm">{{ profileInitial(p) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="chip-name">{{ p.display_name || '未命名' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="n in nearby"
|
||||||
|
:key="'n' + n.profile.id"
|
||||||
|
type="button"
|
||||||
|
class="profile-chip"
|
||||||
|
:class="{ on: profileB === n.profile.id }"
|
||||||
|
@click="$emit('update:profileB', n.profile.id)"
|
||||||
|
>
|
||||||
|
<span class="av-ring sm nearby" :class="{ on: profileB === n.profile.id }">
|
||||||
|
<span class="av-inner sm">附</span>
|
||||||
|
</span>
|
||||||
|
<span class="chip-name">{{ n.profile.display_name || '匿名' }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="profile-chip add-chip" @click="$emit('update:showQuickAdd', !showQuickAdd)">
|
||||||
|
<span class="av-ring sm add">
|
||||||
|
<span class="av-inner sm">+</span>
|
||||||
|
</span>
|
||||||
|
<span class="chip-name">添加档案</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showQuickAdd" class="yxg-card soft add-form">
|
||||||
|
<p class="card-title">快速添加 TA</p>
|
||||||
|
<BirthDateInputs :year="ty" :month="tm" :day="td" @update:year="$emit('update:ty', $event)" @update:month="$emit('update:tm', $event)" @update:day="$emit('update:td', $event)" />
|
||||||
|
<input :value="tName" class="name-in" placeholder="称呼(如:TA)" @input="$emit('update:tName', ($event.target as HTMLInputElement).value)" />
|
||||||
|
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="$emit('add-temp')">
|
||||||
|
{{ adding ? '添加中…' : '添加档案' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="yxg-btn yxg-btn-block cta-main"
|
||||||
|
type="button"
|
||||||
|
:disabled="loading || !profileA || !profileB"
|
||||||
|
@click="$emit('generate')"
|
||||||
|
>
|
||||||
|
{{ loading ? '合盘中…' : '立即合盘' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="toggle-classic" @click="$emit('update:showClassicSelects', !showClassicSelects)">
|
||||||
|
{{ showClassicSelects ? '收起档案选择' : '直接选择档案' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showClassicSelects" class="yxg-card soft classic-panel">
|
||||||
|
<label class="yxg-label">我(档案 A)</label>
|
||||||
|
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
|
||||||
|
<option disabled value="">请选择</option>
|
||||||
|
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
||||||
|
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="yxg-label">TA(档案 B)</label>
|
||||||
|
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
|
||||||
|
<option disabled value="">请选择</option>
|
||||||
|
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
|
||||||
|
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
||||||
|
</option>
|
||||||
|
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
|
||||||
|
附近 · {{ n.profile.display_name || '匿名' }} · {{ n.distance_km }}km
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="yxg-label">推运日期</label>
|
||||||
|
<input :value="asOf" type="date" class="sel" @input="$emit('update:asOf', ($event.target as HTMLInputElement).value)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="social-row" :class="{ pulse: inviteHighlight }">
|
||||||
|
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="$emit('create-invite')">
|
||||||
|
{{ inviting ? '生成中…' : '邀请好友合盘' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="$emit('load-nearby')">
|
||||||
|
{{ nearbyLoading ? '定位中…' : '附近的人' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="invitePath" class="yxg-meta invite-path">邀请链接:{{ invitePath }}(可复制分享)</p>
|
||||||
|
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
|
||||||
|
<p v-if="profiles.length < 2 && nearby.length === 0" class="yxg-meta">
|
||||||
|
需要至少两个档案。可先去
|
||||||
|
<router-link class="yxg-link" to="/profile">档案页</router-link>
|
||||||
|
添加 TA,或点「添加档案」快速创建。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SynastryFeatureCards />
|
||||||
|
|
||||||
|
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Profile } from '@yuxingu/types'
|
||||||
|
import BirthDateInputs from '../BirthDateInputs.vue'
|
||||||
|
import SynastryFeatureCards from './SynastryFeatureCards.vue'
|
||||||
|
import { relationTypes } from '../../composables/useSynastryPage'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
relationType: string
|
||||||
|
selfInitial: string
|
||||||
|
profileAName: string
|
||||||
|
pickableProfiles: Profile[]
|
||||||
|
profileB: string
|
||||||
|
nearby: { profile: Profile; distance_km: number }[]
|
||||||
|
showQuickAdd: boolean
|
||||||
|
ty: string
|
||||||
|
tm: string
|
||||||
|
td: string
|
||||||
|
tName: string
|
||||||
|
adding: boolean
|
||||||
|
loading: boolean
|
||||||
|
profileA: string
|
||||||
|
showClassicSelects: boolean
|
||||||
|
profiles: Profile[]
|
||||||
|
asOf: string
|
||||||
|
inviteHighlight: boolean
|
||||||
|
inviting: boolean
|
||||||
|
nearbyLoading: boolean
|
||||||
|
invitePath: string
|
||||||
|
nearbyHint: string
|
||||||
|
error: string
|
||||||
|
profileInitial: (p: Profile) => string
|
||||||
|
birthLabel: (d?: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:relationType': [value: string]
|
||||||
|
'update:profileB': [value: string]
|
||||||
|
'update:showQuickAdd': [value: boolean]
|
||||||
|
'update:ty': [value: string]
|
||||||
|
'update:tm': [value: string]
|
||||||
|
'update:td': [value: string]
|
||||||
|
'update:tName': [value: string]
|
||||||
|
'update:showClassicSelects': [value: boolean]
|
||||||
|
'update:profileA': [value: string]
|
||||||
|
'update:asOf': [value: string]
|
||||||
|
'add-temp': []
|
||||||
|
generate: []
|
||||||
|
'create-invite': []
|
||||||
|
'load-nearby': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.rel-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.rel-chip {
|
||||||
|
border: 1.5px solid #eee;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
.rel-chip.on {
|
||||||
|
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
|
||||||
|
}
|
||||||
|
.dual-pick {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.pick-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.self-side {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.ta-side {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.pick-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.pick-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.pick-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
max-width: 68px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.av-ring {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
|
||||||
|
}
|
||||||
|
.av-ring.sm {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.av-ring.on {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
|
||||||
|
}
|
||||||
|
.av-ring.add {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px dashed rgba(229, 77, 66, 0.35);
|
||||||
|
}
|
||||||
|
.av-ring.nearby {
|
||||||
|
background: linear-gradient(145deg, #e8f4ff, #b4d2f5);
|
||||||
|
}
|
||||||
|
.av-inner {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #8a4a3a;
|
||||||
|
}
|
||||||
|
.av-inner.sm {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.scroll-profiles {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 2px 0 4px;
|
||||||
|
}
|
||||||
|
.scroll-profiles::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.profile-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
.chip-name {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
max-width: 52px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.profile-chip.on .chip-name {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.add-form {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.name-in {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1.5px solid #eee;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.cta-main {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||||
|
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
|
||||||
|
}
|
||||||
|
.toggle-classic {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.42);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.classic-panel {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.yxg-label {
|
||||||
|
display: block;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.sel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1.5px solid #eee;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fdfaf8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.social-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.social-row.pulse {
|
||||||
|
animation: invitePulse 1.2s ease 2;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(229, 77, 66, 0.06);
|
||||||
|
}
|
||||||
|
@keyframes invitePulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 77, 66, 0); }
|
||||||
|
50% { box-shadow: 0 0 0 4px rgba(229, 77, 66, 0.12); }
|
||||||
|
}
|
||||||
|
.social-row .yxg-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.invite-path { word-break: break-all; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="synastry-result">
|
||||||
|
<div class="result-hero yxg-card">
|
||||||
|
<p class="result-headline">{{ headline }}</p>
|
||||||
|
<p class="yxg-meta">{{ oneLiner }}</p>
|
||||||
|
<div class="indices">
|
||||||
|
<div class="idx">
|
||||||
|
<em>恋爱</em>
|
||||||
|
<strong>{{ love }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="idx">
|
||||||
|
<em>友情</em>
|
||||||
|
<strong>{{ friend }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="idx">
|
||||||
|
<em>婚姻</em>
|
||||||
|
<strong>{{ marriage }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="yxg-meta note">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="yxg-card chart-card">
|
||||||
|
<div class="tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
v-for="t in mainTabs"
|
||||||
|
:key="t.key"
|
||||||
|
type="button"
|
||||||
|
class="tab"
|
||||||
|
:class="{ on: mainTab === t.key }"
|
||||||
|
@click="$emit('update:mainTab', t.key)"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="needsSubTab" class="subtabs">
|
||||||
|
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="$emit('update:subTab', 'natal')">本盘</button>
|
||||||
|
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="$emit('update:subTab', 'prog')">次限</button>
|
||||||
|
<template v-if="mainTab === 'marks'">
|
||||||
|
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="$emit('update:marksWho', 'me')">我</button>
|
||||||
|
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="$emit('update:marksWho', 'other')">TA</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mainTab === 'compare'">
|
||||||
|
<p class="tip">{{ chartTip('compare') }}</p>
|
||||||
|
<h3 class="sec">比较盘 · 我</h3>
|
||||||
|
<NatalWheel v-if="planetsA.length" :planets="planetsA" :aspects="[]" :asc-lon="ascA" />
|
||||||
|
<h3 class="sec">比较盘 · TA</h3>
|
||||||
|
<NatalWheel v-if="planetsB.length" :planets="planetsB" :aspects="[]" :asc-lon="ascB" />
|
||||||
|
<div v-if="aspectPreview.length" class="yxg-card soft inset">
|
||||||
|
<h2 class="card-title">跨盘相位速览</h2>
|
||||||
|
<p v-for="(a, i) in aspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="mainTab === 'overlay'">
|
||||||
|
<p class="tip">{{ overlayTip }}</p>
|
||||||
|
<div class="yxg-card soft inset">
|
||||||
|
<p v-for="(e, i) in overlayEntries" :key="i" class="aline">
|
||||||
|
{{ e.planet }}({{ e.sign }})→ 我方 {{ e.house }} 宫 · {{ e.house_tip }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="tip">{{ activeChartTip }}</p>
|
||||||
|
<NatalWheel
|
||||||
|
v-if="activePlanets.length"
|
||||||
|
:planets="activePlanets"
|
||||||
|
:aspects="[]"
|
||||||
|
:asc-lon="activeAsc"
|
||||||
|
/>
|
||||||
|
<div v-if="activeAspectPreview.length" class="yxg-card soft inset">
|
||||||
|
<h2 class="card-title">相位速览</h2>
|
||||||
|
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="report.has_deep_access && detail" class="yxg-card">
|
||||||
|
<h2 class="card-title">相处深析</h2>
|
||||||
|
<div v-for="(s, i) in sections" :key="i" class="sec-block">
|
||||||
|
<strong>{{ s.title }}</strong>
|
||||||
|
<p>{{ s.body }}</p>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(a, i) in fullAspects" :key="'fa' + i">{{ a.label }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-else class="yxg-card lock">
|
||||||
|
<p>完整相位、推运深文案与落宫解读可在深度版解锁。</p>
|
||||||
|
<button class="yxg-btn" type="button" :disabled="paying" @click="$emit('buy-deep')">解锁完整合盘(模拟支付)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<router-link class="yxg-link-plain" to="/star">回星座排盘 →</router-link>
|
||||||
|
<router-link class="yxg-link-plain" to="/relation">人格匹配 →</router-link>
|
||||||
|
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 →</router-link>
|
||||||
|
<button type="button" class="yxg-link-plain reset" @click="$emit('reset')">再测一对</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="$emit('update:shareOpen', true)">生成分享卡</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GrowthReport } from '@yuxingu/types'
|
||||||
|
import NatalWheel, { type WheelPlanet } from '../NatalWheel.vue'
|
||||||
|
import { mainTabs, type MainTab } from '../../composables/useSynastryPage'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
headline: string
|
||||||
|
oneLiner: string
|
||||||
|
love: number
|
||||||
|
friend: number
|
||||||
|
marriage: number
|
||||||
|
loveNote: string
|
||||||
|
asOfLabel: string
|
||||||
|
mainTab: MainTab
|
||||||
|
needsSubTab: boolean
|
||||||
|
subTab: 'natal' | 'prog'
|
||||||
|
marksWho: 'me' | 'other'
|
||||||
|
chartTip: (key: string) => string
|
||||||
|
planetsA: WheelPlanet[]
|
||||||
|
planetsB: WheelPlanet[]
|
||||||
|
ascA: number | null
|
||||||
|
ascB: number | null
|
||||||
|
aspectPreview: { label: string }[]
|
||||||
|
overlayTip: string
|
||||||
|
overlayEntries: { planet: string; sign: string; house: number; house_tip: string }[]
|
||||||
|
activeChartTip: string
|
||||||
|
activePlanets: WheelPlanet[]
|
||||||
|
activeAsc: number | null
|
||||||
|
activeAspectPreview: { label: string }[]
|
||||||
|
report: GrowthReport
|
||||||
|
detail: Record<string, unknown> | null
|
||||||
|
sections: { title: string; body: string }[]
|
||||||
|
fullAspects: { label: string }[]
|
||||||
|
paying: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:mainTab': [value: MainTab]
|
||||||
|
'update:subTab': [value: 'natal' | 'prog']
|
||||||
|
'update:marksWho': [value: 'me' | 'other']
|
||||||
|
'update:shareOpen': [value: boolean]
|
||||||
|
'buy-deep': []
|
||||||
|
reset: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.result-headline {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.yxg-card.inset {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indices {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
.idx {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff8f6;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(229, 77, 66, 0.08);
|
||||||
|
}
|
||||||
|
.idx em {
|
||||||
|
display: block;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.idx strong {
|
||||||
|
font-size: 26px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.tab.on {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.subtabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stab {
|
||||||
|
border: none;
|
||||||
|
background: #f3eeea;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.stab.on {
|
||||||
|
background: #2c2c2c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
.sec {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 16px 0 6px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.aline {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.sec-block {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.sec-block p {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #555;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lock .yxg-btn,
|
||||||
|
.share-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.reset {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Profile[]>([])
|
||||||
|
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<GrowthReport | null>(null)
|
||||||
|
const shareOpen = ref(false)
|
||||||
|
const ty = ref('')
|
||||||
|
const tm = ref('')
|
||||||
|
const td = ref('')
|
||||||
|
const tName = ref('TA')
|
||||||
|
const mainTab = ref<MainTab>('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<string, unknown>)
|
||||||
|
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||||
|
const charts = computed(() => (summary.value.charts || {}) as Record<string, unknown>)
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown> | null {
|
||||||
|
const c = charts.value[key]
|
||||||
|
if (!c || typeof c !== 'object') return null
|
||||||
|
return c as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RelationSharePayload | null>(() => {
|
||||||
|
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<GeolocationPosition>((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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 成长助手']
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
base: '/psy/',
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
@@ -12,6 +13,12 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// H5 history base is /psy/; client baseURL is /psy/api → rewrite to Go /api
|
||||||
|
'/psy/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/psy\/api/, '/api'),
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:8080',
|
target: 'http://127.0.0.1:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# CHANGELOG — ESS process artifacts
|
||||||
|
|
||||||
|
## 2026-08-05
|
||||||
|
|
||||||
|
- Bind ESS v1.0(`--bind-only`):`docs/` 流程目录 + script wrappers
|
||||||
|
- Dual-track:产品/DoD 仍以 `.ai/` 为准;ECR/Task/Handoff 走 `docs/`
|
||||||
|
- 纠正脚手架默认栈(非 Python/React)为 Go + Vue3
|
||||||
|
- **ECR-001** Approved:结构对齐(否决全仓重写);ADR-0007;HANDOFF → engineer
|
||||||
|
- **ECR-001 Phase B**:拆分 `HomePage` / `SynastryPage`(≤400 行);build + homeCatalog 单测绿
|
||||||
|
- **ECR-001 Phase A/C/D/E + ECR-002**:membership 服务抽出;relation/star 引擎拆文件;`star/fortune`→`outlook`(JSON 双写);integration 绿
|
||||||
|
- H5:`/psy` baseURL + vite `/psy/api` 代理;首页 + 菜单 z-index 修复
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# CODE_REVIEW — ECR-001 Phase A–E (+ ECR-002)
|
||||||
|
|
||||||
|
**Reviewer:** Cursor Agent (REVIEWER)
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
**Decision:** **PASS**(可合入;见 Nits)
|
||||||
|
|
||||||
|
## Scope reviewed
|
||||||
|
|
||||||
|
- Go:`service/membership` 抽出 · `handler/report` / `httpserver` 接线 · `relation`/`star` 拆文件 · `fortune`→`outlook`
|
||||||
|
- H5:Home/Synastry 拆分 · `baseURL`/`vite` 代理 · `+` 菜单 z-index
|
||||||
|
- 治理:ADR-0007 · LEGACY · go-services · ESS docs
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| 公共 URL / envelope 未改 | PASS |
|
||||||
|
| Membership 仅经 router 注入,集成测试绿 | PASS(`go test ./internal/integration/`) |
|
||||||
|
| 无残留 `star/fortune` / `package fortune` | PASS |
|
||||||
|
| JSON `fortune` 旧键保留 + `outlook` 双写 | PASS(compat 策略正确) |
|
||||||
|
| relation/star 单测 | PASS |
|
||||||
|
| `build:h5` | PASS |
|
||||||
|
| Phase B 行为冻结(composable 迁移) | PASS(先前冒烟) |
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Blockers
|
||||||
|
无。
|
||||||
|
|
||||||
|
### Should-fix(建议合入前或紧随 PR)
|
||||||
|
|
||||||
|
无必须项。
|
||||||
|
|
||||||
|
### Nits(不阻断)
|
||||||
|
|
||||||
|
1. **`star/engine.go` 多次 `fort.AsMap()`**
|
||||||
|
summary/detail 各调用多次,建议 `m := fort.AsMap()` 复用,减分配(非正确性)。
|
||||||
|
|
||||||
|
2. **`ReportHandler.Membership` 无 nil 守卫**
|
||||||
|
生产路径始终由 `NewRouter` 注入;测试若手造 handler 会 NPE。可加防御或构造函数。
|
||||||
|
|
||||||
|
3. **`useSynastryPage` ~390 行**
|
||||||
|
贴近 coding 上限;后续可再抽 chart helpers(非本轮必须)。
|
||||||
|
|
||||||
|
4. **Synastry props 面偏宽**
|
||||||
|
可维护性 nit;可改为 provide/inject(后续)。
|
||||||
|
|
||||||
|
5. **JSON `lucky` 字段仍在 `outlook.Period`**
|
||||||
|
ECR-002 已声明兼容期;彻底 lexicon 清理需客户端迁移后再开 ECR。
|
||||||
|
|
||||||
|
6. **分支名未含 ECR-001**
|
||||||
|
ess-validate 仅 warning;合入时 commit message 宜带 `ECR-001`。
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
低。最大残留:旧客户端继续读 `fortune`;新键未使用也不影响。删除旧键前必须扫 H5/sdk。
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
- [x] Architecture / layering PASS
|
||||||
|
- [x] Tests green(unit + integration + h5 build)
|
||||||
|
- [x] No scope redesign
|
||||||
|
- [x] Ready to commit / ship Phase A–E
|
||||||
|
|
||||||
|
**Decision: PASS**
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# CODE_REVIEW — ECR-001 Phase B
|
||||||
|
|
||||||
|
**Reviewer:** Cursor Agent (REVIEWER)
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
**Decision:** PASS(带人工冒烟)
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| Scope 仅 Phase B(Home/Synastry 拆分) | PASS |
|
||||||
|
| 无 API / OpenAPI / schema 变更 | PASS |
|
||||||
|
| 页面行数 ≤400 | Home 133 · Synastry 187 PASS |
|
||||||
|
| BirthDateInputs 绑定与组件 API 一致 | PASS(year/month/day + update:*) |
|
||||||
|
| `build:h5` | PASS(先轮) |
|
||||||
|
| `homeCatalog` 单测 | PASS |
|
||||||
|
| 行为冻结 | 逻辑迁入 composable,未见故意改文案/路由 |
|
||||||
|
|
||||||
|
## Nits(不阻断)
|
||||||
|
|
||||||
|
1. `SynastryPage` 的 `v-else-if="loading"` 在 `!report` 时不可达——与重构前一致,landing 按钮文案承担 loading。
|
||||||
|
2. Synastry props/emits 面偏宽;后续可改为注入 composable 或 provide(非本 Phase)。
|
||||||
|
3. `useSynastryPage` 390 行贴近上限,Phase 后续可再抽 chart helpers。
|
||||||
|
|
||||||
|
## Smoke(本会话)
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
|----|------|
|
||||||
|
| API healthz | PASS |
|
||||||
|
| Home UI(心情卡/金刚区/推荐) | PASS · 截图 `/tmp/yuxingu-smoke/home.png` |
|
||||||
|
| Synastry UI 结构 | PASS |
|
||||||
|
| Synastry `listProfiles` | **BLOCKED initially** — `baseURL=/psy/api` 但 Vite 只代理 `/api`,返回 HTML 200 |
|
||||||
|
|
||||||
|
### Fix applied(review 中发现,非 Phase B 引入)
|
||||||
|
|
||||||
|
1. `vite.config.ts`:`base: '/psy/'` + `/psy/api` → `8080/api` rewrite
|
||||||
|
2. `client.ts`:`baseURL` 从错误的 `/psy/api` 改为 `/psy`(SDK 路径已含 `/api/v1`,避免 `/psy/api/api/v1`)
|
||||||
|
|
||||||
|
冒烟后再验:`GET /psy/api/v1/profiles` → 200 JSON;合盘页无「接口返回异常」。
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
低(拆分本身)。代理缺口已修;合盘需至少两档案才可生成结果。
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
- [x] Ready for local smoke
|
||||||
|
- [x] Human/agent smoke:首页 OK;合盘页 UI OK(代理已修)
|
||||||
|
- [x] Decision **PASS**
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ECR-001
|
||||||
|
|
||||||
|
**Title:** 愈心谷结构对齐重构(Evidence-first · 非全仓重写)
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
**Change Level:** L3(治理双轨 + 结构边界)/ 实现分阶段按 L2 拆单
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
在 **不更换技术栈、不破坏 P1/P2 已发布 API 行为** 的前提下,用 ESS 流程把仓库从「可运行的 P1 Complete」对齐到可长期演进的结构:双轨治理固化、超标大文件拆分、Go 域包边界澄清、词表/包名卫生、共享 packages 补强。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
1. 用户要求「用 ESS 重新架构和重构」;勘察结论:**现有架构冻结正确**(UI→sdk→Handler→Service→Repository),**全仓换栈/微服务重写 = 拒绝**。
|
||||||
|
2. 真实债务(Evidence):
|
||||||
|
- H5 多页远超 `.ai/coding.md` 400 行硬偏好:`HomePage.vue` 1065、`SynastryPage.vue` 1015、`StarProfilePage.vue` 686 等。
|
||||||
|
- Go 引擎单文件偏大:`internal/relation/engine.go` 463、`internal/star/engine.go` 337。
|
||||||
|
- `go-services.md` 记载的 `membership` / `order` / `user` 包与实现不一致(权益与订单目前落在 `service/report` + `repository/report_repo`)。
|
||||||
|
- 存在 `internal/star/fortune` 包名与 JSON `fortune`/`lucky` 字段,与 `.ai/forbidden.md` / lexicon「禁吉凶恐吓词、禁 fortune 包名」存在张力。
|
||||||
|
- `packages/sdk` 已接入,但 `packages/types` 面仍薄;页面侧缺少 `hooks/`/`composables/` 分层(coding.md 期望)。
|
||||||
|
- Legacy 静态前端已在 `9f65c11` 大量删除;`LEGACY.md` / 入口文案需同步,避免 Agent 再去扩展已删路径。
|
||||||
|
3. ESS 已 bind;需正式 ECR 才能进入分阶段 Engineer 实现。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 更新/新增:`docs/**` ESS 工件、`.ai/adr/`(双轨治理 ADR)、必要时同步 `.ai/architecture/go-services.md` / `LEGACY.md` 描述
|
||||||
|
- **行为不变** 的结构重构:Vue 拆组件/composable、Go 按职责拆文件/子包、补测试保持绿
|
||||||
|
- 将 `membership`/`order` 从 `report` 服务中 **抽出独立 service 包**(URL 与 envelope 不变)
|
||||||
|
- 内部包重命名计划(如 `fortune` → lexicon 合规名)+ 必要时 OpenAPI/字段迁移 ADR(单独子 ECR)
|
||||||
|
- 增强 `packages/types` / sdk 方法面,页面继续经 `@/api/client` → sdk
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 换语言/换前端框架/拆微服务/引入 GraphQL 作主 API
|
||||||
|
- 改变 `{code,message,data}` 或无 ADR 改公共 URL
|
||||||
|
- 混入新功能行为或扩 P2 产品能力
|
||||||
|
- Architect / 本 ECR 交付物直接改 `apps/`/`packages/` 生产实现(实现归 Engineer + 子 Task)
|
||||||
|
- Docker-only 日常开发工作流
|
||||||
|
- 一次性「大爆炸」PR 覆盖全部 Phase
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 大页拆分导致 UI 回归 | 每 Phase 单关注点;L0 build + 相关单测/e2e;禁止改文案/交互语义 |
|
||||||
|
| 抽 membership/order 破坏权益裁剪 | 先搬移再改内部 API;集成测试 `p1_flows` Membership 必绿 |
|
||||||
|
| fortune 重命名破坏客户端 | 单独子 ECR;先内部包名,JSON 字段需兼容期或版本策略 |
|
||||||
|
| 双轨文档冲突 | ADR-0007:冲突时领域/DoD 以 `.ai/` 为准,流程以 ESS `docs/` 为准 |
|
||||||
|
| 范围膨胀成重写 | Acceptance 明确「无行为变更」;Reviewer 卡混 feature |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] ADR-0007(`.ai/` ↔ ESS 双轨)Accepted 并写入 Profile Pointers
|
||||||
|
- [ ] ENGINEERING_SPEC + IMPLEMENTATION_PLAN 分 Phase A–F 可执行
|
||||||
|
- [ ] 每个实现 Phase 有独立 Task Contract;单 PR 单 Phase
|
||||||
|
- [ ] Phase 完成后:相关 `go test` / `build:h5` / 既有 e2e 主路径不回归
|
||||||
|
- [ ] 超标页/引擎有明确拆分目标与「完成后行数」门槛(见 ENGINEERING_SPEC)
|
||||||
|
- [ ] TRACEABILITY / CHANGELOG / STATE 已更新
|
||||||
|
- [ ] TEST_REPORT greened per Phase(Engineer)
|
||||||
|
- [ ] Docs + CHANGELOG
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- 治理文档:revert `docs/` + ADR commit
|
||||||
|
- 代码 Phase:git revert 该 Phase PR;DB 无迁移则无需 rollback schema(本 ECR 默认无 schema 变更;若子 ECR 含迁移则按其 Rollback)
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- Path: N/A(非交易域)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-001-structural-realignment.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md`
|
||||||
|
- IMPLEMENTATION_PLAN: `docs/HANDOFF/ECR-001-implementation-plan.md`
|
||||||
|
- HANDOFF: `docs/HANDOFF/ECR-001-architect-to-engineer.md`
|
||||||
|
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`(待写入)
|
||||||
|
- TRACEABILITY row: Yes
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ECR-002
|
||||||
|
|
||||||
|
**Title:** 星座引擎包名卫生(fortune → outlook)
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
将 `internal/star/fortune` 重命名为 `internal/star/outlook`;Summary/Detail 双写 `outlook` / `outlook_detail`,并保留旧键 `fortune` / `fortune_detail` 兼容期。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`.ai/architecture/go-services.md` 禁止 `fortune` 包名;与 lexicon 对齐。用户要求纳入 ECR-001 Phase E。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
- 包重命名、import 更新、双写 JSON 键
|
||||||
|
- 文档 / TRACEABILITY
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
- 删除旧 JSON 键(须另开迁移 ECR)
|
||||||
|
- 改计分算法 / 文案语义
|
||||||
|
- 改公共 URL
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- [x] 无 `internal/star/fortune` 目录
|
||||||
|
- [x] `go test ./internal/star/...` 绿
|
||||||
|
- [x] 旧键 `fortune` 仍存在(compat)
|
||||||
|
- [x] 新键 `outlook` 存在
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
revert 包名与双写 commit
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
N/A
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
- Parent: ECR-001 Phase E
|
||||||
|
- TRACEABILITY: Yes
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-001 Structural Realignment
|
||||||
|
|
||||||
|
> 回答「怎么实现」。默认 **move/extract**,禁止 rewrite。
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
| Doc | Link |
|
||||||
|
|-----|------|
|
||||||
|
| ECR | `docs/ECR/ECR-001-structural-realignment.md` |
|
||||||
|
| PRODUCT_SPEC | `docs/PRODUCT_SPEC/ECR-001-structural-realignment.md` |
|
||||||
|
| Coding | `.ai/coding.md`(≤50 行函数 · ≤400 行文件) |
|
||||||
|
| Go boundaries | `.ai/architecture/go-services.md` |
|
||||||
|
|
||||||
|
## Diagnosis summary(Evidence)
|
||||||
|
|
||||||
|
| 区域 | 现状 | 目标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 调用链 | 已符合冻结架构 | **保持** |
|
||||||
|
| 栈 | Go/gin + Vue3 + PG | **锁定**(见 `docs/TECH_STACK.md`) |
|
||||||
|
| H5 页面体积 | 多页 >400 行,首页/合盘 >1000 | 拆至 ≤400;逻辑进 `composables/` |
|
||||||
|
| Go service 地图 | membership/order 文档有、包无 | 抽 `service/membership` · `service/order`(或合并 membership 包内 order) |
|
||||||
|
| 引擎包 | `internal/{portrait,relation,star,ask,scale,…}` + `service/*` | 文档写清:**engine=纯计算,service=用例+权益** |
|
||||||
|
| 词表张力 | `star/fortune` · JSON `lucky` | 子 ECR:内部改名;对外字段兼容策略 |
|
||||||
|
| packages | sdk 已用;types 薄;无 hooks 目录 | 补 types;建 `src/composables/` |
|
||||||
|
| Legacy | 静态资源已删 | 同步 `LEGACY.md` / CLAUDE Legacy 段 |
|
||||||
|
|
||||||
|
## Module Design
|
||||||
|
|
||||||
|
### Phase A — Governance(可与文档同批,仍无 apps 行为改)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | 固化 ESS↔`.ai/` 双轨;更新过时 Legacy 描述 |
|
||||||
|
| Can | ADR-0007、Profile/Rules 指针、LEGACY.md |
|
||||||
|
| Cannot | 改业务代码 |
|
||||||
|
| Layer | docs / `.ai` |
|
||||||
|
|
||||||
|
### Phase B — H5 page decomposition
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | 拆超标 SFC;抽 composable;不改路由契约 |
|
||||||
|
| Can | 新组件、`src/composables/*`、样式随迁 |
|
||||||
|
| Cannot | 改 API path、改 Tab IA、夹带视觉改版 |
|
||||||
|
| Layer | `apps/user-h5` |
|
||||||
|
| Priority files | `HomePage.vue` · `SynastryPage.vue` · `StarProfilePage.vue` · `ReportPage.vue` · `ProfilePage.vue` · `AskPage.vue` |
|
||||||
|
|
||||||
|
门槛:每个目标页 ≤400 行;单函数 ≤50 行(模板段合理除外,脚本逻辑必须拆)。
|
||||||
|
|
||||||
|
### Phase C — Go service map alignment
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | 将 Membership/Order 从 `service/report` 迁出;handler 仅改 import/构造 |
|
||||||
|
| Can | 新包、搬移方法、补接口在 handler 侧 |
|
||||||
|
| Cannot | 改 SQL 语义、改 HTTP 路径、改裁剪规则 |
|
||||||
|
| Layer | `apps/api/internal/service/*` · `handler/report.go` |
|
||||||
|
| Tests | `internal/integration` Membership / Order 路径必跑 |
|
||||||
|
|
||||||
|
### Phase D — Engine file splits
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | `relation/engine.go`、过大 star 文件按纯函数块拆文件 |
|
||||||
|
| Can | 同包多文件;导出表面兼容 |
|
||||||
|
| Cannot | 改计分结果;跨层把 engine 逻辑塞进 handler |
|
||||||
|
| Layer | `internal/relation` · `internal/star` · `internal/portrait` |
|
||||||
|
|
||||||
|
### Phase E — Lexicon / package hygiene(子 ECR 建议)
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | 消解 `fortune` 包名与对外 `lucky` 张力 |
|
||||||
|
| Can | 内部 rename;兼容 alias;必要时 OpenAPI 标注 deprecated |
|
||||||
|
| Cannot | 无 ADR 破坏已上线客户端字段 |
|
||||||
|
| Layer | api + sdk/types + 可能 H5 |
|
||||||
|
|
||||||
|
**本父 ECR 不直接开工 Phase E 代码**;先立 `ECR-002`。
|
||||||
|
|
||||||
|
### Phase F — packages + api surface
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| Responsibility | types 与 sdk 方法与 OpenAPI/`proto/openapi.yaml` 对齐;页面禁止旁路 |
|
||||||
|
| Can | 补类型与 client 方法 |
|
||||||
|
| Cannot | 页面 `fetch`;扩大任意 `any` |
|
||||||
|
| Layer | `packages/*` · `apps/user-h5/src/api` |
|
||||||
|
|
||||||
|
## Interfaces(约束)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Handler (bind/validate)
|
||||||
|
→ Service (use-case + entitlement)
|
||||||
|
→ Engine packages (pure-ish compute; no gin.Context)
|
||||||
|
→ Repository (SQL)
|
||||||
|
```
|
||||||
|
|
||||||
|
Membership 判定 **不得** 下沉到 Repository 或 H5。
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
无 schema 变更(父 ECR)。若 Phase E 需列迁移 → 子 ECR + `.ai/database.md`。
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| 拆分后编译失败 | 禁止合并;恢复单文件直至测试绿 |
|
||||||
|
| 集成测试红 | 阻断 Phase Done |
|
||||||
|
| 误改文案 | Reviewer BLOCK |
|
||||||
|
|
||||||
|
## Config / Constants
|
||||||
|
|
||||||
|
不新增环境变量。本地仍:本机 Go + Vite + compose 仅 DB。
|
||||||
|
|
||||||
|
## Test Plan Pointer
|
||||||
|
|
||||||
|
见 `docs/HANDOFF/ECR-001-implementation-plan.md` §Tests。
|
||||||
|
映射 ECR Acceptance + `.ai/testing.md` L0–L3。
|
||||||
|
|
||||||
|
## Migration / API Impact
|
||||||
|
|
||||||
|
父 ECR:**无**公共 API / DB migration。
|
||||||
|
Phase E:另案。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Handoff
|
||||||
|
|
||||||
|
**From:** ARCHITECT
|
||||||
|
**To:** ENGINEER
|
||||||
|
**ECR:** ECR-001
|
||||||
|
**State:** design → ready_for_engineering(Phase A/B 待领取)
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- [x] ECR (Approved) — `docs/ECR/ECR-001-structural-realignment.md`
|
||||||
|
- [x] PRODUCT_SPEC — `docs/PRODUCT_SPEC/ECR-001-structural-realignment.md`
|
||||||
|
- [x] ENGINEERING_SPEC — `docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md`
|
||||||
|
- [x] RISK_REVIEW — N/A
|
||||||
|
- [x] IMPLEMENTATION_PLAN / TEST_PLAN — `docs/HANDOFF/ECR-001-implementation-plan.md`
|
||||||
|
- [x] TRACEABILITY updated
|
||||||
|
- [ ] TEST_REPORT / CODE_REVIEW — Engineer per Phase
|
||||||
|
- [x] ADR-0007 — `.ai/adr/0007-ess-ai-dual-track.md`
|
||||||
|
|
||||||
|
## Restrictions — Do not modify
|
||||||
|
|
||||||
|
- 公共 API URL / envelope
|
||||||
|
- 用户可见文案与交互语义(除非子 ECR)
|
||||||
|
- 技术栈与 monorepo 边界
|
||||||
|
- 一次 PR 多个 Phase
|
||||||
|
- `internal/star/fortune` 破坏性重命名(归 ECR-002)
|
||||||
|
|
||||||
|
## Goal for receiver
|
||||||
|
|
||||||
|
1. 领取 **Phase A**(文档对齐)或直接 **Phase B**(H5 拆页,推荐先做最大痛点)。
|
||||||
|
2. 为该 Phase 建 `docs/TASKS/TASK-*-ECR001-phaseX.yaml`,`role: engineer`。
|
||||||
|
3. 实现后:`ess-validate --phase coding` + 本仓 DoD Review Report。
|
||||||
|
4. 绿则更新 `docs/STATE/` owner → reviewer。
|
||||||
|
|
||||||
|
## Done for this hop
|
||||||
|
|
||||||
|
- [x] 架构诊断:全仓重写否决;分阶段结构对齐
|
||||||
|
- [x] ESS 工件齐备
|
||||||
|
- [x] 双轨 ADR Accepted
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- `docs/PROJECT_PROFILE.md`
|
||||||
|
- `.ai/coding.md` · `.ai/architecture.md` · `.ai/architecture/go-services.md`
|
||||||
|
- Evidence:H5/Go 行数勘察(2026-08-05)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Handoff
|
||||||
|
|
||||||
|
**From:** ENGINEER
|
||||||
|
**To:** REVIEWER
|
||||||
|
**ECR:** ECR-001 Phase B
|
||||||
|
**State:** coding → review
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
- [x] Code — HomePage / SynastryPage 拆分
|
||||||
|
- [x] TEST_REPORT — `docs/TEST_REPORT/ECR-001-phaseB.md`
|
||||||
|
- [x] Task — `docs/TASKS/TASK-20260805-ECR001-phaseB.yaml`
|
||||||
|
- [ ] CODE_REVIEW — Reviewer
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
- `HomePage.vue` 1065 → 133:`components/home/*` + `useHomePage` / `useHomeMood` / `homeCatalog`
|
||||||
|
- `SynastryPage.vue` 1015 → 187:`components/synastry/*` + `useSynastryPage`
|
||||||
|
- 无 API / 文案语义 / 路由变更意图
|
||||||
|
|
||||||
|
## Restrictions still active
|
||||||
|
- 勿混 Phase C–F
|
||||||
|
- 勿改 fortune / membership 抽出(其它 Phase)
|
||||||
|
|
||||||
|
## Goal for Reviewer
|
||||||
|
- 确认无行为漂移;抽查首页与合盘关键路径
|
||||||
|
- PASS 则 STATE.owner → release_manager 或按流程回 Architect 开下一 Phase Task
|
||||||
|
|
||||||
|
## Done for this hop
|
||||||
|
- [x] Phase B 两优先页拆完且 build/单测绿
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Implementation Plan: ECR-001
|
||||||
|
|
||||||
|
**Author:** Architect Agent
|
||||||
|
**For:** Engineer Agent
|
||||||
|
|
||||||
|
## Scope reminder
|
||||||
|
|
||||||
|
**Allowed / Forbidden:** 见 `docs/ECR/ECR-001-structural-realignment.md`。
|
||||||
|
**默认:无用户可见行为变更。**
|
||||||
|
|
||||||
|
## Steps(按序;每步独立 PR + Task)
|
||||||
|
|
||||||
|
### Phase A — Governance docs(可 Architect 收尾 / Engineer 轻触)
|
||||||
|
|
||||||
|
1. 确认 ADR-0007 已存在且 Profile 已指向双轨。
|
||||||
|
2. 更新 `LEGACY.md` + `CLAUDE.md` Legacy 段:标明静态 `css/js/pages/yuxingu.html` 已删除;勿再扩展。
|
||||||
|
3. 同步 `.ai/architecture/go-services.md`:**标注** membership/order 当前寄居于 report(实现前真相),并写「目标态」指向 Phase C。
|
||||||
|
|
||||||
|
### Phase B — H5 拆页(优先)
|
||||||
|
|
||||||
|
1. `HomePage.vue`:抽入口九宫格、推荐区、档案摘要 → components/composables。
|
||||||
|
2. `SynastryPage.vue`:图表 / 表单 / 结果区拆分。
|
||||||
|
3. 其余超标页按 ENGINEERING_SPEC 优先级逐个拆。
|
||||||
|
4. 每页:`npm run build:h5`;相关 vitest;必要时 e2e 主路径。
|
||||||
|
|
||||||
|
### Phase C — Go membership/order 抽出
|
||||||
|
|
||||||
|
1. 新建 `internal/service/membership`(含 GetMembership / 配额相关从 report 迁入的用例)。
|
||||||
|
2. Order 创建迁入 `membership` 或独立 `order` 包(二选一,Prefer:`membership` 内 order 若文件仍 <400)。
|
||||||
|
3. `ReportHandler` 改为依赖新 service;路由注册不变。
|
||||||
|
4. 跑 `go test ./...` + `internal/integration` Membership。
|
||||||
|
|
||||||
|
### Phase D — Engine 拆文件
|
||||||
|
|
||||||
|
1. `relation/engine.go` 按纯函数/段落拆多文件(同包)。
|
||||||
|
2. star 过大文件同类处理。
|
||||||
|
3. 引擎单测全绿;禁止改 golden 输出除非测试本有误。
|
||||||
|
|
||||||
|
### Phase E — 另立 ECR-002(本计划仅占位)
|
||||||
|
|
||||||
|
fortune 包名 / `lucky` 字段卫生 → 不在本父 ECR 编码。
|
||||||
|
|
||||||
|
### Phase F — packages 补强
|
||||||
|
|
||||||
|
1. 对照 `proto/openapi.yaml` 补 `@yuxingu/types`。
|
||||||
|
2. sdk 缺方法补齐;H5 只经 client。
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
| Phase | Paths |
|
||||||
|
|-------|--------|
|
||||||
|
| A | `LEGACY.md` · `CLAUDE.md` · `.ai/architecture/go-services.md` · `.ai/adr/0007-*` |
|
||||||
|
| B | `apps/user-h5/src/pages/*` · `components/*` · `composables/*` |
|
||||||
|
| C | `apps/api/internal/service/report/*` · `service/membership/*` · `handler/report.go` · `httpserver` wire |
|
||||||
|
| D | `apps/api/internal/relation/*` · `star/*` |
|
||||||
|
| F | `packages/types` · `packages/sdk` · `apps/user-h5/src/api` |
|
||||||
|
|
||||||
|
## Tests to add
|
||||||
|
|
||||||
|
- 搬移/抽函数处:保留或增加表驱动单测
|
||||||
|
- Phase C:集成测试不得删减
|
||||||
|
- Phase B:优先现有 `PortraitPage.spec.ts` 模式补关键页 smoke(若拆出纯函数)
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- 真支付、新功能、微服务、换栈、Phase E 字段破坏性变更
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
见 ECR Risk 表。Engineer 遇「必须改行为才能编译」→ **停**,回 Architect 开子 ECR。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Handoff
|
||||||
|
|
||||||
|
**From:** ENGINEER
|
||||||
|
**To:** REVIEWER
|
||||||
|
**ECR:** ECR-001 Phase A–E · ECR-002
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
| Phase | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| A | LEGACY/ADR/go-services 已对齐(membership 抽出后文档更新) |
|
||||||
|
| B | 先前完成 |
|
||||||
|
| C | `internal/service/membership`;ReportHandler 接线;URL 不变 |
|
||||||
|
| D | relation + star engine 同包拆分 |
|
||||||
|
| E | ECR-002:`star/outlook`;双写 `fortune`/`outlook` |
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
- `go test ./internal/relation/... ./internal/star/... ./internal/integration/` PASS
|
||||||
|
- `go build ./cmd/server` PASS
|
||||||
|
|
||||||
|
## Restrictions
|
||||||
|
- 勿删 JSON `fortune` 旧键(须客户端迁移后再做)
|
||||||
|
- Phase F(packages)未做
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Implementation Report — ECR-001 Phase B
|
||||||
|
|
||||||
|
**Author:** Engineer
|
||||||
|
**Date:** 2026-08-05
|
||||||
|
|
||||||
|
## Scope delivered
|
||||||
|
Phase B only:优先拆分 HomePage + SynastryPage。
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```
|
||||||
|
apps/user-h5/src/
|
||||||
|
composables/useHomeMood.ts, useHomePage.ts, useSynastryPage.ts
|
||||||
|
lib/homeCatalog.ts (+ spec)
|
||||||
|
components/home/* (6)
|
||||||
|
components/synastry/* (3)
|
||||||
|
pages/HomePage.vue, SynastryPage.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Non-goals respected
|
||||||
|
无 API / schema / 文案语义变更;未做 Phase C–F。
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
- build:h5 PASS
|
||||||
|
- homeCatalog vitest PASS
|
||||||
|
- ess-validate coding(见会话日志)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-001 Structural Realignment
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
| Doc | Link |
|
||||||
|
|-----|------|
|
||||||
|
| ECR | `docs/ECR/ECR-001-structural-realignment.md` |
|
||||||
|
| Feature Spec | **无新产品行为**;现有 Active Spec 全部保持 |
|
||||||
|
| Lexicon | `.ai/product/lexicon.md`(不改用户可见话术,除非 lexicon 合规专项子 ECR) |
|
||||||
|
|
||||||
|
## Product intent
|
||||||
|
|
||||||
|
本变更是 **工程结构对齐**,不是产品功能迭代。
|
||||||
|
|
||||||
|
| 维度 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| 用户可见行为 | **冻结**:画像 / 关系 / 探索 / 问答 / 会员 / 报告深度版 / P2 已上线页交互与文案语义不变 |
|
||||||
|
| 商业规则 | Membership / DeepAccess / Ask 额度裁剪逻辑不变 |
|
||||||
|
| API | 公共 path + `{code,message,data}` 不变(除非子 ECR + ADR) |
|
||||||
|
| 标签 | 不宣称新的 P1/P2 Complete;完成后仍尊重 `.ai/product/p1-status.md` |
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 新 Tab、新付费点、真支付网关
|
||||||
|
- 顾问 / 图谱等 Phase 3 能力
|
||||||
|
- 竞品功能追平(走独立 reverse-engineering Spec)
|
||||||
|
|
||||||
|
## Acceptance (product lens)
|
||||||
|
|
||||||
|
- [ ] 手工/自动主路径与重构前一致(首页→画像→深度版→关系→会员→问答→探索)
|
||||||
|
- [ ] 无新增恐吓/吉凶导向文案
|
||||||
|
- [ ] 无「为了重构」关闭的功能入口
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Project Profile — 愈心谷 (YuXinGu)
|
||||||
|
|
||||||
|
> Agent **第一次**读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
||||||
|
> 产品/领域/完成标准权威仍在 `.ai/`;本文件只锁定栈与 ESS 流程入口。
|
||||||
|
|
||||||
|
## Type
|
||||||
|
|
||||||
|
`saas-web` + AI Agent(心理洞察 / 画像 / 关系 / 问答陪伴)
|
||||||
|
|
||||||
|
## Dual-track governance
|
||||||
|
|
||||||
|
| Layer | Authority | Path |
|
||||||
|
|-------|-----------|------|
|
||||||
|
| Product · Domain · Lexicon · Feature Spec · DoD | **`.ai/`** | `.ai/product/` · `.ai/domain.md` · `.ai/definition-of-done.md` |
|
||||||
|
| Architecture · Forbidden · ADR | **`.ai/`** | `.ai/architecture.md` · `.ai/forbidden.md` · `.ai/adr/` |
|
||||||
|
| Change process · ECR · Task · Handoff · Roles | **ESS** | `docs/ECR/` · `docs/TASKS/` · `docs/HANDOFF/` · `docs/STATE/` |
|
||||||
|
| Stack lock (mirror) | ESS + `.ai/` | 本文 + `.ai/tech-stack.md` |
|
||||||
|
|
||||||
|
冲突时:领域与完成标准以 `.ai/` 为准;变更分级与角色门禁以 ESS 为准。
|
||||||
|
|
||||||
|
## Stack Lock
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|-------|--------|
|
||||||
|
| Language (API) | Go 1.22+ |
|
||||||
|
| Backend | gin · Handler → Service → Repository |
|
||||||
|
| Database | PostgreSQL · migrations under `apps/api/migrations/` |
|
||||||
|
| Frontend | Vue 3 + TypeScript (`strict`) + Vite · Pinia |
|
||||||
|
| Shared | npm workspaces · `@yuxingu/sdk` · `@yuxingu/types` |
|
||||||
|
| Mini-program | Scaffold only until tasked |
|
||||||
|
| Local run | Host Go + Vite;Docker **仅** Postgres(`docker-compose.dev.yml`) |
|
||||||
|
| Deployment | Docker Compose / images — never `latest` in prod |
|
||||||
|
| Architecture Pattern | Monorepo · layered API · REST `{code,message,data}` |
|
||||||
|
|
||||||
|
## Forbidden(无 ADR 禁止)
|
||||||
|
|
||||||
|
- 换后端语言 / 以 GraphQL·gRPC 替代主 REST
|
||||||
|
- 无 ADR 拆微服务
|
||||||
|
- Handler 直连 DB;UI 直连 DB;页面内裸 `fetch`
|
||||||
|
- Docker-only 日常开发(每次改代码重建应用镜像)
|
||||||
|
- 无 Active Feature Spec(`.ai/product/feature-spec/`)实现新行为
|
||||||
|
- L2+ 行为变更无 Approved ECR(`docs/ECR/`)
|
||||||
|
- 复制整棵 ESS 进本仓
|
||||||
|
- 扩展已清理的 Legacy 根 HTML(见 `LEGACY.md`)
|
||||||
|
|
||||||
|
## Active anchors
|
||||||
|
|
||||||
|
- ECR: **ECR-001** structural realignment(Approved)
|
||||||
|
- EXP: (无)
|
||||||
|
- STATE: `docs/STATE/ECR-001.md` → owner engineer
|
||||||
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||||
|
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- Rules: `docs/PROJECT_RULES.md`
|
||||||
|
- Stack detail: `docs/TECH_STACK.md` ↔ `.ai/tech-stack.md`
|
||||||
|
- ESS root: `.ess-root` 或环境变量 `ESS_ROOT`
|
||||||
|
- Agent entry: `AGENTS.md` · `CLAUDE.md`
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Project Rules — 愈心谷
|
||||||
|
|
||||||
|
Inherits: `$ESS_ROOT/product-governance/PROJECT_RULES.md`(流程)
|
||||||
|
Product/DoD 权威:`.ai/`(不在此复制长文)
|
||||||
|
|
||||||
|
## Domain invariants
|
||||||
|
|
||||||
|
1. 语言契约:只用 `.ai/product/lexicon.md` 中的愈心谷用词;禁止竞品品牌词与恐吓/吉凶话术。
|
||||||
|
2. API 信封固定为 `{code,message,data}`;不发明表名、端点、领域同义词。
|
||||||
|
3. 调用链:UI → `@yuxingu/sdk` → Handler → Service → Repository → DB。
|
||||||
|
4. 新功能 / 扩行为:必须有 Active `.ai/product/feature-spec/<id>.md`;L2+ 另须 Approved `docs/ECR/`。
|
||||||
|
5. 本地默认:本机 `go run` + Vite;compose 只起 DB。
|
||||||
|
6. Done ≠ build 通过:须过 `.ai/definition-of-done.md` 并输出 Review Report。
|
||||||
|
|
||||||
|
## Change levels(映射)
|
||||||
|
|
||||||
|
| ESS | 本仓要求 |
|
||||||
|
|-----|----------|
|
||||||
|
| L0 | 文档/注释/无行为;可无 ECR |
|
||||||
|
| L1 | 明确 bugfix;可无新 Feature Spec,但不得扩行为 |
|
||||||
|
| L2 | 功能行为变更 → Feature Spec Active + ECR Approved + HANDOFF |
|
||||||
|
| L3 | 栈/包边界/架构 → ADR(`.ai/adr/`)+ ECR + ENGINEERING_SPEC |
|
||||||
|
|
||||||
|
## Forbidden(extra)
|
||||||
|
|
||||||
|
- 猜需求继续写(ASK FIRST)
|
||||||
|
- Architect 角色改 `apps/` / `packages/` 生产实现
|
||||||
|
- 无 Human 批准把实验当 Live 行为推广
|
||||||
|
- 提交 `config.local.yaml`、`.env`、密钥
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
1. ESS gate(有 ECR 时):`python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx`
|
||||||
|
2. 本仓 DoD:`.ai/definition-of-done.md` + `.ai/review.md`
|
||||||
|
3. 有行为变更时更新/尊重 `.ai/product/p1-status.md` 标签
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# STATE — ECR-001
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| ECR | ECR-001 (+ ECR-002) |
|
||||||
|
| Title | Structural realignment |
|
||||||
|
| Active slice | Phase A–E 已编码 |
|
||||||
|
| Owner | release-ready(CODE_REVIEW PASS) |
|
||||||
|
| Phase | review PASS |
|
||||||
|
| Next | Human commit / 可选 Phase F |
|
||||||
|
| Updated | 2026-08-05 |
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
| Date | Event |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-08-05 | Architect 诊断 + HANDOFF |
|
||||||
|
| 2026-08-05 | Phase B Home/Synastry;代理 baseURL 热修 |
|
||||||
|
| 2026-08-05 | Phase A 收口 · C membership · D engine 拆分 · E/ECR-002 outlook |
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# docs/TASKS/TASK-20260805-ECR001-phaseA-E.yaml
|
||||||
|
task_id: ECR-001-phaseA-E
|
||||||
|
role: engineer
|
||||||
|
phase: coding
|
||||||
|
tool: cursor
|
||||||
|
|
||||||
|
input:
|
||||||
|
- docs/ECR/ECR-001-structural-realignment.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md
|
||||||
|
- docs/HANDOFF/ECR-001-implementation-plan.md
|
||||||
|
|
||||||
|
output:
|
||||||
|
- apps/api/internal/service/membership/*
|
||||||
|
- apps/api/internal/relation/*
|
||||||
|
- apps/api/internal/star/*
|
||||||
|
- docs/ECR/ECR-002-fortune-hygiene.md
|
||||||
|
- docs/TEST_REPORT/ECR-001-phaseA-E.md
|
||||||
|
|
||||||
|
forbidden:
|
||||||
|
- redesign_scope
|
||||||
|
- behavior_change
|
||||||
|
- api_url_change
|
||||||
|
|
||||||
|
next_agent: reviewer
|
||||||
|
notes: >
|
||||||
|
A docs finalize; C membership extract; D engine split;
|
||||||
|
E internal fortune package rename with JSON key compat (ECR-002).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# docs/TASKS/TASK-20260805-ECR001-phaseB.yaml
|
||||||
|
task_id: ECR-001-phaseB
|
||||||
|
role: engineer
|
||||||
|
phase: coding
|
||||||
|
tool: cursor
|
||||||
|
|
||||||
|
input:
|
||||||
|
- docs/ECR/ECR-001-structural-realignment.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md
|
||||||
|
- docs/HANDOFF/ECR-001-architect-to-engineer.md
|
||||||
|
- docs/HANDOFF/ECR-001-implementation-plan.md
|
||||||
|
- .ai/coding.md
|
||||||
|
|
||||||
|
output:
|
||||||
|
- apps/user-h5/src/composables/*
|
||||||
|
- apps/user-h5/src/components/home/*
|
||||||
|
- apps/user-h5/src/components/synastry/*
|
||||||
|
- apps/user-h5/src/pages/HomePage.vue
|
||||||
|
- apps/user-h5/src/pages/SynastryPage.vue
|
||||||
|
- docs/TEST_REPORT/ECR-001-phaseB.md
|
||||||
|
- docs/HANDOFF/ECR-001-engineer-to-reviewer.md
|
||||||
|
|
||||||
|
forbidden:
|
||||||
|
- redesign_scope
|
||||||
|
- behavior_change
|
||||||
|
- api_contract_change
|
||||||
|
|
||||||
|
next_agent: reviewer
|
||||||
|
notes: >
|
||||||
|
Phase B only: split HomePage + SynastryPage to ≤400 lines each.
|
||||||
|
No copy/UX/API changes. Other oversized pages deferred.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Copy to project: docs/TASKS/TASK-20260805-ECR001.yaml
|
||||||
|
task_id: ECR-001
|
||||||
|
role: architect
|
||||||
|
phase: design
|
||||||
|
tool: cursor
|
||||||
|
|
||||||
|
input:
|
||||||
|
- docs/PROJECT_PROFILE.md
|
||||||
|
- docs/PROJECT_RULES.md
|
||||||
|
- docs/TECH_STACK.md
|
||||||
|
- .ai/architecture.md
|
||||||
|
- .ai/architecture/go-services.md
|
||||||
|
- .ai/coding.md
|
||||||
|
- .ai/product/p1-status.md
|
||||||
|
|
||||||
|
output:
|
||||||
|
- docs/ECR/ECR-001-structural-realignment.md
|
||||||
|
- docs/PRODUCT_SPEC/ECR-001-structural-realignment.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-001-structural-realignment.md
|
||||||
|
- docs/HANDOFF/ECR-001-implementation-plan.md
|
||||||
|
- docs/HANDOFF/ECR-001-architect-to-engineer.md
|
||||||
|
- .ai/adr/0007-ess-ai-dual-track.md
|
||||||
|
|
||||||
|
forbidden:
|
||||||
|
- code_change
|
||||||
|
|
||||||
|
next_agent: engineer
|
||||||
|
notes: >
|
||||||
|
Full rewrite rejected. Engineer picks Phase A or B first;
|
||||||
|
one Phase per Task/PR. Phase E requires ECR-002.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Tech Stack — 愈心谷(Project Lock)
|
||||||
|
|
||||||
|
权威镜像:`.ai/tech-stack.md`。本文供 ESS Agent 首读;冲突以 `.ai/tech-stack.md` + Accepted ADR 为准。
|
||||||
|
|
||||||
|
Global matrix(可选):`$ESS_ROOT/technology-governance/DECISION_MATRIX.md`
|
||||||
|
|
||||||
|
## Locked
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|-------|--------|
|
||||||
|
| Language | Go 1.22+ |
|
||||||
|
| HTTP | gin |
|
||||||
|
| DB | PostgreSQL |
|
||||||
|
| Migrations | `apps/api/migrations/`(启动自动应用) |
|
||||||
|
| Log | structured(zap / slog);业务路径禁止 `fmt.Println` |
|
||||||
|
| Module | `github.com/yuxingu/digital-psychology/apps/api` |
|
||||||
|
| Frontend | Vue 3 + TypeScript `strict` + Vite + vue-router + Pinia |
|
||||||
|
| Shared TS | npm workspaces · `@yuxingu/sdk` · `@yuxingu/types` · `@yuxingu/utils` · `@yuxingu/ui` |
|
||||||
|
| Mini-program | Native WeChat scaffold;同 SDK adapters;不与 H5 共享 Vue SFC |
|
||||||
|
| Local deps | Docker Compose **仅** Postgres(`docker-compose.dev.yml`) |
|
||||||
|
| API health | `/api/v1/healthz` |
|
||||||
|
|
||||||
|
## Do not introduce without ADR
|
||||||
|
|
||||||
|
- 另一后端语言
|
||||||
|
- GraphQL / gRPC 作为主公网 API
|
||||||
|
- 未约定的 ORM 隐藏 SQL
|
||||||
|
- H5 上 Pinia 以外的新状态库
|
||||||
|
- Kafka / MongoDB(除非 Profile + ADR 明确允许)
|
||||||
|
- 无 ADR 的微服务拆分
|
||||||
|
|
||||||
|
Deviation = L3 + ADR(`.ai/adr/`)+ ECR。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Test Report — ECR-001 Phase A–E (+ ECR-002)
|
||||||
|
|
||||||
|
## ECR
|
||||||
|
ECR-001 Phase A/C/D/E · ECR-002
|
||||||
|
|
||||||
|
## Change Level
|
||||||
|
L2 / L3(结构 · 无行为变更意图)
|
||||||
|
|
||||||
|
## Cases
|
||||||
|
- [x] Phase A:LEGACY / ADR-0007 / go-services 对齐
|
||||||
|
- [x] Phase B:已完成(先前)
|
||||||
|
- [x] Phase C:`service/membership` 抽出;路由不变;handler 接线
|
||||||
|
- [x] Phase D:relation/star engine 同包拆分
|
||||||
|
- [x] Phase E / ECR-002:`star/fortune` → `star/outlook`;JSON 双写
|
||||||
|
- [x] `go test` relation/star/outlook + build server
|
||||||
|
- [x] integration(`go test ./internal/integration/` PASS)
|
||||||
|
|
||||||
|
## Command
|
||||||
|
```bash
|
||||||
|
cd apps/api && go test ./internal/relation/... ./internal/star/... ./internal/service/... -count=1
|
||||||
|
go build ./cmd/server
|
||||||
|
go test ./internal/integration/ -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
- JSON 旧键 `fortune` 仍在;彻底删除需客户端迁移 ECR
|
||||||
|
- Period 结构体字段 `Lucky` json:`lucky` 仍保留兼容
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
- [x] Phase A–E 代码落地
|
||||||
|
- [ ] Reviewer 签核
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Test Report — ECR-001 Phase B
|
||||||
|
|
||||||
|
## ECR
|
||||||
|
ECR-001 (Phase B — H5 page decomposition)
|
||||||
|
|
||||||
|
## Change Level
|
||||||
|
L2(结构重构 · 无行为变更)
|
||||||
|
|
||||||
|
## Cases
|
||||||
|
- [x] `HomePage.vue` ≤400 行(实测 133)
|
||||||
|
- [x] `SynastryPage.vue` ≤400 行(实测 187)
|
||||||
|
- [x] 子组件/composable 拆分后 `npm run build:h5` 通过
|
||||||
|
- [x] `homeCatalog.spec.ts` 宫格/推荐数据不变
|
||||||
|
- [ ] 手工冒烟:首页入口点击 / 合盘生成(待 Reviewer 或本地人工)
|
||||||
|
|
||||||
|
## Result
|
||||||
|
自动化:build PASS · vitest 2/2 PASS
|
||||||
|
行为回归:依赖人工冒烟(无 e2e 专测本 Phase)
|
||||||
|
|
||||||
|
## Command
|
||||||
|
```bash
|
||||||
|
npm run build:h5
|
||||||
|
npm run test -w @yuxingu/user-h5 -- src/lib/homeCatalog.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
- Synastry 交互面大,拆分后 props/emits 面宽,建议人工点一遍落地→合盘→结果 tabs
|
||||||
|
- 其他超标页(StarProfile/Report/Profile/Ask)未纳入本 Phase
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
- [x] Phase B Acceptance(两页 ≤400 + build 绿)mapped and green
|
||||||
|
- [ ] Ready for Reviewer sign-off
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# TRACEABILITY — 愈心谷
|
||||||
|
|
||||||
|
| ID | Kind | Status | Links |
|
||||||
|
|----|------|--------|-------|
|
||||||
|
| — | product | P1 Complete | `.ai/product/feature-map.md` · `feature-spec/` · `p1-status.md` |
|
||||||
|
| ECR-001 | structural realignment | Phase A–E coded | PRODUCT_SPEC · ENGINEERING_SPEC · HANDOFF · ADR-0007 |
|
||||||
|
| ECR-001-B | H5 Home/Synastry split | PASS | TEST_REPORT/ECR-001-phaseB.md · CODE_REVIEW |
|
||||||
|
| ECR-001-A/C/D/E | docs · membership · engines · outlook | coded → review | TEST_REPORT/ECR-001-phaseA-E.md |
|
||||||
|
| ECR-002 | fortune→outlook hygiene | Approved / coded | `docs/ECR/ECR-002-fortune-hygiene.md` |
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ess = Path(os.environ.get("ESS_ROOT", "/Users/jack/Project/skills/engineering-spec-system"))
|
||||||
|
script = ess / "scripts" / "deploy-ess.py"
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(f"Missing {script}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(sys.executable, [sys.executable, str(script), *sys.argv[1:]])
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ess = Path(os.environ.get("ESS_ROOT", "/Users/jack/Project/skills/engineering-spec-system"))
|
||||||
|
script = ess / "scripts" / "ess-gate-check.py"
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(f"Missing {script}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(sys.executable, [sys.executable, str(script), *sys.argv[1:]])
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ess = Path(os.environ.get("ESS_ROOT", "/Users/jack/Project/skills/engineering-spec-system"))
|
||||||
|
script = ess / "scripts" / "ess-runner.py"
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(f"Missing {script}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(sys.executable, [sys.executable, str(script), *sys.argv[1:]])
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ess = Path(os.environ.get("ESS_ROOT", "/Users/jack/Project/skills/engineering-spec-system"))
|
||||||
|
script = ess / "scripts" / "ess-validate.py"
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(f"Missing {script}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(sys.executable, [sys.executable, str(script), *sys.argv[1:]])
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ess = Path(os.environ.get("ESS_ROOT", "/Users/jack/Project/skills/engineering-spec-system"))
|
||||||
|
script = ess / "scripts" / "version-control-check.py"
|
||||||
|
if not script.is_file():
|
||||||
|
sys.stderr.write(f"Missing {script}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
os.execv(sys.executable, [sys.executable, str(script), *sys.argv[1:]])
|
||||||
Reference in New Issue
Block a user