From 13860bf1efe21803dec39bfd8bc6a6068b83c445 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Wed, 12 Aug 2026 22:05:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ECR-011):=20=E6=98=B5=E7=A7=B0=E3=80=81?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=B4=B4=E5=A3=AB=E4=B8=8E=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=20Self=20=E5=94=AF=E4=B8=80/=E5=90=88=E7=9B=98=E4=BA=A4?= =?UTF-8?q?=E5=8F=89=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor --- .ai/product/feature-spec/account-auth.md | 5 +- .ai/product/feature-spec/home.md | 36 +- .ai/product/feature-spec/profile.md | 11 + .ai/product/feature-spec/star-profile.md | 1 + apps/api/internal/handler/auth.go | 25 ++ apps/api/internal/handler/home.go | 12 + apps/api/internal/handler/profile.go | 5 + apps/api/internal/httpserver/router.go | 6 +- apps/api/internal/integration/ecr011_test.go | 66 ++++ apps/api/internal/nickname/generate.go | 73 ++++ apps/api/internal/nickname/generate_test.go | 55 +++ apps/api/internal/repository/auth_repo.go | 8 + apps/api/internal/repository/profile_repo.go | 9 + apps/api/internal/service/auth/service.go | 44 ++- apps/api/internal/service/home/daily_tips.go | 217 +++++++++++ apps/api/internal/service/home/service.go | 7 +- apps/api/internal/service/profile/service.go | 12 + apps/api/internal/service/report/service.go | 5 + apps/api/internal/yijing/seed.go | 93 +++++ apps/api/internal/yijing/seed_test.go | 29 ++ .../000017_unique_self_profile.down.sql | 1 + .../000017_unique_self_profile.up.sql | 16 + .../src/components/home/HomeSelfCard.vue | 362 +++++++++++++----- .../src/components/home/HomeToolGrid.vue | 90 ++--- .../profile/AccountNicknameCard.vue | 171 +++++++++ .../synastry/SynastryClassicSelects.vue | 9 +- .../components/synastry/SynastryLanding.vue | 7 +- .../src/composables/synastryPageActions.ts | 6 +- apps/user-h5/src/composables/useAskPage.ts | 4 +- .../src/composables/useHomeDailyTips.ts | 77 ++++ apps/user-h5/src/composables/useHomeMood.ts | 39 -- apps/user-h5/src/composables/useHomePage.ts | 17 +- .../src/composables/useImageCardPage.ts | 4 +- .../src/composables/useLifeRhythmPage.ts | 4 +- .../src/composables/usePortraitPage.ts | 4 +- .../user-h5/src/composables/useProfilePage.ts | 14 + .../src/composables/useRelationPage.ts | 17 +- apps/user-h5/src/composables/useScalePage.ts | 5 +- .../src/composables/useStarProfilePage.ts | 5 +- .../src/composables/useSynastryPage.ts | 6 +- apps/user-h5/src/lib/authSession.ts | 76 ++++ apps/user-h5/src/lib/homeCatalog.ts | 2 +- apps/user-h5/src/pages/HomePage.vue | 7 +- apps/user-h5/src/pages/ImageCardPage.spec.ts | 3 + apps/user-h5/src/pages/LoginPage.vue | 4 +- apps/user-h5/src/pages/ProfilePage.spec.ts | 5 +- apps/user-h5/src/pages/ProfilePage.vue | 8 + apps/user-h5/src/pages/SynastryPage.spec.ts | 16 + apps/user-h5/src/pages/SynastryPage.vue | 4 +- .../BD-2026-011-identity-home-profile.md | 77 ++++ docs/CHANGELOG.md | 8 + docs/CODE_REVIEW/ECR-011.md | 41 ++ docs/ECR/ECR-011-identity-home-profile.md | 71 ++++ .../ECR-011-identity-home-profile.md | 58 +++ docs/HANDOFF/ECR-011-architect-to-engineer.md | 39 ++ docs/HANDOFF/ECR-011-engineer-to-reviewer.md | 24 ++ docs/HANDOFF/ECR-011-implementation-report.md | 30 ++ docs/HANDOFF/ECR-011-reviewer-to-engineer.md | 15 + .../ECR-011-identity-home-profile.md | 18 + docs/PROJECT_PROFILE.md | 6 +- docs/STATE/ECR-011.md | 14 + docs/TASKS/TASK-011-ECR011.yaml | 19 + docs/TEST_REPORT/ECR-011.md | 33 ++ docs/TRACEABILITY.md | 1 + packages/sdk/src/index.ts | 4 + packages/types/src/index.ts | 13 + proto/openapi.yaml | 38 +- 67 files changed, 1936 insertions(+), 275 deletions(-) create mode 100644 apps/api/internal/integration/ecr011_test.go create mode 100644 apps/api/internal/nickname/generate.go create mode 100644 apps/api/internal/nickname/generate_test.go create mode 100644 apps/api/internal/service/home/daily_tips.go create mode 100644 apps/api/internal/yijing/seed.go create mode 100644 apps/api/internal/yijing/seed_test.go create mode 100644 apps/api/migrations/000017_unique_self_profile.down.sql create mode 100644 apps/api/migrations/000017_unique_self_profile.up.sql create mode 100644 apps/user-h5/src/components/profile/AccountNicknameCard.vue create mode 100644 apps/user-h5/src/composables/useHomeDailyTips.ts delete mode 100644 apps/user-h5/src/composables/useHomeMood.ts create mode 100644 docs/BACKEND_DESIGN/BD-2026-011-identity-home-profile.md create mode 100644 docs/CODE_REVIEW/ECR-011.md create mode 100644 docs/ECR/ECR-011-identity-home-profile.md create mode 100644 docs/ENGINEERING_SPEC/ECR-011-identity-home-profile.md create mode 100644 docs/HANDOFF/ECR-011-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-011-engineer-to-reviewer.md create mode 100644 docs/HANDOFF/ECR-011-implementation-report.md create mode 100644 docs/HANDOFF/ECR-011-reviewer-to-engineer.md create mode 100644 docs/PRODUCT_SPEC/ECR-011-identity-home-profile.md create mode 100644 docs/STATE/ECR-011.md create mode 100644 docs/TASKS/TASK-011-ECR011.yaml create mode 100644 docs/TEST_REPORT/ECR-011.md diff --git a/.ai/product/feature-spec/account-auth.md b/.ai/product/feature-spec/account-auth.md index 0025f75..e1ff26b 100644 --- a/.ai/product/feature-spec/account-auth.md +++ b/.ai/product/feature-spec/account-auth.md @@ -83,6 +83,8 @@ Device 绑定到账号 User | R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 | | R5 | 登出作废 session;设备可再登录其他账号 | | R6 | 正式环境恢复校验前须改回本 Spec | +| R7 | 新账号未填昵称时,服务端用「意象词+场景词」词库(100×100)随机生成,如心语岛、微光谷 | +| R8 | 账号昵称可在个人档案页修改(PATCH `/auth/me`);首页展示昵称而非「自己」 | --- @@ -97,10 +99,11 @@ Device 绑定到账号 User | Method | Path | 说明 | |---|---|---| -| POST | `/api/v1/auth/register` | 注册 | +| POST | `/api/v1/auth/register` | 注册(可带 nickname) | | POST | `/api/v1/auth/login` | 登录 | | POST | `/api/v1/auth/logout` | 登出 | | GET | `/api/v1/auth/me` | 当前账号 | +| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` | --- diff --git a/.ai/product/feature-spec/home.md b/.ai/product/feature-spec/home.md index 68bd445..8dc629d 100644 --- a/.ai/product/feature-spec/home.md +++ b/.ai/product/feature-spec/home.md @@ -63,8 +63,9 @@ ```text / ├── Hero / 品牌 -├── 建档 CTA -├── 功能入口 +├── 「自己」卡片标题展示账号昵称(默认意象词+场景词;档案页可改) +├── 穿衣指数 · 颜色搭配 · 养生推荐 +├── 功能入口(4 列固定网格 · 多行换行 · 禁止横向滑动) └── (可选)推荐内容 ``` @@ -90,27 +91,34 @@ Empty:首页本身是入口,不因无档案空白死页。 | R2 | 不展示内部运维信息(API 连接状态等) | | R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) | | R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 | +| R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 档案生日时按生日+本地时刻易经卦象种子调大模型生成;无档案或 LLM 不可用则本地回退 | +| R6 | 文案禁止占卜/算命恐吓与医疗疗效承诺;卦象仅作节律意象 | --- ## 8. 数据模型影响 -经首页建档时写 `profiles`(见 profile Spec)。 +经首页建档时写 `profiles`(见 profile Spec)。每日贴士可缓存(同用户约 45 分钟 / 按时段刷新)。 --- ## 9. API 需求 -复用 `POST /profiles`、`POST /reports/portrait`;无专用 home API。 +| Method | Path | 说明 | +|---|---|---| +| GET | `/api/v1/home/tools` | 宫格(已有) | +| GET | `/api/v1/home/daily-tips` | 自己卡片贴士(DeviceAuth;有 birthday 则 LLM) | +| — | 复用 | `POST /profiles`、`POST /reports/portrait` | --- ## 10. 权限设计 -| 能力 | Visitor | -|---|---| -| 浏览首页 | ✓ | -| 建档并生成画像 | ✓ | +| 能力 | Visitor | User | +|---|---|---| +| 浏览首页 | ✓ | ✓ | +| 建档并生成画像 | 需登录 | ✓ | +| daily-tips(个性化) | 回退文案 | 有 Self 生日则 LLM | --- @@ -134,10 +142,22 @@ Empty:首页本身是入口,不因无档案空白死页。 **When** 提交主 CTA **Then** 进入画像基础结果路径 +**Given** 已登录且有 Self 生日、已配 deepseek +**When** GET `/home/daily-tips` +**Then** `source=llm` 且含 clothing_index / palette / wellness + +**Given** 无档案或无 LLM +**When** GET `/home/daily-tips` +**Then** `source=fallback` 且页面不白屏 + --- ## 13. AI 开发前检查 +- [x] lexicon +- [x] 不恐吓 / 不算命话术 +- [x] OpenAPI 已登记 `/home/daily-tips` + - [x] Spec 齐全 --- diff --git a/.ai/product/feature-spec/profile.md b/.ai/product/feature-spec/profile.md index bceb723..f7e9710 100644 --- a/.ai/product/feature-spec/profile.md +++ b/.ai/product/feature-spec/profile.md @@ -92,6 +92,8 @@ | R4 | 删除策略:任务未要求硬删 PII 时按实现(软删优先);级联影响报告见服务规则 | | R5 | Ask / 画像 / 关系必须挂合法 profile_id | | R6 | 文案用「个人档案」,不用命盘 | +| R7 | **每账号至多一条** `relation=self`(唯一索引);再建 self → 409;解码/星盘等复用或更新既有 Self,禁止重复造「我」 | +| R8 | 合盘/匹配对象列表只展示 `other` + 附近的人;不得把多余 self 当作 TA | --- @@ -100,6 +102,7 @@ | 表 | 字段要点 | |---|---| | `profiles` | id, user_id, relation, display_name, birth_date, birth_time?, birth_place?, gender?, relation_type? | +| 约束 | 部分唯一:`(user_id) WHERE relation='self' AND deleted_at IS NULL` | --- @@ -143,10 +146,18 @@ **When** 添加 TA **Then** relation=other 且可用于关系理解 +**Given** 已有 Self +**When** 再次 POST relation=self +**Then** 409 conflict;列表仍仅一条 Self + **Given** 他人 id **When** PATCH **Then** 失败 +**Given** 同账号多条历史 self(脏数据) +**When** 迁移/启动 +**Then** 仅保留最新一条,其余软删;合盘 TA 侧不再出现多个「我」 + --- ## 13. AI 开发前检查 diff --git a/.ai/product/feature-spec/star-profile.md b/.ai/product/feature-spec/star-profile.md index 76ca44f..9e321b1 100644 --- a/.ai/product/feature-spec/star-profile.md +++ b/.ai/product/feature-spec/star-profile.md @@ -106,6 +106,7 @@ | R8 | 合盘与人格匹配分流:`/synastry` 盘型;`/relation` 行为风格 | | R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 | | R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign | +| R11 | 合盘交叉校验:档案 A 为本账号 Self;档案 B 须为本账号 `other` 或附近可见他人 Self;拒绝 self×self / 同 id | --- diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index 911bfbe..660ba4f 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -23,6 +23,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) { g.POST("/login", h.Login) g.POST("/logout", h.Logout) g.GET("/me", h.Me) + g.PATCH("/me", h.PatchMe) } type authBody struct { @@ -97,6 +98,30 @@ func (h *AuthHandler) Me(c *gin.Context) { response.OK(c, me) } +type patchMeBody struct { + Nickname string `json:"nickname"` +} + +// PatchMe handles PATCH /auth/me (update nickname). +func (h *AuthHandler) PatchMe(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var body patchMeBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40113, err.Error()) + return + } + response.OK(c, me) +} + func bearerToken(c *gin.Context) string { h := c.GetHeader("Authorization") if strings.HasPrefix(strings.ToLower(h), "bearer ") { diff --git a/apps/api/internal/handler/home.go b/apps/api/internal/handler/home.go index 06b8de8..a716286 100644 --- a/apps/api/internal/handler/home.go +++ b/apps/api/internal/handler/home.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/service/home" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" @@ -19,6 +20,7 @@ type HomeHandler struct { func (h *HomeHandler) Register(api *gin.RouterGroup) { g := api.Group("/home") g.GET("/tools", h.Tools) + g.GET("/daily-tips", h.DailyTips) } func (h *HomeHandler) Tools(c *gin.Context) { @@ -32,3 +34,13 @@ func (h *HomeHandler) Tools(c *gin.Context) { } response.OK(c, gin.H{"items": items}) } + +func (h *HomeHandler) DailyTips(c *gin.Context) { + uid, _ := middleware.UserIDFromContext(c) + tips, err := h.Svc.DailyTips(c.Request.Context(), uid) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50031, "daily tips failed") + return + } + response.OK(c, tips) +} diff --git a/apps/api/internal/handler/profile.go b/apps/api/internal/handler/profile.go index d8b209c..6f6d1ac 100644 --- a/apps/api/internal/handler/profile.go +++ b/apps/api/internal/handler/profile.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "time" @@ -56,6 +57,10 @@ func (h *ProfileHandler) Create(c *gin.Context) { RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace, }) if err != nil { + if errors.Is(err, profile.ErrSelfExists) { + response.Fail(c, http.StatusConflict, 40902, err.Error()) + return + } response.Fail(c, http.StatusBadRequest, 30001, err.Error()) return } diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index 8588d28..c0d2634 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -62,7 +62,11 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { Reports: reportRepo, Quotas: &repository.ImageCardRepo{Pool: pool}, } - homeSvc := &homesvc.Service{Repo: &repository.HomeToolsRepo{Pool: pool}} + homeSvc := &homesvc.Service{ + Repo: &repository.HomeToolsRepo{Pool: pool}, + Profiles: profileRepo, + LLM: llm, + } analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo} adminSvc := &adminsvc.Service{ Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo, diff --git a/apps/api/internal/integration/ecr011_test.go b/apps/api/internal/integration/ecr011_test.go new file mode 100644 index 0000000..2962ee1 --- /dev/null +++ b/apps/api/internal/integration/ecr011_test.go @@ -0,0 +1,66 @@ +package integration_test + +import ( + "net/http" + "testing" +) + +// ECR-011: one self per account; nickname patch; daily-tips envelope. +func TestECR011ProfileIntegrityNicknameTips(t *testing.T) { + r, _ := setupAPI(t) + key := mustRegister(t, r) + + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1990-05-12", "display_name": "我", + }, key) + self := decodeData[map[string]any](t, env.Data) + selfID, _ := self["id"].(string) + if selfID == "" { + t.Fatal("missing self id") + } + + _, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1991-01-01", "display_name": "我2", + }, key, 40902) + if status != http.StatusConflict { + t.Fatalf("second self want HTTP 409, got %d", status) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "other", "birth_date": "1992-08-20", "display_name": "TA", + }, key) + other := decodeData[map[string]any](t, env.Data) + otherID, _ := other["id"].(string) + + _, key, status = doJSONExpect(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{ + "profile_id_a": selfID, "profile_id_b": selfID, + }, key, 10000) + if status != http.StatusBadRequest { + t.Fatalf("same id synastry want HTTP 400, got %d", status) + } + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{ + "profile_id_a": selfID, "profile_id_b": otherID, + }, key) + + env, key = doJSON(t, r, http.MethodPatch, "/api/v1/auth/me", map[string]any{ + "nickname": "心语岛", + }, key) + me := decodeData[map[string]any](t, env.Data) + if me["nickname"] != "心语岛" { + t.Fatalf("nickname want 心语岛 got %#v", me["nickname"]) + } + + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/home/daily-tips", nil, key) + tips := decodeData[map[string]any](t, env.Data) + if tips["clothing"] == nil || tips["palette"] == nil || tips["wellness"] == nil { + t.Fatalf("daily-tips missing fields: %#v", tips) + } + if _, ok := tips["clothing_index"].(float64); !ok { + t.Fatalf("clothing_index missing: %#v", tips["clothing_index"]) + } + src, _ := tips["source"].(string) + if src != "llm" && src != "fallback" { + t.Fatalf("source want llm|fallback got %q", src) + } +} diff --git a/apps/api/internal/nickname/generate.go b/apps/api/internal/nickname/generate.go new file mode 100644 index 0000000..4ba8570 --- /dev/null +++ b/apps/api/internal/nickname/generate.go @@ -0,0 +1,73 @@ +// Package nickname generates default display names: 意象词 + 场景词. +package nickname + +import ( + "crypto/rand" + "math/big" +) + +// imageryWords: 100 soft, product-safe imagery stems (愈心谷 tone). +var imageryWords = []string{ + "心语", "微光", "星愿", "清风", "暖心", "柔光", "浅梦", "静水", "云舒", "月白", + "初晴", "晚风", "晨露", "细雨", "薄雾", "青禾", "橘光", "蜜语", "安然", "念初", + "听澜", "拾光", "半夏", "秋迟", "春晓", "夏安", "冬暖", "暮色", "晓风", "星河", + "流年", "浅唱", "低语", "轻舟", "远山", "近水", "素心", "明澄", "澄澈", "清欢", + "淡然", "悠然", "恬静", "安宁", "和风", "暖阳", "软光", "碎金", "银杏", "白露", + "谷雨", "立秋", "小满", "惊蛰", "霜降", "芒种", "处暑", "大寒", "小雪", "清明", + "听雪", "望舒", "栖云", "栖月", "栖星", "栖心", "栖光", "栖梦", "栖风", "栖雨", + "知夏", "知秋", "知春", "知暖", "知光", "知心", "知安", "知宁", "知澄", "知柔", + "若水", "若云", "若风", "若光", "若梦", "若安", "若澄", "若暖", "若柔", "若心", + "一念", "一息", "一寸", "一抹", "一缕", "一盏", "一程", "一湾", "一隅", "一席", +} + +// sceneWords: 100 place-like endings that read naturally after imagery stems. +var sceneWords = []string{ + "岛", "谷", "屿", "里", "湾", "庭", "舍", "居", "苑", "港", + "岸", "亭", "巷", "院", "轩", "斋", "庐", "屋", "堂", "阁", + "台", "畔", "角", "隅", "间", "处", "边", "旁", "侧", "前", + "后", "上", "下", "中", "外", "内", "东", "西", "南", "北", + "林", "原", "坡", "丘", "岭", "峰", "溪", "泉", "湖", "海", + "田", "野", "陌", "径", "路", "桥", "门", "窗", "廊", "阶", + "坊", "市", "镇", "村", "庄", "乡", "社", "园", "圃", "畦", + "巢", "窝", "坞", "渡", "津", "洲", "渚", "汀", "滩", "礁", + "岚", "岫", "岩", "石", "沙", "草", "花", "叶", "枝", "根", + "灯", "烛", "席", "枕", "案", "书", "笺", "墨", "茶", "盏", +} + +// Random returns 意象词+场景词, e.g. 心语岛. +func Random() string { + a := pick(len(imageryWords)) + b := pick(len(sceneWords)) + return imageryWords[a] + sceneWords[b] +} + +func pick(n int) int { + if n <= 0 { + return 0 + } + v, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + return 0 + } + return int(v.Int64()) +} + +// Normalize trims and clamps nickname length (1–16 runes). +func Normalize(s string) string { + r := []rune(s) + for len(r) > 0 && (r[0] == ' ' || r[0] == '\t' || r[0] == '\n') { + r = r[1:] + } + for len(r) > 0 { + last := r[len(r)-1] + if last == ' ' || last == '\t' || last == '\n' { + r = r[:len(r)-1] + continue + } + break + } + if len(r) > 16 { + r = r[:16] + } + return string(r) +} diff --git a/apps/api/internal/nickname/generate_test.go b/apps/api/internal/nickname/generate_test.go new file mode 100644 index 0000000..9767b43 --- /dev/null +++ b/apps/api/internal/nickname/generate_test.go @@ -0,0 +1,55 @@ +package nickname + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestWordBankSize(t *testing.T) { + if len(imageryWords) != 100 { + t.Fatalf("imagery want 100 got %d", len(imageryWords)) + } + if len(sceneWords) != 100 { + t.Fatalf("scene want 100 got %d", len(sceneWords)) + } +} + +func TestRandomFormat(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 50; i++ { + n := Random() + if utf8.RuneCountInString(n) < 2 { + t.Fatalf("too short: %q", n) + } + seen[n] = true + ok := false + for _, im := range imageryWords { + for _, sc := range sceneWords { + if n == im+sc { + ok = true + break + } + } + if ok { + break + } + } + if !ok { + t.Fatalf("not from bank: %q", n) + } + } + if len(seen) < 5 { + t.Fatalf("expected some variety, got %d unique", len(seen)) + } +} + +func TestNormalize(t *testing.T) { + if got := Normalize(" 心语岛 "); got != "心语岛" { + t.Fatalf("got %q", got) + } + long := strings.Repeat("啊", 20) + if utf8.RuneCountInString(Normalize(long)) != 16 { + t.Fatalf("clamp failed") + } +} diff --git a/apps/api/internal/repository/auth_repo.go b/apps/api/internal/repository/auth_repo.go index 07fdef0..0f55dc5 100644 --- a/apps/api/internal/repository/auth_repo.go +++ b/apps/api/internal/repository/auth_repo.go @@ -97,6 +97,14 @@ func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nicknam return id, err } +// UpdateNickname sets users.nickname (non-empty). +func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nickname string) error { + _, err := r.Pool.Exec(ctx, ` + UPDATE users SET nickname=$2, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, userID, nickname) + return err +} + // TouchPassword updates stored password hash (open-login record). func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error { _, err := r.Pool.Exec(ctx, ` diff --git a/apps/api/internal/repository/profile_repo.go b/apps/api/internal/repository/profile_repo.go index 642eac0..955858e 100644 --- a/apps/api/internal/repository/profile_repo.go +++ b/apps/api/internal/repository/profile_repo.go @@ -34,6 +34,15 @@ func scanProfile(scan func(dest ...any) error) (*model.Profile, error) { return p, nil } +// CountActiveSelf returns non-deleted self profiles for the user. +func (r *ProfileRepo) CountActiveSelf(ctx context.Context, userID uuid.UUID) (int, error) { + var n int + err := r.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM profiles + WHERE user_id=$1 AND relation='self' AND deleted_at IS NULL`, userID).Scan(&n) + return n, err +} + // Create inserts a profile. func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) { row := r.Pool.QueryRow(ctx, ` diff --git a/apps/api/internal/service/auth/service.go b/apps/api/internal/service/auth/service.go index 0c21c5f..e2c9f3f 100644 --- a/apps/api/internal/service/auth/service.go +++ b/apps/api/internal/service/auth/service.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" + nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) @@ -46,12 +47,12 @@ func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone, } // OpenLogin: any phone+password accepted; persist account; issue session. -func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) { +func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickIn string) (*SessionResult, error) { phone = strings.TrimSpace(phone) if phone == "" { return nil, errors.New("请填写手机号") } - nickname = strings.TrimSpace(nickname) + nickIn = nickgen.Normalize(nickIn) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err @@ -65,8 +66,12 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK _ = s.Repo.BindDevice(ctx, deviceKey, acc.ID) } nick := acc.Nickname - if nickname != "" { - nick = nickname + if nickIn != "" { + nick = nickIn + _ = s.Repo.UpdateNickname(ctx, acc.ID, nick) + } else if nick == "" { + nick = nickgen.Random() + _ = s.Repo.UpdateNickname(ctx, acc.ID, nick) } return s.issue(ctx, acc.ID, acc.Phone, nick) } @@ -74,15 +79,19 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK return nil, err } - // New phone: prefer upgrading anonymous device user. + nick := nickIn + if nick == "" { + nick = nickgen.Random() + } + cur, curErr := s.Repo.GetAccount(ctx, deviceUserID) uid := deviceUserID if curErr == nil && cur.Phone == "" { - if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil { + if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nick); err != nil { return nil, errors.New("登录失败,请重试") } } else { - uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname) + uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nick) if err != nil { return nil, errors.New("登录失败,请重试") } @@ -90,7 +99,26 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK if deviceKey != "" { _ = s.Repo.BindDevice(ctx, deviceKey, uid) } - return s.issue(ctx, uid, phone, nickname) + return s.issue(ctx, uid, phone, nick) +} + +// UpdateNickname changes account display nickname (1–16 chars). +func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) { + nick = nickgen.Normalize(nick) + if nick == "" { + return nil, errors.New("请填写昵称") + } + if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil { + return nil, err + } + acc, err := s.Repo.GetAccount(ctx, userID) + if err != nil { + return nil, err + } + if acc.Phone == "" { + return nil, errors.New("未登录") + } + return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil } // Logout revokes the current bearer session and unbinds the device from the account diff --git a/apps/api/internal/service/home/daily_tips.go b/apps/api/internal/service/home/daily_tips.go new file mode 100644 index 0000000..970e3cb --- /dev/null +++ b/apps/api/internal/service/home/daily_tips.go @@ -0,0 +1,217 @@ +package home + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/yijing" +) + +// ColorChip is one palette swatch. +type ColorChip struct { + Name string `json:"name"` + Hex string `json:"hex"` +} + +// DailyTips is homepage self-card lifestyle tips. +type DailyTips struct { + ClothingIndex int `json:"clothing_index"` + Clothing string `json:"clothing"` + ColorNote string `json:"color_note"` + Palette []ColorChip `json:"palette"` + Wellness string `json:"wellness"` + Source string `json:"source"` // llm | fallback + GuaName string `json:"gua_name,omitempty"` + DayPart string `json:"day_part,omitempty"` + NeedBirth bool `json:"need_birth"` +} + +type tipsCacheEntry struct { + tips DailyTips + exp time.Time +} + +var tipsCache sync.Map // key string → tipsCacheEntry + +// DailyTips returns personalized tips; falls back when LLM / profile unavailable. +func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) { + now := time.Now() + self, ok := s.findSelf(ctx, userID) + if !ok { + fb := fallbackTips(now, "", nil) + fb.NeedBirth = true + return fb, nil + } + + birth := self.BirthDate.Format("2006-01-02") + yc := yijing.Seed(birth, self.BirthTime, now) + cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3) + if v, hit := tipsCache.Load(cacheKey); hit { + e := v.(tipsCacheEntry) + if time.Now().Before(e.exp) { + out := e.tips + return &out, nil + } + } + + if s.LLM == nil || !s.LLM.Enabled() { + fb := fallbackTips(now, birth, self.BirthTime) + fb.GuaName = yc.GuaName + fb.DayPart = yc.DayPart + return fb, nil + } + + tips, err := s.generateLLMTips(ctx, yc) + if err != nil { + log.Printf("home daily-tips llm: %v", err) + fb := fallbackTips(now, birth, self.BirthTime) + fb.GuaName = yc.GuaName + fb.DayPart = yc.DayPart + return fb, nil + } + tips.Source = "llm" + tips.GuaName = yc.GuaName + tips.DayPart = yc.DayPart + tips.NeedBirth = false + tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)}) + return tips, nil +} + +func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) { + if s.Profiles == nil || userID == uuid.Nil { + return nil, false + } + items, err := s.Profiles.ListByUser(ctx, userID) + if err != nil { + return nil, false + } + for i := range items { + if items[i].Relation == "self" { + return &items[i], true + } + } + return nil, false +} + +func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) { + sys := `你是愈心谷的生活节律助手。根据用户出生信息与当前时刻的易经卦象种子,给出温和的生活建议。 +硬性要求: +- 禁止占卜/算命/改命恐吓/吉凶祸福话术 +- 禁止医疗疗效承诺 +- 用探索向、身心节律语气;可参考卦象意象,不要说「必有」类断言 +- 只输出 JSON,不要 markdown: +{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"} +- clothing_index 为 55–95 整数;palette 恰好 2 项;文案各不超过 40 字` + + user := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。" + raw, err := s.LLM.Chat(ctx, []deepseek.Message{ + {Role: "system", Content: sys}, + {Role: "user", Content: user}, + }) + if err != nil { + return nil, err + } + return parseTipsJSON(raw) +} + +func parseTipsJSON(raw string) (*DailyTips, error) { + raw = strings.TrimSpace(raw) + if i := strings.Index(raw, "{"); i >= 0 { + raw = raw[i:] + } + if j := strings.LastIndex(raw, "}"); j >= 0 { + raw = raw[:j+1] + } + var t DailyTips + if err := json.Unmarshal([]byte(raw), &t); err != nil { + return nil, err + } + if t.ClothingIndex < 40 || t.ClothingIndex > 100 { + t.ClothingIndex = 72 + } + t.Clothing = clampRunes(t.Clothing, 48) + t.ColorNote = clampRunes(t.ColorNote, 40) + t.Wellness = clampRunes(t.Wellness, 48) + if len(t.Palette) != 2 { + return nil, fmt.Errorf("palette len") + } + for i := range t.Palette { + t.Palette[i].Name = clampRunes(t.Palette[i].Name, 8) + if !looksHex(t.Palette[i].Hex) { + t.Palette[i].Hex = "#E8DCC8" + } + } + return &t, nil +} + +func clampRunes(s string, max int) string { + s = strings.TrimSpace(s) + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:max]) +} + +func looksHex(h string) bool { + if len(h) != 7 || h[0] != '#' { + return false + } + for i := 1; i < 7; i++ { + c := h[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips { + yc := yijing.Seed(birth, birthTime, now) + if birth == "" { + yc = yijing.Seed(now.Format("2006-01-02"), nil, now) + } + palettes := [][]ColorChip{ + {{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}}, + {{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}}, + {{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}}, + {{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}}, + {{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}}, + } + clothing := []string{ + "轻薄透气更舒服,外套可备一件薄衫应付温差。", + "选柔软面料贴身,活动一整天也不易紧绷。", + "上下同色系更省心,再加一件小配饰点睛即可。", + "宽松剪裁更自在,适合慢节奏出门与散步。", + "内里干净简约,外搭一件有质感的外套就够。", + } + notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"} + well := []string{ + "午后泡一杯温茶,给身心一点缓冲。", + "今晚早点放下屏幕,让眼睛歇一会儿。", + "走路时把肩膀放松,呼吸会顺很多。", + "饭后慢走十分钟,比猛坐着更舒服。", + "喝水提醒自己小口多次,别等口渴再灌。", + } + i := (yc.GuaIndex + yc.Line + now.Hour()) % 5 + return &DailyTips{ + ClothingIndex: 62 + (yc.GuaIndex % 31), + Clothing: clothing[i], + ColorNote: notes[i], + Palette: palettes[i], + Wellness: well[i], + Source: "fallback", + GuaName: yc.GuaName, + DayPart: yc.DayPart, + } +} diff --git a/apps/api/internal/service/home/service.go b/apps/api/internal/service/home/service.go index 39a2b25..8f1fca4 100644 --- a/apps/api/internal/service/home/service.go +++ b/apps/api/internal/service/home/service.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" + "github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) @@ -25,9 +26,11 @@ var allowedIcons = map[string]struct{}{ "companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {}, } -// Service serves homepage tool catalog. +// Service serves homepage tool catalog and daily tips. type Service struct { - Repo *repository.HomeToolsRepo + Repo *repository.HomeToolsRepo + Profiles *repository.ProfileRepo + LLM *deepseek.Client } // ListPublic returns enabled tools. diff --git a/apps/api/internal/service/profile/service.go b/apps/api/internal/service/profile/service.go index a5d24c4..bd563ef 100644 --- a/apps/api/internal/service/profile/service.go +++ b/apps/api/internal/service/profile/service.go @@ -30,6 +30,9 @@ type CreateInput struct { BirthPlace *string } +// ErrSelfExists is returned when creating a second self profile. +var ErrSelfExists = errors.New("self profile already exists") + // Create stores a profile for the user. func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) { if in.Relation != "self" && in.Relation != "other" { @@ -38,6 +41,15 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) if in.BirthDate.IsZero() { return nil, errors.New("birth_date required") } + if in.Relation == "self" { + n, err := s.Repo.CountActiveSelf(ctx, userID) + if err != nil { + return nil, err + } + if n > 0 { + return nil, ErrSelfExists + } + } name := in.DisplayName if name == "" { if in.Relation == "self" { diff --git a/apps/api/internal/service/report/service.go b/apps/api/internal/service/report/service.go index eefb145..07edf1d 100644 --- a/apps/api/internal/service/report/service.go +++ b/apps/api/internal/service/report/service.go @@ -163,6 +163,9 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) ( // CreateSynastry builds a multi-chart synastry report for two profiles. // asOf is the secondary-progression date (defaults to today CST when nil/zero). func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) { + if profileAID == profileBID { + return nil, errors.New("合盘需要两个不同档案") + } pa, err := s.Profiles.GetForUser(ctx, userID, profileAID) if err != nil { return nil, errors.New("profile a not found") @@ -174,6 +177,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID { return nil, errors.New("profile b not found") } + } else if pb.Relation != "other" { + return nil, errors.New("合盘对象须为 TA 档案或附近的人") } ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace) if err != nil { diff --git a/apps/api/internal/yijing/seed.go b/apps/api/internal/yijing/seed.go new file mode 100644 index 0000000..6673385 --- /dev/null +++ b/apps/api/internal/yijing/seed.go @@ -0,0 +1,93 @@ +// Package yijing provides a deterministic hexagram seed from birth + local time. +// It is a cultural rhythm seed for lifestyle tips — not fortune-telling. +package yijing + +import ( + "fmt" + "time" +) + +// Context is the seed passed to LLM prompts. +type Context struct { + GuaIndex int // 1..64 + GuaName string // short label + Line int // 1..6 changing line hint + DayPart string // morning|noon|afternoon|evening|night + NowLocal string + BirthDate string + BirthTime string +} + +var guaNames = []string{ + "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", + "小畜", "履", "泰", "否", "同人", "大有", "谦", "豫", + "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复", + "无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", + "遁", "大壮", "晋", "明夷", "家人", "睽", "蹇", "解", + "损", "益", "夬", "姤", "萃", "升", "困", "井", + "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", + "巽", "兑", "涣", "节", "中孚", "小过", "既济", "未济", +} + +// Seed builds hexagram context from birth date/time and now (Asia/Shanghai preferred). +func Seed(birthDate string, birthTime *string, now time.Time) Context { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + loc = time.FixedZone("CST", 8*3600) + } + now = now.In(loc) + + bt := "12:00" + if birthTime != nil && *birthTime != "" { + bt = *birthTime + } + + sum := hash(birthDate) + hash(bt) + hash(now.Format("2006-01-02")) + now.Hour()*17 + now.Minute()/10 + gua := (sum % 64) + 1 + line := (sum/7)%6 + 1 + name := guaNames[gua-1] + + return Context{ + GuaIndex: gua, + GuaName: name, + Line: line, + DayPart: dayPart(now.Hour()), + NowLocal: now.Format("2006-01-02 15:04"), + BirthDate: birthDate, + BirthTime: bt, + } +} + +// PromptLine is a compact Chinese summary for the model. +func (c Context) PromptLine() string { + return fmt.Sprintf( + "出生 %s %s;此刻 %s(时段 %s);卦象种子:%s(第%d卦)· 动爻%d", + c.BirthDate, c.BirthTime, c.NowLocal, c.DayPart, c.GuaName, c.GuaIndex, c.Line, + ) +} + +func dayPart(h int) string { + switch { + case h < 6: + return "night" + case h < 11: + return "morning" + case h < 14: + return "noon" + case h < 18: + return "afternoon" + default: + return "evening" + } +} + +func hash(s string) int { + n := 0 + for i := 0; i < len(s); i++ { + n = n*131 + int(s[i]) + } + if n < 0 { + n = -n + } + return n +} diff --git a/apps/api/internal/yijing/seed_test.go b/apps/api/internal/yijing/seed_test.go new file mode 100644 index 0000000..4a4ffcf --- /dev/null +++ b/apps/api/internal/yijing/seed_test.go @@ -0,0 +1,29 @@ +package yijing + +import ( + "testing" + "time" +) + +func TestSeedStableSameInputs(t *testing.T) { + bt := "10:30" + now := time.Date(2026, 8, 11, 19, 0, 0, 0, time.FixedZone("CST", 8*3600)) + a := Seed("1990-05-12", &bt, now) + b := Seed("1990-05-12", &bt, now) + if a.GuaIndex != b.GuaIndex || a.GuaName != b.GuaName || a.Line != b.Line { + t.Fatalf("expected stable seed, got %+v vs %+v", a, b) + } + if a.GuaIndex < 1 || a.GuaIndex > 64 { + t.Fatalf("gua out of range: %d", a.GuaIndex) + } +} + +func TestSeedChangesWithHour(t *testing.T) { + now1 := time.Date(2026, 8, 11, 9, 0, 0, 0, time.FixedZone("CST", 8*3600)) + now2 := time.Date(2026, 8, 11, 18, 0, 0, 0, time.FixedZone("CST", 8*3600)) + a := Seed("1990-05-12", nil, now1) + b := Seed("1990-05-12", nil, now2) + if a.DayPart == b.DayPart { + t.Fatalf("expected different day parts, both %s", a.DayPart) + } +} diff --git a/apps/api/migrations/000017_unique_self_profile.down.sql b/apps/api/migrations/000017_unique_self_profile.down.sql new file mode 100644 index 0000000..b504ebc --- /dev/null +++ b/apps/api/migrations/000017_unique_self_profile.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_profiles_one_self_per_user; diff --git a/apps/api/migrations/000017_unique_self_profile.up.sql b/apps/api/migrations/000017_unique_self_profile.up.sql new file mode 100644 index 0000000..f31932f --- /dev/null +++ b/apps/api/migrations/000017_unique_self_profile.up.sql @@ -0,0 +1,16 @@ +-- One active self profile per user; soft-delete older duplicates first. + +WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC, id DESC) AS rn + FROM profiles + WHERE relation = 'self' AND deleted_at IS NULL +) +UPDATE profiles p +SET deleted_at = now(), updated_at = now() +FROM ranked r +WHERE p.id = r.id AND r.rn > 1 AND p.deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_profiles_one_self_per_user + ON profiles (user_id) + WHERE relation = 'self' AND deleted_at IS NULL; diff --git a/apps/user-h5/src/components/home/HomeSelfCard.vue b/apps/user-h5/src/components/home/HomeSelfCard.vue index 4d70545..45a6aa8 100644 --- a/apps/user-h5/src/components/home/HomeSelfCard.vue +++ b/apps/user-h5/src/components/home/HomeSelfCard.vue @@ -1,53 +1,118 @@ diff --git a/apps/user-h5/src/components/home/HomeToolGrid.vue b/apps/user-h5/src/components/home/HomeToolGrid.vue index f0396b8..b742662 100644 --- a/apps/user-h5/src/components/home/HomeToolGrid.vue +++ b/apps/user-h5/src/components/home/HomeToolGrid.vue @@ -1,52 +1,36 @@ diff --git a/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue b/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue index 354e7e2..313fd7d 100644 --- a/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue +++ b/apps/user-h5/src/components/synastry/SynastryClassicSelects.vue @@ -3,14 +3,14 @@