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:
jackyu66git
2026-08-05 17:51:40 +08:00
co-authored by Cursor
parent 583133dd09
commit 19d3cd5945
75 changed files with 4199 additions and 2505 deletions
+6 -4
View File
@@ -8,13 +8,15 @@ import (
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ReportHandler exposes portrait reports and commerce mock.
type ReportHandler struct {
Svc *report.Service
Svc *report.Service
Membership *membership.Service
}
// Register mounts report/commerce routes.
@@ -200,7 +202,7 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
me, err := h.Svc.GetMembership(c.Request.Context(), userID)
me, err := h.Membership.Get(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
@@ -233,7 +235,7 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) {
}
rid = &id
}
oid, err := h.Svc.CreateOrder(c.Request.Context(), userID, report.CreateOrderInput{
oid, err := h.Membership.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{
Kind: req.Kind, Plan: req.Plan, ReportID: rid,
})
if err != nil {
@@ -255,7 +257,7 @@ func (h *ReportHandler) PayMock(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
if err := h.Svc.PayMock(c.Request.Context(), userID, oid); err != nil {
if err := h.Membership.PayMock(c.Request.Context(), userID, oid); err != nil {
response.Fail(c, http.StatusBadRequest, 30004, err.Error())
return
}
+3 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
@@ -38,6 +39,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
Reports: reportRepo,
Invites: &repository.SynastryInviteRepo{Pool: pool},
}
membershipSvc := &membership.Service{Reports: reportRepo}
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
@@ -64,7 +66,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
(&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(authed)
(&handler.SynastryHandler{Svc: reportSvc}).Register(authed)
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
+136
View File
@@ -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
}
-316
View File
@@ -145,319 +145,3 @@ func BuildFull(aBirth, bBirth time.Time, aTime, bTime, aPlace, bPlace *string, a
}
return Output{Summary: summary, Detail: detail}
}
func relationTypePack(t, aName, bName string) (label, body string, bullets []string) {
switch t {
case "partner", "恋人", "伴侣":
return "伴侣",
fmt.Sprintf("%s与%s更适合把差异写成「亲密说明书」:欲望、节奏与安全感都说清楚。", aName, bName),
[]string{"每周一次情绪复盘,不谈对错", "亲密请求用「我需要」句式", "边界:疲惫时先暂停再继续"}
case "family", "家人", "父母", "亲子":
return "家人",
fmt.Sprintf("家人关系里,%s与%s容易把旧角色带进新对话。试着把对方当「现在的人」而不是旧剧本。", aName, bName),
[]string{"少用「你总是」句式", "大事拆成可协商的小请求", "保留各自的私人空间"}
case "friend", "朋友":
return "朋友",
fmt.Sprintf("友情里%s与%s可以更轻松地互补:约会期待与回应频率说开即可。", aName, bName),
[]string{"约见用明确时间,减少猜测", "忙时用短消息保持连结", "冲突后用玩笑或直接道歉都行,别冷处理太久"}
default:
return "", "", nil
}
}
type dimScore struct {
Score int
Teaser string
}
func dimMap(v any) map[string]dimScore {
out := map[string]dimScore{}
arr, ok := v.([]map[string]any)
if !ok {
// Build() uses []map[string]any — also tolerate []any
raw, ok2 := v.([]any)
if !ok2 {
return out
}
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
key := str(m["key"])
out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])}
}
return out
}
for _, m := range arr {
key := str(m["key"])
out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])}
}
return out
}
func asInt(v any) int {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
default:
return 0
}
}
func dimTitle(key string) string {
switch key {
case "personality":
return "性格特点"
case "communication":
return "沟通方式"
case "relation":
return "关系模式"
case "career":
return "事业节奏"
case "emotion":
return "情绪调节"
case "lifestyle":
return "生活节奏"
default:
return key
}
}
func dimNote(key string, gap int, aName, bName string) string {
if gap > 12 {
return fmt.Sprintf("在「%s」上,%s 分值更高,适合由 %s 多给结构,%s 多给弹性。", dimTitle(key), aName, aName, bName)
}
if gap < -12 {
return fmt.Sprintf("在「%s」上,%s 分值更高,相处时可多尊重 %s 的节奏。", dimTitle(key), bName, bName)
}
return fmt.Sprintf("在「%s」上双方接近,容易形成默契,也要防止都默认对方「应该懂」。", dimTitle(key))
}
func firstTeaser(m map[string]dimScore, key string) string {
if d, ok := m[key]; ok && d.Teaser != "" {
return d.Teaser
}
return "表达方式各有节奏"
}
func complementarityKey(a, b string) string {
if a == b {
return "same"
}
// simple buckets by trait family
drive := map[string]string{
"稳进探索者": "steady", "细腻分析者": "steady", "守护担当者": "steady", "洞察策略者": "steady",
"敏锐连接者": "warm", "温和协调者": "warm", "热忱鼓舞者": "warm",
"果断行动派": "drive", "自由创造者": "drive",
}
ak, bk := drive[a], drive[b]
if ak == "" || bk == "" {
return "mix"
}
if ak == bk {
return "same_family"
}
if (ak == "steady" && bk == "warm") || (ak == "warm" && bk == "steady") {
return "steady_warm"
}
if (ak == "drive" && bk == "steady") || (ak == "steady" && bk == "drive") {
return "drive_steady"
}
if (ak == "drive" && bk == "warm") || (ak == "warm" && bk == "drive") {
return "drive_warm"
}
return "mix"
}
type compCopy struct {
OneLiner, Overview, Chemistry, DeepOverview, CommDeep, ConflictDeep, IntimacyDeep, GrowthDeep string
ChemistryPoints, Watchouts, CommTips, ConflictTips, IntimacyTips, GrowthTips, Weekly, Scripts []string
}
func complementarityCopy(key, aName, bName, aLabel, bLabel string) compCopy {
base := compCopy{
OneLiner: fmt.Sprintf("%s偏「%s」,%s偏「%s」——差异可以写成相处说明书。", aName, aLabel, bName, bLabel),
Overview: fmt.Sprintf("双方在表达、节奏与需求上并不相同。把差异看清楚,比急着证明「谁更对」更有用。下面从沟通、冲突、亲密与共同成长几个维度展开。"),
Chemistry: "互补往往出现在:一方给结构,另一方给温度;或一方推进,另一方稳住质量。",
Weekly: []string{
"本周进行一次 20 分钟「非解决问题」闲聊或散步。",
"各自写三件「我需要你这样支持我」的具体行为,互换阅读。",
"约定一个冲突停火词,任一方说出即暂停 15 分钟。",
},
Scripts: []string{
fmt.Sprintf("%s可以说:我需要先把事实说清楚,再谈感受。", aName),
fmt.Sprintf("%s可以说:我希望你先听到我的感受,再给方案。", bName),
"我们可以先复述对方一句,再表达自己的需要。",
},
}
switch key {
case "same":
base.OneLiner = fmt.Sprintf("你们风格接近(都偏「%s」),默契来得快,也要防止一起陷入同样的盲区。", aLabel)
base.Chemistry = "同类相吸:理解成本低,推进或回避也可能同步发生。"
base.DeepOverview = "风格相近意味着你们很容易「懂对方在想什么」,但也可能同时逃避冲突,或同时过度冲刺。建议定期引入外部视角(朋友建议、清单复盘),打破镜像盲区。"
base.CommDeep = "沟通效率高,但要刻意练习提出不同意见。安排「唱反调」轮值:每周一人专门提出风险点。"
base.ConflictDeep = "冲突可能被快速和好掩盖,问题未真正处理。用「问题清单」追踪未完成议题。"
base.IntimacyDeep = "熟悉感强,新鲜感需主动创造:共同学习或小旅行比重复日常更能充电。"
base.GrowthDeep = "一起设定一个共同小目标,并互相做问责伙伴。"
base.ChemistryPoints = []string{"理解成本低", "节奏容易对齐", "共同语言多"}
base.Watchouts = []string{"共享同一盲区", "缺少外部校正", "意见过于一致缺少张力"}
base.CommTips = []string{"鼓励提出异议", "重要决定写利弊表", "避免默认对方已懂"}
base.ConflictTips = []string{"追踪未完成议题", "避免假性和好", "冷静后再做决定"}
base.IntimacyTips = []string{"主动制造新鲜体验", "表达感谢要具体", "保留个人空间"}
base.GrowthTips = []string{"共同目标 + 问责", "每月复盘一次关系", "引入可信第三方建议"}
case "steady_warm":
base.Chemistry = "稳与暖互补:一方提供结构与可靠,另一方提供连接与温度。"
base.DeepOverview = fmt.Sprintf("%s与%s之间,最常见的张力是「要先讲清楚」还是「要先被看见」。若能轮流满足这两种需求,关系会既安全又有温度。", aName, bName)
base.CommDeep = "沟通协议:情绪话题先共鸣 2 分钟,再进入事实与方案;事务话题先结论,再补感受。"
base.ConflictDeep = "稳的一方别用沉默当结束;暖的一方别用追问升级压力。停火后用「我需要…」重开。"
base.IntimacyDeep = "暖的一方需要回应频率;稳的一方需要可预期的独处。把两者写进约定。"
base.GrowthDeep = "把互补写成分工:谁更擅长安抚,谁更擅长推进落地。"
base.ChemistryPoints = []string{"结构 × 温度", "可靠 × 连接", "可形成完整支持系统"}
base.Watchouts = []string{"一方觉得被冷落", "一方觉得被情绪淹没", "节奏错位积累委屈"}
base.CommTips = []string{"情绪先共鸣再方案", "事务先结论再感受", "用文字确认关键约定"}
base.ConflictTips = []string{"禁止用沉默结束话题", "追问前先问是否方便", "停火词机制"}
base.IntimacyTips = []string{"约定回应窗口", "尊重独处不被解读为冷淡", "每周一次深度连接"}
base.GrowthTips = []string{"按优势分工", "互相学习对方语言", "月度关系复盘"}
case "drive_steady":
base.Chemistry = "推与稳互补:一方破局加速,另一方把关质量与可持续。"
base.DeepOverview = "行动派容易嫌分析派慢;稳健派容易嫌行动派莽。把「速度」用在试验,「稳健」用在关键承诺,冲突会下降。"
base.CommDeep = "行动方给时间盒与最小方案;稳健方在时限内给风险清单,而不是无限延期。"
base.ConflictDeep = "冲突焦点常是节奏。先对齐「这是可逆试验还是重大决定」,再选速度。"
base.IntimacyDeep = "行动方用陪伴质量弥补碎片时间;稳健方减少用担忧浇灭热情,改用「我支持你试,我们设检查点」。"
base.GrowthDeep = "共同项目里明确角色:谁启动、谁验收、何时复盘。"
base.ChemistryPoints = []string{"破局 × 把关", "速度 × 质量", "试验与承诺可分工"}
base.Watchouts = []string{"节奏互斥", "一方压抑热情", "一方焦虑失控"}
base.CommTips = []string{"先定义可逆/不可逆", "时间盒决策", "风险清单限时"}
base.ConflictTips = []string{"争论节奏前先分类问题", "避免人格化指责", "用检查点代替否决"}
base.IntimacyTips = []string{"质量陪伴", "支持试验+检查点", "庆祝小进展"}
base.GrowthTips = []string{"项目角色清晰", "复盘节奏", "互相翻译动机"}
case "drive_warm":
base.Chemistry = "驱动与连接互补:一方带节奏,另一方维系人心与氛围。"
base.DeepOverview = "热情与效率碰到一起很有火花,也容易在「推进」与「照顾感受」之间拉扯。约定场景切换:冲刺模式 / 连接模式。"
base.CommDeep = "冲刺时短讯同步进度;连接时关掉任务话题。不要用效率语言处理情绪时刻。"
base.ConflictDeep = "驱动方避免「你想太多」;连接方避免「你只在乎结果」。改说具体需求。"
base.IntimacyDeep = "用共同体验(运动、活动)同时满足推进感与连接感。"
base.GrowthDeep = "轮流做「本周关系主理人」,负责安排一次连接或一次共同目标。"
base.ChemistryPoints = []string{"推进力 × 氛围", "行动号召力强", "共同体验易充电"}
base.Watchouts = []string{"情绪被效率压过", "承诺过多难兑现", "连接变任务化"}
base.CommTips = []string{"模式切换:冲刺/连接", "情绪时刻禁用效率话术", "进度短讯化"}
base.ConflictTips = []string{"禁止否定感受", "需求具体化", "修复后再推进"}
base.IntimacyTips = []string{"共同体验", "兑现小承诺", "非任务陪伴"}
base.GrowthTips = []string{"轮值关系主理人", "控制并行承诺", "庆祝与复盘并重"}
case "same_family":
base.DeepOverview = "你们属于相近气质族,容易互相理解,也要主动制造一点建设性差异,避免舒适区停滞。"
base.CommDeep = "沟通顺畅时更要确认细节,防止「好像说好了」其实理解不同。"
base.ConflictDeep = "冲突可能被淡化。强制做一次「最担心的三件事」互换。"
base.IntimacyDeep = "在舒适之外增加挑战性共同任务,刷新关系动能。"
base.GrowthDeep = "互相指出对方一个盲区,并约定本月各改一项小行为。"
base.ChemistryPoints = []string{"气质相近", "理解门槛低", "协作起步快"}
base.Watchouts = []string{"舒适区停滞", "细节默认错误", "回避尖锐议题"}
base.CommTips = []string{"确认细节", "书面关键约定", "鼓励异议"}
base.ConflictTips = []string{"互换担忧清单", "不假性和好", "设讨论截止"}
base.IntimacyTips = []string{"共同挑战任务", "新鲜体验", "具体感谢"}
base.GrowthTips = []string{"互指一个盲区", "月改一小行为", "外部输入"}
default:
base.DeepOverview = fmt.Sprintf("%s与%s风格路径不同,说明书价值更高。先承认差异合法,再谈协作规则。", aName, bName)
base.CommDeep = "建立双通道:事实通道与感受通道,讨论前先声明走哪一条。"
base.ConflictDeep = "冲突时回到共同目标句:我们都希望关系更好/事情做成。"
base.IntimacyDeep = "用定期同步取代猜测;空间与连接都要有配额。"
base.GrowthDeep = "把差异写成「我擅长 / 我需要」对照表,贴在看得见的地方。"
base.ChemistryPoints = []string{"视角多样", "可互补决策", "扩展彼此舒适区"}
base.Watchouts = []string{"误解成本高", "价值观冲突需早谈", "节奏长期错位"}
base.CommTips = []string{"声明沟通通道", "复述再回应", "关键约定书面化"}
base.ConflictTips = []string{"回到共同目标", "停火机制", "一次只谈一个议题"}
base.IntimacyTips = []string{"定期同步", "空间与连接配额", "具体肯定"}
base.GrowthTips = []string{"擅长/需要对照表", "月度复盘", "小步共同目标"}
}
return base
}
func harmonyIndex(dims []map[string]any) int {
if len(dims) == 0 {
return 72
}
sum := 0
for _, d := range dims {
gap := asInt(d["gap"])
sum += 100 - gap*4
}
avg := sum / len(dims)
if avg < 45 {
return 45
}
if avg > 96 {
return 96
}
return avg
}
func fitFromHarmony(score int, aLabel, bLabel string) (string, []string) {
switch {
case score >= 82:
return "默契互补型", []string{
fmt.Sprintf("%s与%s节奏接近,适合共同推进小事。", aLabel, bLabel),
"把欣赏说出口,默契会更稳。",
"每周留一次轻松同步,不必每次谈大事。",
}
case score >= 68:
return "磨合成长型", []string{
"差异可见,正好写成相处说明书。",
"冲突时先复述再提方案。",
"共同目标写清楚,减少猜忌。",
}
default:
return "反差探索型", []string{
"反差大不等于不合,关键是边界与节奏。",
"重要约定尽量具体、可检查。",
"给彼此独处充电的空间。",
}
}
}
func starPairNote(a, b string) string {
if a == b {
return fmt.Sprintf("同为%s:容易共鸣,也要避免同质盲区。", a)
}
return fmt.Sprintf("%s × %s:节奏不同,适合「我负责启动 / 你负责收尾」式分工。", a, b)
}
func str(v any) string {
s, _ := v.(string)
return s
}
func strSlice(v any) []string {
arr, ok := v.([]string)
if ok {
return arr
}
raw, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, x := range raw {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+66
View File
@@ -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
}
+123
View File
@@ -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
}
// 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
}
+10 -169
View File
@@ -1,12 +1,12 @@
// Package star builds 星座 reports (natal chart · fortune · deep copy).
// Package star builds 星座 reports (natal chart · period outlook · deep copy).
package star
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/outlook"
)
// Output is free summary + gated detail.
@@ -21,7 +21,7 @@ type BuildOpts struct {
BirthTime *string
BirthPlace *string
Name string
AsOf time.Time // fortune anchor; zero = now
AsOf time.Time // period outlook anchor; zero = now
}
// Build generates StarProfile from birth date (compat wrapper).
@@ -68,7 +68,7 @@ func BuildWith(opts BuildOpts) (Output, error) {
if asOf.IsZero() {
asOf = time.Now()
}
fort := fortune.Build(chart, asOf)
fort := outlook.Build(chart, asOf)
daily := fort.Daily
planetsOut := make([]map[string]any, 0, len(chart.Planets))
@@ -114,8 +114,10 @@ func BuildWith(opts BuildOpts) (Output, error) {
},
"planets": planetsOut,
"aspects_preview": aspectPreview,
"fortune": fort.AsMap(),
"transits": fort.AsMap()["transits"],
// "fortune" kept for client compat; prefer "outlook" (ECR-002).
"fortune": fort.AsMap(),
"outlook": fort.AsMap(),
"transits": fort.AsMap()["transits"],
"daily_soft": map[string]any{
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
@@ -169,169 +171,8 @@ func BuildWith(opts BuildOpts) (Output, error) {
"behavior_pattern": pack.SunDeep,
"relation_style": pack.RelationDeep,
"growth_direction": pack.GrowthDeep,
"fortune_detail": fort.AsMap(),
"fortune_detail": fort.AsMap(), // compat
"outlook_detail": fort.AsMap(),
}
return Output{Summary: summary, Detail: detail}, nil
}
func signIndexOf(key string) int {
for i, s := range []string{
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
} {
if s == key {
return i
}
}
return 0
}
func planetBullets(chart natal.Chart) []string {
out := make([]string, 0, 6)
for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} {
for _, p := range chart.Planets {
if p.Key == key {
out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House))
break
}
}
}
return out
}
func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any {
ks := keywords
if len(ks) > 3 {
ks = ks[:3]
}
return map[string]any{
"key": key, "title": title, "label": b.Sign,
"element": b.Element, "modality": b.Modality,
"teaser": teaser, "keywords": ks,
}
}
func section(title, body string, bullets []string) map[string]any {
return map[string]any{"title": title, "body": body, "bullets": bullets}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// SignLabels returns sun/moon/rise labels (uses natal engine).
func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) {
return SignLabelsPlace(birth, birthTime, nil)
}
// SignLabelsPlace includes birth place for rising accuracy.
func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) {
c, err := natal.Compute(birth, birthTime, place)
if err != nil {
return "", "", ""
}
return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign
}
// NatalChart exposes chart for relation/synastry.
func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) {
return natal.Compute(birth, birthTime, place)
}
type signMeta struct {
Key, Label, Element, Modality string
}
type pack struct {
Label, OneLiner, Overview, LifeTip string
Keywords []string
Scores map[string]int
SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string
SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string
SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string
Strengths, BlindSpots, Scripts []string
PlanWeek, PlanMonth, PlanLong string
FAQ []map[string]string
}
var packs = map[string]pack{}
func init() {
for _, s := range []signMeta{
{"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"},
} {
packs[s.Key] = defaultPack(s)
}
packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。",
"你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。")
packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。",
"你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。")
packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。",
"信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。")
packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。",
"你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。")
packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。",
"热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。")
packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。",
"把「足够好」纳入标准,你会轻松很多,交付也会更快。")
packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。",
"协调是天赋。重要决定里请给自己一票,而不只是各方折中。")
packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。",
"信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。")
packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。",
"探索欲强。给自由一点结构,热情才能变成持续作品。")
packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。",
"担当是优势。学会求助与休息,攀登才可持续。")
packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。",
"独特视角是礼物。把想法翻译成别人跟得上的一步行动。")
packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。",
"共情是天赋。区分「理解」与「承包」,你会更稳。")
}
func defaultPack(s signMeta) pack {
return pack{
Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。",
Overview: "在星座框架里,你的表达与需求有独特侧重。",
LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。",
Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"},
Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75},
SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。",
CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。",
SunDeep: "太阳星座描述你的核心动机与自我表达。",
MoonDeep: "月亮星座指向情绪调节与安全感来源。",
RiseDeep: "上升星座影响别人对你的第一印象。",
RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。",
CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。",
GrowthDeep: "成长是认识模式后做可验证的小调整。",
SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"},
MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"},
RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"},
RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"},
CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"},
GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"},
Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"},
BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"},
Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"},
PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。",
PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。",
PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。",
FAQ: []map[string]string{
{"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"},
{"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"},
},
}
}
func enrich(base pack, label, one, overview string) pack {
base.Label = label
base.OneLiner = one
base.Overview = overview
base.Keywords = []string{label, "星座", "运势"}
return base
}
+75
View File
@@ -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)
}
@@ -1,5 +1,5 @@
// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
package fortune
// Package outlook synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
package outlook
import (
"fmt"
@@ -1,4 +1,4 @@
package fortune
package outlook
import (
"testing"
+96
View File
@@ -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
}
+1
View File
@@ -2,6 +2,7 @@ import { createClient, createBrowserAdapters } from '@yuxingu/sdk'
/** Shared API client for user-h5 (proxied to Go in dev). */
export const api = createClient({
// SDK paths already include `/api/v1/...`; with SPA base `/psy/` → `/psy/api/v1/...`
baseURL: '/psy',
adapters: createBrowserAdapters(),
})
@@ -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,
}
}
+17
View File
@@ -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)
}
})
})
+94
View File
@@ -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
+7
View File
@@ -4,6 +4,7 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
base: '/psy/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -12,6 +13,12 @@ export default defineConfig({
server: {
port: 5173,
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': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,