feat(ECR-011): 昵称、首页贴士与档案 Self 唯一/合盘交叉校验
每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,6 +83,8 @@ Device 绑定到账号 User
|
|||||||
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
|
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
|
||||||
| R5 | 登出作废 session;设备可再登录其他账号 |
|
| R5 | 登出作废 session;设备可再登录其他账号 |
|
||||||
| R6 | 正式环境恢复校验前须改回本 Spec |
|
| R6 | 正式环境恢复校验前须改回本 Spec |
|
||||||
|
| R7 | 新账号未填昵称时,服务端用「意象词+场景词」词库(100×100)随机生成,如心语岛、微光谷 |
|
||||||
|
| R8 | 账号昵称可在个人档案页修改(PATCH `/auth/me`);首页展示昵称而非「自己」 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,10 +99,11 @@ Device 绑定到账号 User
|
|||||||
|
|
||||||
| Method | Path | 说明 |
|
| Method | Path | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/api/v1/auth/register` | 注册 |
|
| POST | `/api/v1/auth/register` | 注册(可带 nickname) |
|
||||||
| POST | `/api/v1/auth/login` | 登录 |
|
| POST | `/api/v1/auth/login` | 登录 |
|
||||||
| POST | `/api/v1/auth/logout` | 登出 |
|
| POST | `/api/v1/auth/logout` | 登出 |
|
||||||
| GET | `/api/v1/auth/me` | 当前账号 |
|
| GET | `/api/v1/auth/me` | 当前账号 |
|
||||||
|
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@
|
|||||||
```text
|
```text
|
||||||
/
|
/
|
||||||
├── Hero / 品牌
|
├── Hero / 品牌
|
||||||
├── 建档 CTA
|
├── 「自己」卡片标题展示账号昵称(默认意象词+场景词;档案页可改)
|
||||||
├── 功能入口
|
├── 穿衣指数 · 颜色搭配 · 养生推荐
|
||||||
|
├── 功能入口(4 列固定网格 · 多行换行 · 禁止横向滑动)
|
||||||
└── (可选)推荐内容
|
└── (可选)推荐内容
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -90,27 +91,34 @@ Empty:首页本身是入口,不因无档案空白死页。
|
|||||||
| R2 | 不展示内部运维信息(API 连接状态等) |
|
| R2 | 不展示内部运维信息(API 连接状态等) |
|
||||||
| R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) |
|
| R3 | 入口文案符合 lexicon(个人画像/关系理解/成长会员…) |
|
||||||
| R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 |
|
| R4 | Tab 五名冻结:首页·探索·问答·陪伴·我的 |
|
||||||
|
| R5 | 「自己」卡片:穿衣指数 / 颜色搭配 / 养生推荐;有 Self 档案生日时按生日+本地时刻易经卦象种子调大模型生成;无档案或 LLM 不可用则本地回退 |
|
||||||
|
| R6 | 文案禁止占卜/算命恐吓与医疗疗效承诺;卦象仅作节律意象 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 数据模型影响
|
## 8. 数据模型影响
|
||||||
|
|
||||||
经首页建档时写 `profiles`(见 profile Spec)。
|
经首页建档时写 `profiles`(见 profile Spec)。每日贴士可缓存(同用户约 45 分钟 / 按时段刷新)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. API 需求
|
## 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. 权限设计
|
## 10. 权限设计
|
||||||
|
|
||||||
| 能力 | Visitor |
|
| 能力 | Visitor | User |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| 浏览首页 | ✓ |
|
| 浏览首页 | ✓ | ✓ |
|
||||||
| 建档并生成画像 | ✓ |
|
| 建档并生成画像 | 需登录 | ✓ |
|
||||||
|
| daily-tips(个性化) | 回退文案 | 有 Self 生日则 LLM |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,10 +142,22 @@ Empty:首页本身是入口,不因无档案空白死页。
|
|||||||
**When** 提交主 CTA
|
**When** 提交主 CTA
|
||||||
**Then** 进入画像基础结果路径
|
**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 开发前检查
|
## 13. AI 开发前检查
|
||||||
|
|
||||||
|
- [x] lexicon
|
||||||
|
- [x] 不恐吓 / 不算命话术
|
||||||
|
- [x] OpenAPI 已登记 `/home/daily-tips`
|
||||||
|
|
||||||
- [x] Spec 齐全
|
- [x] Spec 齐全
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -92,6 +92,8 @@
|
|||||||
| R4 | 删除策略:任务未要求硬删 PII 时按实现(软删优先);级联影响报告见服务规则 |
|
| R4 | 删除策略:任务未要求硬删 PII 时按实现(软删优先);级联影响报告见服务规则 |
|
||||||
| R5 | Ask / 画像 / 关系必须挂合法 profile_id |
|
| R5 | Ask / 画像 / 关系必须挂合法 profile_id |
|
||||||
| R6 | 文案用「个人档案」,不用命盘 |
|
| 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? |
|
| `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
|
**When** 添加 TA
|
||||||
**Then** relation=other 且可用于关系理解
|
**Then** relation=other 且可用于关系理解
|
||||||
|
|
||||||
|
**Given** 已有 Self
|
||||||
|
**When** 再次 POST relation=self
|
||||||
|
**Then** 409 conflict;列表仍仅一条 Self
|
||||||
|
|
||||||
**Given** 他人 id
|
**Given** 他人 id
|
||||||
**When** PATCH
|
**When** PATCH
|
||||||
**Then** 失败
|
**Then** 失败
|
||||||
|
|
||||||
|
**Given** 同账号多条历史 self(脏数据)
|
||||||
|
**When** 迁移/启动
|
||||||
|
**Then** 仅保留最新一条,其余软删;合盘 TA 侧不再出现多个「我」
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. AI 开发前检查
|
## 13. AI 开发前检查
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
| R8 | 合盘与人格匹配分流:`/synastry` 盘型;`/relation` 行为风格 |
|
| R8 | 合盘与人格匹配分流:`/synastry` 盘型;`/relation` 行为风格 |
|
||||||
| R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 |
|
| R9 | 附近的人默认 `geo_visible=false`;仅可见已开启位置的 self 档案 |
|
||||||
| R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign |
|
| R10 | 合盘星历走 Swiss Ephemeris(默认 Moshier);宫位制 Whole Sign |
|
||||||
|
| R11 | 合盘交叉校验:档案 A 为本账号 Self;档案 B 须为本账号 `other` 或附近可见他人 Self;拒绝 self×self / 同 id |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
|||||||
g.POST("/login", h.Login)
|
g.POST("/login", h.Login)
|
||||||
g.POST("/logout", h.Logout)
|
g.POST("/logout", h.Logout)
|
||||||
g.GET("/me", h.Me)
|
g.GET("/me", h.Me)
|
||||||
|
g.PATCH("/me", h.PatchMe)
|
||||||
}
|
}
|
||||||
|
|
||||||
type authBody struct {
|
type authBody struct {
|
||||||
@@ -97,6 +98,30 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
|||||||
response.OK(c, me)
|
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 {
|
func bearerToken(c *gin.Context) string {
|
||||||
h := c.GetHeader("Authorization")
|
h := c.GetHeader("Authorization")
|
||||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/repository"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
@@ -19,6 +20,7 @@ type HomeHandler struct {
|
|||||||
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
func (h *HomeHandler) Register(api *gin.RouterGroup) {
|
||||||
g := api.Group("/home")
|
g := api.Group("/home")
|
||||||
g.GET("/tools", h.Tools)
|
g.GET("/tools", h.Tools)
|
||||||
|
g.GET("/daily-tips", h.DailyTips)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HomeHandler) Tools(c *gin.Context) {
|
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})
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ func (h *ProfileHandler) Create(c *gin.Context) {
|
|||||||
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
|
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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())
|
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
|||||||
Reports: reportRepo,
|
Reports: reportRepo,
|
||||||
Quotas: &repository.ImageCardRepo{Pool: pool},
|
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}
|
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
|
||||||
adminSvc := &adminsvc.Service{
|
adminSvc := &adminsvc.Service{
|
||||||
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
|
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,14 @@ func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nicknam
|
|||||||
return id, err
|
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).
|
// TouchPassword updates stored password hash (open-login record).
|
||||||
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
|
||||||
_, err := r.Pool.Exec(ctx, `
|
_, err := r.Pool.Exec(ctx, `
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ func scanProfile(scan func(dest ...any) error) (*model.Profile, error) {
|
|||||||
return p, nil
|
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.
|
// 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) {
|
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, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
"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.
|
// 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)
|
phone = strings.TrimSpace(phone)
|
||||||
if phone == "" {
|
if phone == "" {
|
||||||
return nil, errors.New("请填写手机号")
|
return nil, errors.New("请填写手机号")
|
||||||
}
|
}
|
||||||
nickname = strings.TrimSpace(nickname)
|
nickIn = nickgen.Normalize(nickIn)
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
|
||||||
}
|
}
|
||||||
nick := acc.Nickname
|
nick := acc.Nickname
|
||||||
if nickname != "" {
|
if nickIn != "" {
|
||||||
nick = nickname
|
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)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// New phone: prefer upgrading anonymous device user.
|
nick := nickIn
|
||||||
|
if nick == "" {
|
||||||
|
nick = nickgen.Random()
|
||||||
|
}
|
||||||
|
|
||||||
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
|
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
|
||||||
uid := deviceUserID
|
uid := deviceUserID
|
||||||
if curErr == nil && cur.Phone == "" {
|
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("登录失败,请重试")
|
return nil, errors.New("登录失败,请重试")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
|
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("登录失败,请重试")
|
return nil, errors.New("登录失败,请重试")
|
||||||
}
|
}
|
||||||
@@ -90,7 +99,26 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
|||||||
if deviceKey != "" {
|
if deviceKey != "" {
|
||||||
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
|
_ = 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
|
// Logout revokes the current bearer session and unbinds the device from the account
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,9 +26,11 @@ var allowedIcons = map[string]struct{}{
|
|||||||
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
|
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service serves homepage tool catalog.
|
// Service serves homepage tool catalog and daily tips.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Repo *repository.HomeToolsRepo
|
Repo *repository.HomeToolsRepo
|
||||||
|
Profiles *repository.ProfileRepo
|
||||||
|
LLM *deepseek.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPublic returns enabled tools.
|
// ListPublic returns enabled tools.
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type CreateInput struct {
|
|||||||
BirthPlace *string
|
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.
|
// Create stores a profile for the user.
|
||||||
func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) {
|
func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) {
|
||||||
if in.Relation != "self" && in.Relation != "other" {
|
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() {
|
if in.BirthDate.IsZero() {
|
||||||
return nil, errors.New("birth_date required")
|
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
|
name := in.DisplayName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
if in.Relation == "self" {
|
if in.Relation == "self" {
|
||||||
|
|||||||
@@ -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.
|
// CreateSynastry builds a multi-chart synastry report for two profiles.
|
||||||
// asOf is the secondary-progression date (defaults to today CST when nil/zero).
|
// 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) {
|
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)
|
pa, err := s.Profiles.GetForUser(ctx, userID, profileAID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("profile a not found")
|
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 {
|
if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID {
|
||||||
return nil, errors.New("profile b not found")
|
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)
|
ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_profiles_one_self_per_user;
|
||||||
@@ -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;
|
||||||
@@ -1,53 +1,118 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card" @click="$emit('open-portrait')">
|
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card">
|
||||||
<div class="self-head">
|
<div class="self-head">
|
||||||
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
|
<button type="button" class="who-btn" @click="$emit('open-profile')">
|
||||||
{{ profileLabel }}
|
{{ profileLabel }}
|
||||||
<span class="caret" aria-hidden="true">▾</span>
|
<span class="caret" aria-hidden="true">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<router-link class="more" to="/profile" @click.stop>更多</router-link>
|
<router-link class="more" to="/profile">更多</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="self-body">
|
<div class="self-body">
|
||||||
<div class="mood-col">
|
<p v-if="loading" class="tips-loading">正在根据生日与此刻节律整理…</p>
|
||||||
<div class="mood-label">
|
<template v-else>
|
||||||
今日心情 <em>{{ mood.score }}</em><span>分</span>
|
<div class="duo">
|
||||||
</div>
|
<!-- 穿衣指数 -->
|
||||||
<p class="mood-text">{{ mood.text }}</p>
|
<div class="wear-panel">
|
||||||
</div>
|
<div class="wear-title">
|
||||||
<div class="dims" role="list">
|
<span class="ico wear" aria-hidden="true">
|
||||||
<div
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
v-for="d in mood.dims"
|
<path
|
||||||
:key="d.key"
|
d="M8 5.5 12 4l4 1.5 3 2.5v2l-2.2-.6-.8 10.6H8L7.2 9.4 5 10V7.5L8 5.5Z"
|
||||||
class="dim"
|
stroke="currentColor"
|
||||||
role="listitem"
|
stroke-width="1.6"
|
||||||
:style="{ '--bar': d.color, '--h': d.score + '%' }"
|
stroke-linejoin="round"
|
||||||
>
|
/>
|
||||||
<b class="dim-score">{{ d.score }}</b>
|
</svg>
|
||||||
<div class="dim-track">
|
</span>
|
||||||
<i class="dim-fill" />
|
穿衣指数
|
||||||
|
</div>
|
||||||
|
<div class="wear-ring" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 72 72" class="ring-svg">
|
||||||
|
<circle class="ring-track" cx="36" cy="36" r="30" />
|
||||||
|
<circle
|
||||||
|
class="ring-val"
|
||||||
|
cx="36"
|
||||||
|
cy="36"
|
||||||
|
r="30"
|
||||||
|
:style="{ strokeDashoffset: ringOffset }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="ring-num">{{ tips.clothingIndex }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="wear-text">{{ tips.clothing }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 颜色搭配 -->
|
||||||
|
<div class="block color-block">
|
||||||
|
<div class="block-head">
|
||||||
|
<span class="ico palette" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="8" cy="10" r="3.2" fill="currentColor" opacity=".35" />
|
||||||
|
<circle cx="14.5" cy="8" r="3.2" fill="currentColor" opacity=".55" />
|
||||||
|
<circle cx="12" cy="14.5" r="3.2" fill="currentColor" opacity=".8" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="block-title">颜色搭配</span>
|
||||||
|
</div>
|
||||||
|
<div class="swatches" role="list">
|
||||||
|
<div v-for="c in tips.palette" :key="c.name" class="swatch" role="listitem">
|
||||||
|
<span class="chip" :style="{ background: c.hex }" :title="c.name" />
|
||||||
|
<span class="chip-name">{{ c.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="block-note">{{ tips.colorNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="dim-label">{{ d.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- 养生推荐 -->
|
||||||
|
<div class="block wellness-block">
|
||||||
|
<div class="block-head">
|
||||||
|
<span class="ico leaf" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M19 5c-5.5.4-9.8 3-12 7.2-1.4 2.7-1.6 5.5-1.2 7.8 2.4.2 5.2-.4 7.6-2.2C17.5 14.5 19.6 9.8 19 5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.2 18.5c2.2-3.6 5.8-6.2 10.3-7.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="block-title">养生推荐</span>
|
||||||
|
</div>
|
||||||
|
<p class="block-note wellness">{{ tips.wellness }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="tips.needBirth" class="birth-hint">完善生日档案后,会按你的生日与此刻节律个性化</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { computed } from 'vue'
|
||||||
|
import type { HomeDailyTips } from '../../composables/useHomeDailyTips'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
profileLabel: string
|
profileLabel: string
|
||||||
mood: {
|
tips: HomeDailyTips
|
||||||
score: number
|
loading?: boolean
|
||||||
text: string
|
|
||||||
dims: { key: string; label: string; score: number; color: string }[]
|
|
||||||
}
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
'open-portrait': []
|
|
||||||
'open-profile': []
|
'open-profile': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const ringOffset = computed(() => {
|
||||||
|
const c = 2 * Math.PI * 30
|
||||||
|
const pct = Math.min(100, Math.max(0, props.tips.clothingIndex)) / 100
|
||||||
|
return c * (1 - pct)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -59,10 +124,6 @@ defineEmits<{
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
box-shadow: var(--shadow-hero);
|
box-shadow: var(--shadow-hero);
|
||||||
transition: transform var(--duration-fast) ease;
|
|
||||||
}
|
|
||||||
.self-card:active {
|
|
||||||
transform: scale(0.99);
|
|
||||||
}
|
}
|
||||||
.self-head {
|
.self-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -95,103 +156,197 @@ defineEmits<{
|
|||||||
}
|
}
|
||||||
.self-body {
|
.self-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tips-loading {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.birth-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.mood-col {
|
|
||||||
flex: 1;
|
.wear-panel {
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
align-items: center;
|
||||||
padding-right: 4px;
|
gap: 8px;
|
||||||
|
padding: 12px 10px 12px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(160deg, #fff8f4 0%, #ffe8e0 55%, #fff5f0 100%);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.mood-label {
|
.wear-title {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
line-height: 1.2;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
.mood-label em {
|
.wear-ring {
|
||||||
font-style: normal;
|
position: relative;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ring-svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.ring-track {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(229, 77, 66, 0.12);
|
||||||
|
stroke-width: 6;
|
||||||
|
}
|
||||||
|
.ring-val {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--color-primary);
|
||||||
|
stroke-width: 6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 188.5;
|
||||||
|
transition: stroke-dashoffset 0.8s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.ring-num {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 34px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
margin: 0 1px 0 4px;
|
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
vertical-align: -4px;
|
|
||||||
}
|
}
|
||||||
.mood-label span {
|
.wear-text {
|
||||||
font-size: 12px;
|
margin: 0;
|
||||||
color: var(--color-text-tertiary);
|
font-size: 11px;
|
||||||
font-weight: 500;
|
color: #7a6f6a;
|
||||||
}
|
line-height: 1.45;
|
||||||
.mood-text {
|
text-align: center;
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #777;
|
|
||||||
line-height: 1.55;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.dims {
|
|
||||||
display: flex;
|
.ico {
|
||||||
gap: 7px;
|
width: 18px;
|
||||||
align-items: flex-end;
|
height: 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
}
|
||||||
.dim {
|
.ico svg {
|
||||||
width: 28px;
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
.ico.wear {
|
||||||
|
background: rgba(229, 77, 66, 0.12);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.ico.palette {
|
||||||
|
background: rgba(74, 144, 226, 0.12);
|
||||||
|
color: #4a90e2;
|
||||||
|
}
|
||||||
|
.ico.leaf {
|
||||||
|
background: rgba(47, 168, 102, 0.12);
|
||||||
|
color: #2fa866;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #faf8f6;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.block-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.block-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6e6662;
|
||||||
|
line-height: 1.45;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.block-note.wellness {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-block {
|
||||||
|
background: linear-gradient(160deg, #f7f9fc 0%, #eef4fa 100%);
|
||||||
|
}
|
||||||
|
.wellness-block {
|
||||||
|
background: linear-gradient(160deg, #f5faf6 0%, #eaf5ee 100%);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.swatch {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.dim-score {
|
.chip {
|
||||||
font-size: 11px;
|
width: 36px;
|
||||||
font-weight: 700;
|
height: 36px;
|
||||||
color: var(--color-text-primary);
|
border-radius: 11px;
|
||||||
line-height: 1;
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.06),
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
.dim-track {
|
.chip-name {
|
||||||
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;
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal {
|
.reveal {
|
||||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
animation-delay: var(--d, 0ms);
|
animation-delay: var(--d, 0ms);
|
||||||
@@ -208,8 +363,9 @@ defineEmits<{
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reveal,
|
.reveal,
|
||||||
.dim-fill {
|
.ring-val {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,52 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section grid-sec">
|
<section class="section grid-sec" aria-label="功能入口">
|
||||||
<div class="tool-scroll" aria-label="功能入口">
|
<div class="tool-grid">
|
||||||
<div class="tool-rows">
|
<router-link
|
||||||
<div class="tool-row">
|
v-for="t in tools"
|
||||||
<router-link
|
:key="t.to + t.label"
|
||||||
v-for="t in row1"
|
:to="t.to"
|
||||||
:key="t.to + t.label"
|
class="tool-item"
|
||||||
:to="t.to"
|
@click="$emit('track', t.label)"
|
||||||
class="tool-item"
|
>
|
||||||
@click="$emit('track', t.label)"
|
<div class="icon" aria-hidden="true">
|
||||||
>
|
<HomeToolIcon :name="t.icon" :size="48" />
|
||||||
<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 class="tool-row">
|
<div class="label">{{ t.label }}</div>
|
||||||
<router-link
|
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||||
v-for="t in row2"
|
</router-link>
|
||||||
: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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import HomeToolIcon from '../HomeToolIcon.vue'
|
import HomeToolIcon from '../HomeToolIcon.vue'
|
||||||
import type { HomeTool } from '../../lib/homeCatalog'
|
import type { HomeTool } from '../../lib/homeCatalog'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
row1: HomeTool[]
|
row1: HomeTool[]
|
||||||
row2: HomeTool[]
|
row2: HomeTool[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{ track: [label: string] }>()
|
defineEmits<{ track: [label: string] }>()
|
||||||
|
|
||||||
|
const tools = computed(() => [...props.row1, ...props.row2])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -55,46 +39,31 @@ defineEmits<{ track: [label: string] }>()
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
.grid-sec {
|
.grid-sec {
|
||||||
padding-right: 0;
|
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
.tool-scroll {
|
.tool-grid {
|
||||||
overflow-x: auto;
|
display: grid;
|
||||||
-webkit-overflow-scrolling: touch;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
scrollbar-width: none;
|
row-gap: 16px;
|
||||||
padding-right: var(--spacing-md);
|
column-gap: 0;
|
||||||
}
|
|
||||||
.tool-scroll::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.tool-rows {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
width: max-content;
|
|
||||||
padding: 4px 0 2px;
|
padding: 4px 0 2px;
|
||||||
}
|
}
|
||||||
.tool-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
width: 88px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
min-width: 0;
|
||||||
transition: transform var(--duration-fast) ease;
|
transition: transform var(--duration-fast) ease;
|
||||||
}
|
}
|
||||||
.tool-item:active {
|
.tool-item:active {
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
.tool-item .icon {
|
.tool-item .icon {
|
||||||
width: 75px;
|
width: 68px;
|
||||||
height: 75px;
|
height: 68px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -109,11 +78,16 @@ defineEmits<{ track: [label: string] }>()
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
top: -2px;
|
||||||
right: 6px;
|
right: max(0px, calc(50% - 38px));
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div class="nick-card">
|
||||||
|
<div class="nick-head">
|
||||||
|
<div>
|
||||||
|
<p class="lbl">账号昵称</p>
|
||||||
|
<p class="val">{{ nickname || '未设置' }}</p>
|
||||||
|
<p class="hint">首页「自己」处展示;默认如心语岛、微光谷</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="!editing" type="button" class="op-btn" @click="startEdit">修改</button>
|
||||||
|
</div>
|
||||||
|
<form v-if="editing" class="nick-form" @submit.prevent="save">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
class="inp"
|
||||||
|
maxlength="16"
|
||||||
|
placeholder="例如:心语岛"
|
||||||
|
autocomplete="nickname"
|
||||||
|
/>
|
||||||
|
<p v-if="err" class="err">{{ err }}</p>
|
||||||
|
<div class="ops">
|
||||||
|
<button type="button" class="ghost" :disabled="busy" @click="cancel">取消</button>
|
||||||
|
<button type="submit" class="cta" :disabled="busy || !draft.trim()">
|
||||||
|
{{ busy ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
nickname: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: [nickname: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const draft = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const err = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.nickname,
|
||||||
|
(v) => {
|
||||||
|
if (!editing.value) draft.value = v || ''
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
draft.value = props.nickname || ''
|
||||||
|
err.value = ''
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
editing.value = false
|
||||||
|
err.value = ''
|
||||||
|
draft.value = props.nickname || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const next = draft.value.trim()
|
||||||
|
if (!next) {
|
||||||
|
err.value = '请填写昵称'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
err.value = ''
|
||||||
|
try {
|
||||||
|
const me = await api.authUpdateMe({ nickname: next })
|
||||||
|
emit('updated', me.nickname)
|
||||||
|
editing.value = false
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nick-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: #faf8f6;
|
||||||
|
}
|
||||||
|
.nick-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.lbl {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #999;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.val {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.op-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid #f0ebe8;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.nick-form {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.inp {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid #efe8e4;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.ops {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.ghost,
|
||||||
|
.cta {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.cta {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.cta:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
<label class="yxg-label">我(档案 A)</label>
|
<label class="yxg-label">我(档案 A)</label>
|
||||||
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
|
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
|
||||||
<option disabled value="">请选择</option>
|
<option disabled value="">请选择</option>
|
||||||
<option v-for="p in profiles" :key="p.id" :value="p.id">
|
<option v-for="p in selfProfiles" :key="p.id" :value="p.id">
|
||||||
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
{{ p.display_name || '我' }} · {{ birthLabel(p.birth_date) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="yxg-label">TA(档案 B)</label>
|
<label class="yxg-label">TA(档案 B)</label>
|
||||||
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
|
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
|
||||||
<option disabled value="">请选择</option>
|
<option disabled value="">请选择</option>
|
||||||
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
|
<option v-for="p in otherProfiles" :key="p.id" :value="p.id">
|
||||||
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
|
||||||
</option>
|
</option>
|
||||||
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
|
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
|
||||||
@@ -28,7 +28,8 @@ import type { Profile } from '@yuxingu/types'
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
profileA: string
|
profileA: string
|
||||||
profileB: string
|
profileB: string
|
||||||
profiles: Profile[]
|
selfProfiles: Profile[]
|
||||||
|
otherProfiles: Profile[]
|
||||||
nearby: { profile: Profile; distance_km: number }[]
|
nearby: { profile: Profile; distance_km: number }[]
|
||||||
asOf: string
|
asOf: string
|
||||||
birthLabel: (d?: string) => string
|
birthLabel: (d?: string) => string
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
v-if="showClassicSelects"
|
v-if="showClassicSelects"
|
||||||
:profile-a="profileA"
|
:profile-a="profileA"
|
||||||
:profile-b="profileB"
|
:profile-b="profileB"
|
||||||
:profiles="profiles"
|
:self-profiles="selfProfiles"
|
||||||
|
:other-profiles="pickableProfiles"
|
||||||
:nearby="nearby"
|
:nearby="nearby"
|
||||||
:as-of="asOf"
|
:as-of="asOf"
|
||||||
:birth-label="birthLabel"
|
:birth-label="birthLabel"
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
:nearby-loading="nearbyLoading"
|
:nearby-loading="nearbyLoading"
|
||||||
:invite-path="invitePath"
|
:invite-path="invitePath"
|
||||||
:nearby-hint="nearbyHint"
|
:nearby-hint="nearbyHint"
|
||||||
:profiles-length="profiles.length"
|
:profiles-length="pickableProfiles.length + selfProfiles.length"
|
||||||
:nearby-length="nearby.length"
|
:nearby-length="nearby.length"
|
||||||
@create-invite="$emit('create-invite')"
|
@create-invite="$emit('create-invite')"
|
||||||
@load-nearby="$emit('load-nearby')"
|
@load-nearby="$emit('load-nearby')"
|
||||||
@@ -102,7 +103,7 @@ defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
profileA: string
|
profileA: string
|
||||||
showClassicSelects: boolean
|
showClassicSelects: boolean
|
||||||
profiles: Profile[]
|
selfProfiles: Profile[]
|
||||||
asOf: string
|
asOf: string
|
||||||
inviteHighlight: boolean
|
inviteHighlight: boolean
|
||||||
inviting: boolean
|
inviting: boolean
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { GrowthReport, Profile } from '@yuxingu/types'
|
|||||||
import { validateBirth } from '@yuxingu/utils'
|
import { validateBirth } from '@yuxingu/utils'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
|
import { ensureOtherProfile } from '../lib/authSession'
|
||||||
import type { SynastryMainTab } from '../lib/synastryChart'
|
import type { SynastryMainTab } from '../lib/synastryChart'
|
||||||
|
|
||||||
type NearbyItem = { profile: Profile; distance_km: number }
|
type NearbyItem = { profile: Profile; distance_km: number }
|
||||||
@@ -37,7 +38,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
|
|||||||
r.profiles.value = res.items || []
|
r.profiles.value = res.items || []
|
||||||
const self = r.profiles.value.find((p) => p.relation === 'self')
|
const self = r.profiles.value.find((p) => p.relation === 'self')
|
||||||
if (self && !r.profileA.value) r.profileA.value = self.id
|
if (self && !r.profileA.value) r.profileA.value = self.id
|
||||||
const other = r.profiles.value.find((p) => p.id !== r.profileA.value)
|
const other = r.profiles.value.find((p) => p.relation === 'other')
|
||||||
if (other && !r.profileB.value) r.profileB.value = other.id
|
if (other && !r.profileB.value) r.profileB.value = other.id
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
r.error.value = e instanceof Error ? e.message : '加载档案失败'
|
r.error.value = e instanceof Error ? e.message : '加载档案失败'
|
||||||
@@ -57,8 +58,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
|
|||||||
r.error.value = ''
|
r.error.value = ''
|
||||||
try {
|
try {
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const p = await api.createProfile({
|
const p = await ensureOtherProfile({
|
||||||
relation: 'other',
|
|
||||||
birth_date: birth,
|
birth_date: birth,
|
||||||
display_name: r.tName.value || 'TA',
|
display_name: r.tName.value || 'TA',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { ensureAccount } from '../lib/authSession'
|
import { ensureAccount, pickableArchiveList } from '../lib/authSession'
|
||||||
|
|
||||||
export const askScenes = [
|
export const askScenes = [
|
||||||
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
|
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
|
||||||
@@ -51,7 +51,7 @@ export function useAskPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
|
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
|
||||||
profiles.value = list.items || []
|
profiles.value = pickableArchiveList(list.items || [])
|
||||||
quota.value = q
|
quota.value = q
|
||||||
const self = profiles.value.find((p) => p.relation === 'self')
|
const self = profiles.value.find((p) => p.relation === 'self')
|
||||||
profileId.value = self?.id || profiles.value[0]?.id || ''
|
profileId.value = self?.id || profiles.value[0]?.id || ''
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import type { HomeDailyTips as ApiTips } from '@yuxingu/types'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
export type ColorChip = { name: string; hex: string }
|
||||||
|
|
||||||
|
export type HomeDailyTips = {
|
||||||
|
clothingIndex: number
|
||||||
|
clothing: string
|
||||||
|
colorNote: string
|
||||||
|
palette: ColorChip[]
|
||||||
|
wellness: string
|
||||||
|
needBirth?: boolean
|
||||||
|
guaName?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function localFallback(): HomeDailyTips {
|
||||||
|
const n = new Date()
|
||||||
|
const seed = n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate() + n.getHours()
|
||||||
|
const palettes: ColorChip[][] = [
|
||||||
|
[
|
||||||
|
{ name: '米白', hex: '#F5F0E8' },
|
||||||
|
{ name: '雾霾蓝', hex: '#A8C4D8' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: '燕麦色', hex: '#E8DCC8' },
|
||||||
|
{ name: '浅杏', hex: '#F0C9A8' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: '浅灰', hex: '#D8D6D4' },
|
||||||
|
{ name: '雾粉', hex: '#E8B8C4' },
|
||||||
|
],
|
||||||
|
]
|
||||||
|
const i = seed % palettes.length
|
||||||
|
return {
|
||||||
|
clothingIndex: 62 + (seed % 31),
|
||||||
|
clothing: '轻薄透气更舒服,外套可备一件薄衫应付温差。',
|
||||||
|
colorNote: '清爽干净,不抢戏。',
|
||||||
|
palette: palettes[i],
|
||||||
|
wellness: '午后泡一杯温茶,给身心一点缓冲。',
|
||||||
|
source: 'fallback',
|
||||||
|
needBirth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromApi(t: ApiTips): HomeDailyTips {
|
||||||
|
return {
|
||||||
|
clothingIndex: t.clothing_index,
|
||||||
|
clothing: t.clothing,
|
||||||
|
colorNote: t.color_note,
|
||||||
|
palette: (t.palette || []).map((p) => ({ name: p.name, hex: p.hex })),
|
||||||
|
wellness: t.wellness,
|
||||||
|
needBirth: !!t.need_birth,
|
||||||
|
guaName: t.gua_name,
|
||||||
|
source: t.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load daily tips from API (LLM + 易经种子); local fallback on failure. */
|
||||||
|
export function useHomeDailyTips() {
|
||||||
|
const tips = ref<HomeDailyTips>(localFallback())
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tips.value = fromApi(await api.getHomeDailyTips())
|
||||||
|
} catch {
|
||||||
|
/* keep local fallback */
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { tips, loading }
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type HomeTool,
|
type HomeTool,
|
||||||
} from '../lib/homeCatalog'
|
} from '../lib/homeCatalog'
|
||||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||||
import { useHomeMood } from './useHomeMood'
|
import { useHomeDailyTips } from './useHomeDailyTips'
|
||||||
|
|
||||||
const ICONS = new Set([
|
const ICONS = new Set([
|
||||||
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
|
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
|
||||||
@@ -55,15 +55,23 @@ function mapTools(
|
|||||||
|
|
||||||
export function useHomePage() {
|
export function useHomePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const profileLabel = ref('自己')
|
const profileLabel = ref('访客')
|
||||||
const plusOpen = ref(false)
|
const plusOpen = ref(false)
|
||||||
const mood = useHomeMood()
|
const { tips, loading: tipsLoading } = useHomeDailyTips()
|
||||||
const grid = reactive({
|
const grid = reactive({
|
||||||
gridRow1: [...homeGridRow1] as HomeTool[],
|
gridRow1: [...homeGridRow1] as HomeTool[],
|
||||||
gridRow2: [...homeGridRow2] as HomeTool[],
|
gridRow2: [...homeGridRow2] as HomeTool[],
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void api
|
||||||
|
.authMe()
|
||||||
|
.then((me) => {
|
||||||
|
if (me.nickname) profileLabel.value = me.nickname
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
profileLabel.value = '访客'
|
||||||
|
})
|
||||||
void api
|
void api
|
||||||
.getHomeTools()
|
.getHomeTools()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -105,7 +113,8 @@ export function useHomePage() {
|
|||||||
router,
|
router,
|
||||||
profileLabel,
|
profileLabel,
|
||||||
plusOpen,
|
plusOpen,
|
||||||
mood,
|
tips,
|
||||||
|
tipsLoading,
|
||||||
searchHint,
|
searchHint,
|
||||||
...toRefs(grid),
|
...toRefs(grid),
|
||||||
feeds: homeFeeds,
|
feeds: homeFeeds,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { GrowthReport } from '@yuxingu/types'
|
|||||||
import { validateBirth } from '@yuxingu/utils'
|
import { validateBirth } from '@yuxingu/utils'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
import { ensureAccount } from '../lib/authSession'
|
import { ensureAccount, ensureSelfProfile } from '../lib/authSession'
|
||||||
|
|
||||||
export const promptRows = [
|
export const promptRows = [
|
||||||
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
|
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
|
||||||
@@ -101,7 +101,7 @@ export function useImageCardPage() {
|
|||||||
drawing.value = true
|
drawing.value = true
|
||||||
try {
|
try {
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
|
||||||
const out = await api.drawImageCard({
|
const out = await api.drawImageCard({
|
||||||
profile_id: profile.id,
|
profile_id: profile.id,
|
||||||
scene: scene.value,
|
scene: scene.value,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { validateBirth } from '@yuxingu/utils'
|
|||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { WuxingBar } from '../components/WuxingBars.vue'
|
import type { WuxingBar } from '../components/WuxingBars.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
|
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
|
||||||
|
|
||||||
export const resultTabs = [
|
export const resultTabs = [
|
||||||
{ key: 'overview' as const, label: '概览' },
|
{ key: 'overview' as const, label: '概览' },
|
||||||
@@ -69,7 +69,7 @@ export function useLifeRhythmPage() {
|
|||||||
needBirth.value = false
|
needBirth.value = false
|
||||||
try {
|
try {
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
|
||||||
report.value = await api.createRhythm(profile.id)
|
report.value = await api.createRhythm(profile.id)
|
||||||
tab.value = 'overview'
|
tab.value = 'overview'
|
||||||
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
|
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { GrowthReport } from '@yuxingu/types'
|
|||||||
import { validateBirth } from '@yuxingu/utils'
|
import { validateBirth } from '@yuxingu/utils'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
|
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
|
||||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||||
|
|
||||||
export function usePortraitPage() {
|
export function usePortraitPage() {
|
||||||
@@ -45,7 +45,7 @@ export function usePortraitPage() {
|
|||||||
needBirth.value = false
|
needBirth.value = false
|
||||||
try {
|
try {
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
|
||||||
report.value = await api.createPortrait(profile.id)
|
report.value = await api.createPortrait(profile.id)
|
||||||
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
|
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { Profile } from '@yuxingu/types'
|
import type { Profile } from '@yuxingu/types'
|
||||||
|
import { api } from '../api/client'
|
||||||
import { ensureAccount } from '../lib/authSession'
|
import { ensureAccount } from '../lib/authSession'
|
||||||
import {
|
import {
|
||||||
avatarInitial,
|
avatarInitial,
|
||||||
@@ -16,6 +17,7 @@ export function useProfilePage() {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const accountNickname = ref('')
|
||||||
const items = ref<Profile[]>([])
|
const items = ref<Profile[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -102,13 +104,25 @@ export function useProfilePage() {
|
|||||||
() => applyQueryFlags(),
|
() => applyQueryFlags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function onNicknameUpdated(n: string) {
|
||||||
|
accountNickname.value = n
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!(await ensureAccount(router, route.fullPath))) return
|
if (!(await ensureAccount(router, route.fullPath))) return
|
||||||
|
try {
|
||||||
|
const me = await api.authMe()
|
||||||
|
accountNickname.value = me.nickname || ''
|
||||||
|
} catch {
|
||||||
|
accountNickname.value = ''
|
||||||
|
}
|
||||||
await load()
|
await load()
|
||||||
applyQueryFlags()
|
applyQueryFlags()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
accountNickname,
|
||||||
|
onNicknameUpdated,
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
import type { GrowthReport } from '@yuxingu/types'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
import { ensureAccount } from '../lib/authSession'
|
import { ensureAccount, ensureOtherProfile, findSelfProfile } from '../lib/authSession'
|
||||||
import type { RelationSharePayload } from '../lib/shareLink'
|
import type { RelationSharePayload } from '../lib/shareLink'
|
||||||
|
|
||||||
export const focusTabs = [
|
export const focusTabs = [
|
||||||
@@ -93,13 +93,6 @@ export function useRelationPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function ensureSelf(): Promise<Profile> {
|
|
||||||
const { items } = await api.listProfiles()
|
|
||||||
const self = (items || []).find((p) => p.relation === 'self')
|
|
||||||
if (self) return self
|
|
||||||
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!(await ensureAccount(router, route.fullPath))) return
|
if (!(await ensureAccount(router, route.fullPath))) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -111,10 +104,10 @@ export function useRelationPage() {
|
|||||||
const m = Number(om.value)
|
const m = Number(om.value)
|
||||||
const d = Number(od.value)
|
const d = Number(od.value)
|
||||||
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
|
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
|
||||||
const self = await ensureSelf()
|
const self = await findSelfProfile()
|
||||||
|
if (!self) throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const other = await api.createProfile({
|
const other = await ensureOtherProfile({
|
||||||
relation: 'other',
|
|
||||||
birth_date: birth,
|
birth_date: birth,
|
||||||
display_name: otherName.value || 'TA',
|
display_name: otherName.value || 'TA',
|
||||||
relation_type: relationType.value,
|
relation_type: relationType.value,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { ensureAccount } from '../lib/authSession'
|
import { ensureAccount, findSelfProfile } from '../lib/authSession'
|
||||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
||||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||||
|
|
||||||
@@ -146,8 +146,7 @@ export function useScalePage() {
|
|||||||
for (const q of questions.value) {
|
for (const q of questions.value) {
|
||||||
if (!answers[q.id]) throw new Error('请完成全部题目')
|
if (!answers[q.id]) throw new Error('请完成全部题目')
|
||||||
}
|
}
|
||||||
const { items } = await api.listProfiles()
|
const self = await findSelfProfile()
|
||||||
const self = (items || []).find((p) => p.relation === 'self')
|
|
||||||
if (!self) {
|
if (!self) {
|
||||||
needProfile.value = true
|
needProfile.value = true
|
||||||
throw new Error('请先在首页完成性格探索,创建个人档案')
|
throw new Error('请先在首页完成性格探索,创建个人档案')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { validateBirth } from '@yuxingu/utils'
|
|||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
|
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
|
||||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||||
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
|
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
|
||||||
import {
|
import {
|
||||||
ascLonFromSummary,
|
ascLonFromSummary,
|
||||||
fortuneBundleFrom,
|
fortuneBundleFrom,
|
||||||
@@ -111,8 +111,7 @@ export function useStarProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||||
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
|
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
|
||||||
const profile = await api.createProfile({
|
const profile = await ensureSelfProfile({
|
||||||
relation: 'self',
|
|
||||||
birth_date: birth,
|
birth_date: birth,
|
||||||
display_name: '我',
|
display_name: '我',
|
||||||
birth_time: bt,
|
birth_time: bt,
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export function useSynastryPage() {
|
|||||||
const showQuickAdd = ref(false)
|
const showQuickAdd = ref(false)
|
||||||
|
|
||||||
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
||||||
const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value))
|
const pickableProfiles = computed(() =>
|
||||||
|
profiles.value.filter((p) => p.relation === 'other' && p.id !== profileA.value),
|
||||||
|
)
|
||||||
|
const selfProfiles = computed(() => profiles.value.filter((p) => p.relation === 'self'))
|
||||||
const profileAName = computed(() => {
|
const profileAName = computed(() => {
|
||||||
const p = profiles.value.find((x) => x.id === profileA.value)
|
const p = profiles.value.find((x) => x.id === profileA.value)
|
||||||
return p?.display_name || ''
|
return p?.display_name || ''
|
||||||
@@ -178,6 +181,7 @@ export function useSynastryPage() {
|
|||||||
showQuickAdd,
|
showQuickAdd,
|
||||||
needsSubTab,
|
needsSubTab,
|
||||||
pickableProfiles,
|
pickableProfiles,
|
||||||
|
selfProfiles,
|
||||||
profileAName,
|
profileAName,
|
||||||
selfInitial,
|
selfInitial,
|
||||||
profileInitial,
|
profileInitial,
|
||||||
|
|||||||
@@ -32,6 +32,82 @@ export async function findSelfProfile(): Promise<Profile | null> {
|
|||||||
return (items ?? []).find((p) => p.relation === 'self') || null
|
return (items ?? []).find((p) => p.relation === 'self') || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create self once, or update birth fields; never spawn a second self. */
|
||||||
|
export async function ensureSelfProfile(input: {
|
||||||
|
birth_date: string
|
||||||
|
display_name?: string
|
||||||
|
birth_time?: string
|
||||||
|
birth_place?: string
|
||||||
|
}): Promise<Profile> {
|
||||||
|
const existing = await findSelfProfile()
|
||||||
|
if (existing) {
|
||||||
|
return api.updateProfile(existing.id, {
|
||||||
|
// Keep user-edited archive name; only set name on create.
|
||||||
|
display_name: existing.display_name || input.display_name || '我',
|
||||||
|
birth_date: input.birth_date,
|
||||||
|
birth_time: input.birth_time,
|
||||||
|
birth_place: input.birth_place,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await api.createProfile({
|
||||||
|
relation: 'self',
|
||||||
|
birth_date: input.birth_date,
|
||||||
|
display_name: input.display_name || '我',
|
||||||
|
birth_time: input.birth_time,
|
||||||
|
birth_place: input.birth_place,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// Concurrent create: another tab/request won the unique-self race.
|
||||||
|
const msg = e instanceof Error ? e.message : ''
|
||||||
|
if (!/already exists|已有|409/.test(msg) && !msg.includes('self profile')) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
const again = await findSelfProfile()
|
||||||
|
if (!again) throw e
|
||||||
|
return api.updateProfile(again.id, {
|
||||||
|
display_name: again.display_name || input.display_name || '我',
|
||||||
|
birth_date: input.birth_date,
|
||||||
|
birth_time: input.birth_time,
|
||||||
|
birth_place: input.birth_place,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create other once for same name+birthday, else reuse (avoids TA spam from 匹配/合盘). */
|
||||||
|
export async function ensureOtherProfile(input: {
|
||||||
|
birth_date: string
|
||||||
|
display_name?: string
|
||||||
|
relation_type?: string
|
||||||
|
birth_place?: string
|
||||||
|
}): Promise<Profile> {
|
||||||
|
const name = (input.display_name || 'TA').trim() || 'TA'
|
||||||
|
const birth = input.birth_date.slice(0, 10)
|
||||||
|
const { items } = await api.listProfiles()
|
||||||
|
const hit = (items ?? []).find(
|
||||||
|
(p) =>
|
||||||
|
p.relation === 'other' &&
|
||||||
|
String(p.birth_date).slice(0, 10) === birth &&
|
||||||
|
(p.display_name || 'TA') === name,
|
||||||
|
)
|
||||||
|
if (hit) return hit
|
||||||
|
return api.createProfile({
|
||||||
|
relation: 'other',
|
||||||
|
birth_date: birth,
|
||||||
|
display_name: name,
|
||||||
|
relation_type: input.relation_type,
|
||||||
|
birth_place: input.birth_place,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer one Self + Others for pickers (hide duplicate selves if any remain). */
|
||||||
|
export function pickableArchiveList(items: Profile[]): Profile[] {
|
||||||
|
const list = items ?? []
|
||||||
|
const self = list.find((p) => p.relation === 'self')
|
||||||
|
const others = list.filter((p) => p.relation === 'other')
|
||||||
|
return self ? [self, ...others] : others
|
||||||
|
}
|
||||||
|
|
||||||
/** Load cached solo report for self profile; null report means need archive. */
|
/** Load cached solo report for self profile; null report means need archive. */
|
||||||
export async function loadSelfLatest(
|
export async function loadSelfLatest(
|
||||||
type: 'portrait' | 'star' | 'rhythm',
|
type: 'portrait' | 'star' | 'rhythm',
|
||||||
|
|||||||
@@ -91,4 +91,4 @@ export const homeFeeds: HomeFeed[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const homeSearchHints = ['探索今日心情', '愈心解码', '合盘了解彼此', 'AI 成长助手']
|
export const homeSearchHints = ['今日穿衣指数', '愈心解码', '合盘了解彼此', 'AI 成长助手']
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
<HomeArchiveStrip @add="goPlus('add')" />
|
<HomeArchiveStrip @add="goPlus('add')" />
|
||||||
<HomeSelfCard
|
<HomeSelfCard
|
||||||
:profile-label="profileLabel"
|
:profile-label="profileLabel"
|
||||||
:mood="mood"
|
:tips="tips"
|
||||||
@open-portrait="router.push('/portrait')"
|
:loading="tipsLoading"
|
||||||
@open-profile="router.push('/profile')"
|
@open-profile="router.push('/profile')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -40,7 +40,8 @@ const {
|
|||||||
router,
|
router,
|
||||||
profileLabel,
|
profileLabel,
|
||||||
plusOpen,
|
plusOpen,
|
||||||
mood,
|
tips,
|
||||||
|
tipsLoading,
|
||||||
searchHint,
|
searchHint,
|
||||||
gridRow1,
|
gridRow1,
|
||||||
gridRow2,
|
gridRow2,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ const { api } = vi.hoisted(() => ({
|
|||||||
authMe: vi.fn(),
|
authMe: vi.fn(),
|
||||||
listImageCardScenes: vi.fn(),
|
listImageCardScenes: vi.fn(),
|
||||||
getImageCardQuota: vi.fn(),
|
getImageCardQuota: vi.fn(),
|
||||||
|
listProfiles: vi.fn(),
|
||||||
createProfile: vi.fn(),
|
createProfile: vi.fn(),
|
||||||
|
updateProfile: vi.fn(),
|
||||||
drawImageCard: vi.fn(),
|
drawImageCard: vi.fn(),
|
||||||
createOrder: vi.fn(),
|
createOrder: vi.fn(),
|
||||||
payMock: vi.fn(),
|
payMock: vi.fn(),
|
||||||
@@ -35,6 +37,7 @@ describe('ImageCardPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('boots scenes and draws a card', async () => {
|
it('boots scenes and draws a card', async () => {
|
||||||
|
api.listProfiles.mockResolvedValue({ items: [] })
|
||||||
api.createProfile.mockResolvedValue({ id: 'p1' })
|
api.createProfile.mockResolvedValue({ id: 'p1' })
|
||||||
api.drawImageCard.mockResolvedValue({
|
api.drawImageCard.mockResolvedValue({
|
||||||
scene: '情绪整理',
|
scene: '情绪整理',
|
||||||
|
|||||||
@@ -64,8 +64,8 @@
|
|||||||
v-model="nickname"
|
v-model="nickname"
|
||||||
class="inp"
|
class="inp"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="32"
|
maxlength="16"
|
||||||
placeholder="怎么称呼你"
|
placeholder="不填则自动生成,如心语岛"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ProfilePage from './ProfilePage.vue'
|
|||||||
const { api } = vi.hoisted(() => ({
|
const { api } = vi.hoisted(() => ({
|
||||||
api: {
|
api: {
|
||||||
authMe: vi.fn(),
|
authMe: vi.fn(),
|
||||||
|
authUpdateMe: vi.fn(),
|
||||||
listProfiles: vi.fn(),
|
listProfiles: vi.fn(),
|
||||||
updateProfile: vi.fn(),
|
updateProfile: vi.fn(),
|
||||||
deleteProfile: vi.fn(),
|
deleteProfile: vi.fn(),
|
||||||
@@ -29,13 +30,15 @@ const RouterLinkStub = defineComponent({
|
|||||||
describe('ProfilePage', () => {
|
describe('ProfilePage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
api.authMe.mockResolvedValue({ id: 'u1' })
|
api.authMe.mockResolvedValue({ id: 'u1', nickname: '心语岛', phone: '138****0000' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty state', async () => {
|
it('shows empty state', async () => {
|
||||||
api.listProfiles.mockResolvedValue({ items: [] })
|
api.listProfiles.mockResolvedValue({ items: [] })
|
||||||
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(w.text()).toContain('账号昵称')
|
||||||
|
expect(w.text()).toContain('心语岛')
|
||||||
expect(w.text()).toContain('暂无档案')
|
expect(w.text()).toContain('暂无档案')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<AccountNicknameCard
|
||||||
|
:nickname="accountNickname"
|
||||||
|
@updated="onNicknameUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
<ProfileEmpty v-if="!items.length" />
|
<ProfileEmpty v-if="!items.length" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import BackButton from '../components/BackButton.vue'
|
import BackButton from '../components/BackButton.vue'
|
||||||
import PageShell from '../components/PageShell.vue'
|
import PageShell from '../components/PageShell.vue'
|
||||||
|
import AccountNicknameCard from '../components/profile/AccountNicknameCard.vue'
|
||||||
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
|
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
|
||||||
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
|
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
|
||||||
import ProfileEditForm from '../components/profile/ProfileEditForm.vue'
|
import ProfileEditForm from '../components/profile/ProfileEditForm.vue'
|
||||||
@@ -95,6 +101,8 @@ import ProfileSelfCard from '../components/profile/ProfileSelfCard.vue'
|
|||||||
import { useProfilePage } from '../composables/useProfilePage'
|
import { useProfilePage } from '../composables/useProfilePage'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
accountNickname,
|
||||||
|
onNicknameUpdated,
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -110,4 +110,20 @@ describe('SynastryPage', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(w.text()).toContain('组合次限')
|
expect(w.text()).toContain('组合次限')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hides duplicate self from TA pick list', async () => {
|
||||||
|
api.listProfiles.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },
|
||||||
|
{ id: 'a2', relation: 'self', display_name: '我', birth_date: '1991-01-01' },
|
||||||
|
{ id: 'b1', relation: 'other', display_name: '伴侣', birth_date: '1992-08-20' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const w = mount(SynastryPage)
|
||||||
|
await flushPromises()
|
||||||
|
const chips = w.findAll('.chip-name').map((n) => n.text())
|
||||||
|
expect(chips.filter((t) => t === '我')).toHaveLength(0)
|
||||||
|
expect(chips).toContain('伴侣')
|
||||||
|
expect(chips).toContain('添加档案')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
:nearby="nearby"
|
:nearby="nearby"
|
||||||
:adding="adding"
|
:adding="adding"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:profiles="profiles"
|
:self-profiles="selfProfiles"
|
||||||
:invite-highlight="inviteHighlight"
|
:invite-highlight="inviteHighlight"
|
||||||
:inviting="inviting"
|
:inviting="inviting"
|
||||||
:nearby-loading="nearbyLoading"
|
:nearby-loading="nearbyLoading"
|
||||||
@@ -107,7 +107,6 @@ const {
|
|||||||
nearbyHint,
|
nearbyHint,
|
||||||
invitePath,
|
invitePath,
|
||||||
inviteHighlight,
|
inviteHighlight,
|
||||||
profiles,
|
|
||||||
nearby,
|
nearby,
|
||||||
profileA,
|
profileA,
|
||||||
profileB,
|
profileB,
|
||||||
@@ -126,6 +125,7 @@ const {
|
|||||||
showQuickAdd,
|
showQuickAdd,
|
||||||
needsSubTab,
|
needsSubTab,
|
||||||
pickableProfiles,
|
pickableProfiles,
|
||||||
|
selfProfiles,
|
||||||
profileAName,
|
profileAName,
|
||||||
selfInitial,
|
selfInitial,
|
||||||
profileInitial,
|
profileInitial,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# BD-2026-011 — Identity · Home tips · Profile integrity
|
||||||
|
|
||||||
|
Status: Approved
|
||||||
|
**ECR:** ECR-011
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Approved:** 2026-08-12(随 ECR-011 Human Approve)
|
||||||
|
**Change Level:** L2
|
||||||
|
**Risk:** Medium
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- 问题:重复 self;合盘/档案展示不一致;缺昵称与首页贴士契约门禁
|
||||||
|
- 非目标:社交关系链、真推送、医疗建议
|
||||||
|
- Spec:profile · star-profile · account-auth · home
|
||||||
|
|
||||||
|
## Backend Change Boundary
|
||||||
|
|
||||||
|
### In
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| Migration | `000017`:软删 rn>1 的 self;`UNIQUE (user_id) WHERE relation='self' AND deleted_at IS NULL` |
|
||||||
|
| nickname | `internal/nickname`:100×100 词库 · Random · Normalize |
|
||||||
|
| auth | 注册/回填空昵称;`UpdateNickname`;`PATCH /auth/me` → 40902 语义外的校验错误用既有码 |
|
||||||
|
| profile | `CountActiveSelf`;Create self 已存在 → `ErrSelfExists` → HTTP 409 / code 40902 |
|
||||||
|
| report | `CreateSynastry`:同 id 拒绝;本账号 B 须 `other`;附近仍允许他人 geo-visible self |
|
||||||
|
| home | `GET /home/daily-tips`:生日+本地时 → yijing seed → DeepSeek JSON;失败回退 |
|
||||||
|
| OpenAPI | `/auth/me` PATCH · `/home/daily-tips` |
|
||||||
|
|
||||||
|
### Out
|
||||||
|
|
||||||
|
- Handler 直连 DB · 新外部依赖(除既有 DeepSeek)· 硬删 profiles
|
||||||
|
|
||||||
|
### Ownership
|
||||||
|
|
||||||
|
| Layer | Owner |
|
||||||
|
|-------|--------|
|
||||||
|
| Repository | AuthRepo · ProfileRepo |
|
||||||
|
| Service | auth · profile · home · report |
|
||||||
|
| Handler | AuthHandler · ProfileHandler · HomeHandler |
|
||||||
|
| UI | user-h5 Profile / Home / Synastry / Portrait·Star·Rhythm·Cards |
|
||||||
|
|
||||||
|
## Architecture Change
|
||||||
|
|
||||||
|
- 分层边界:No(无需新 ADR)
|
||||||
|
- 受影响层:API · Application · Infrastructure(migration) · H5
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Register/Login → nick empty? → nickname.Random → users.nickname
|
||||||
|
PATCH /auth/me → Normalize → UpdateNickname
|
||||||
|
|
||||||
|
POST /profiles self → CountActiveSelf>0? 409 : Insert
|
||||||
|
GET /profiles → List(H5 用 ensure* / pickable 过滤)
|
||||||
|
|
||||||
|
POST /reports/synastry → A owned · B other|nearby self
|
||||||
|
|
||||||
|
GET /home/daily-tips → Self birth? → seed → LLM|fallback → tips JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
## Failure / Rollback
|
||||||
|
|
||||||
|
- down:`DROP INDEX idx_profiles_one_self_per_user`(不自动恢复已软删行)
|
||||||
|
- LLM 失败:返回 `source=fallback`,HTTP 仍 200
|
||||||
|
- 40902:前端 ensureSelf 捕获后改走 update
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- go:`internal/nickname` · profile Create 冲突(integration 或 service)
|
||||||
|
- H5:Synastry 隐藏重复 self · ImageCard/Portrait 经 ensureSelf · Profile 昵称卡
|
||||||
|
- 手工:档案仅 1 个我;合盘 TA 无多个我;改昵称首页同步;贴士有/无生日
|
||||||
|
|
||||||
|
## Rollback plan
|
||||||
|
|
||||||
|
1. 回滚应用版本;保留 migration 索引(安全)或执行 down(仅删索引)
|
||||||
|
2. 关闭 DeepSeek 配置 → 贴士自动 fallback
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# CHANGELOG — ESS process artifacts
|
# CHANGELOG — ESS process artifacts
|
||||||
|
|
||||||
|
## 2026-08-12
|
||||||
|
|
||||||
|
- **ECR-011 Closed**:integration TestECR011 PASS;CODE_REVIEW Approve
|
||||||
|
- **ECR-011 Implemented**:昵称 · 首页 daily-tips · Self 唯一/合盘交叉校验
|
||||||
|
migration `000017` · PATCH `/auth/me` · GET `/home/daily-tips` · 40902
|
||||||
|
TEST_REPORT · HANDOFF→reviewer · STATE phase=review
|
||||||
|
- **ECR-011 Approved**(Human):进入 coding / 收敛 WIP
|
||||||
|
|
||||||
## 2026-08-07
|
## 2026-08-07
|
||||||
|
|
||||||
- **ECR-009 Implementing**:Ops-D 订单筛选 · 展示价 · 退款只读(LOOP-RUN-002)
|
- **ECR-009 Implementing**:Ops-D 订单筛选 · 展示价 · 退款只读(LOOP-RUN-002)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# CODE_REVIEW — ECR-011 Identity · Home tips · Profile integrity
|
||||||
|
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Reviewer:** Agent (ESS REVIEWER)
|
||||||
|
**Verdict:** **Approve**
|
||||||
|
|
||||||
|
## Backend Review
|
||||||
|
|
||||||
|
```text
|
||||||
|
Backend Review:
|
||||||
|
☑ Design Compliance
|
||||||
|
☑ Layer boundary respected
|
||||||
|
☑ Domain independent
|
||||||
|
☑ Repository used
|
||||||
|
☑ API contract
|
||||||
|
☑ Migration exists — 000017
|
||||||
|
☑ Observability
|
||||||
|
☑ Error handling — 40902 · synastry 校验
|
||||||
|
☑ Tests included — TestECR011 PASS(Postgres)
|
||||||
|
☑ Rollback considered
|
||||||
|
☑ No architecture regression
|
||||||
|
☑ Matches Change Boundary
|
||||||
|
☑ Trace — TEST_REPORT PASS · BD-2026-011
|
||||||
|
```
|
||||||
|
|
||||||
|
## Iterate since Request changes
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| P1 integration 断言 wear/colors | **Fixed** → clothing/palette/wellness |
|
||||||
|
| `TestECR011ProfileIntegrityNicknameTips` | **PASS** 2026-08-12 |
|
||||||
|
|
||||||
|
## Residual(接受)
|
||||||
|
|
||||||
|
- CreateSynastry 未强制 A=`self`(UI 约束)
|
||||||
|
- tipsCache 进程内
|
||||||
|
- 手工 UI 冒烟可后置
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve** → 可 Closed / 合入(勿带 test-results、密钥)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# ECR-011
|
||||||
|
|
||||||
|
**Title:** 账号昵称 · 首页每日贴士 · 档案唯一 Self / 合盘交叉校验
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Approved:** 2026-08-12(Human 确认 Approve)
|
||||||
|
**Change Level:** L2
|
||||||
|
|
||||||
|
## Architect 分级说明
|
||||||
|
|
||||||
|
| 工作流 | 级别 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| A 档案唯一 Self + 合盘交叉校验 | L2(含 L1 缺陷修复) | migration 唯一索引 · API 409 · 合盘 B 约束 · Spec R7/R8/R11 |
|
||||||
|
| B 账号昵称生成/编辑 | L2 | 新行为 · PATCH `/auth/me` · Spec account-auth R7–R8 |
|
||||||
|
| C 首页每日贴士(穿衣/颜色/养生) | L2 | 新 API `/home/daily-tips` · LLM · Spec home R5–R6 |
|
||||||
|
|
||||||
|
**禁止**将本 ECR 拆成「无 Spec 的顺手改」继续合入 main。
|
||||||
|
**过程补录:** 实现曾领先于 ECR;现已 Human Approve。Closed 前须 TEST_REPORT + CODE_REVIEW + DoD;不得宣称 P1 Complete 除非 DoD PASS。
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
1. Active Spec(已改,须随本 ECR 冻结):
|
||||||
|
- `.ai/product/feature-spec/profile.md`(R7–R8)
|
||||||
|
- `.ai/product/feature-spec/star-profile.md`(R11)
|
||||||
|
- `.ai/product/feature-spec/account-auth.md`(R7–R8)
|
||||||
|
- `.ai/product/feature-spec/home.md`(R5–R6)
|
||||||
|
2. migration `000017_unique_self_profile`:软删重复 self + 部分唯一索引
|
||||||
|
3. API:`PATCH /auth/me` · `GET /home/daily-tips` · Create self → 40902 · Synastry B 须 other/附近
|
||||||
|
4. H5:`ensureSelfProfile` / `ensureOtherProfile` · 合盘只挑 other · 档案昵称卡 · 自己卡贴士
|
||||||
|
5. OpenAPI · types · sdk · 单测
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
- 合盘出现多个「我」、档案页只显示一条 → 数据不一致
|
||||||
|
- 解码/星盘等重复 `createProfile(self)` 制造脏数据
|
||||||
|
- 账号需要可编辑昵称;首页「自己」卡需要节律贴士(非心情条)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 上列 Spec / migration / auth·home·profile·report 服务与对应 H5
|
||||||
|
- 昵称词库包 `internal/nickname` · 易经种子 `internal/yijing` · daily_tips
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 改 ECR-010 Ops 行为 · 真支付 · 真推送 · 拆微服务 · 无 ADR 换栈
|
||||||
|
- 提交 `config.local.yaml` / 密钥 / `test-results/`
|
||||||
|
- 扩大到紫微/达人/关系链拉取
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 软删「错误」self 导致报告悬挂 | 保留最新 self;报告仍挂 profile_id(软删档案下游已有规则) |
|
||||||
|
| LLM 延迟/失败 | daily-tips 本地回退;超时配置 |
|
||||||
|
| 昵称词库碰撞 | 随机即可;非唯一约束 |
|
||||||
|
| 过程补 ECR 与代码漂移 | HANDOFF 以 Spec+BD 为权威;Reviewer 对照 diff |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 每账号至多 1 条 active self;再建 → 40902(handler + OpenAPI;integration 待 Postgres)
|
||||||
|
- [x] 合盘 TA 列表无多余 self;同 id 合盘拒绝;B=本账号须 other(service)
|
||||||
|
- [x] 注册空昵称 → 词库生成;档案可改;首页展示昵称
|
||||||
|
- [x] 有 Self 生日 → daily-tips 可返回 llm/fallback;无恐吓/疗效承诺(Spec R6)
|
||||||
|
- [x] OpenAPI 同步;相关 vitest / go unit PASS
|
||||||
|
- [x] `ess-validate.py --phase coding --ecr ECR-011`(交 Reviewer 前跑)
|
||||||
|
|
||||||
|
## Trace
|
||||||
|
|
||||||
|
Spec profile · star-profile · account-auth · home · BD-2026-011 · TASK-011
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-011
|
||||||
|
|
||||||
|
**ECR:** ECR-011
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Approved:** 2026-08-12
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在补齐 ESS 门禁的前提下,交付三块 L2 行为:账号昵称、首页每日贴士、档案 Self 唯一与合盘交叉校验。
|
||||||
|
|
||||||
|
## Workstreams
|
||||||
|
|
||||||
|
### A — Profile integrity(优先合入)
|
||||||
|
|
||||||
|
| Item | Done when |
|
||||||
|
|------|-----------|
|
||||||
|
| migration 000017 | 无用户 COUNT(self)>1;索引存在 |
|
||||||
|
| Create self 409 | OpenAPI/handler 一致 |
|
||||||
|
| Synastry B 约束 | service 拒绝同账号 self |
|
||||||
|
| H5 ensureSelf/Other | 不再重复造 self/同名同生日 other |
|
||||||
|
| Spec profile R7–R8 · star R11 | Active |
|
||||||
|
|
||||||
|
### B — Nickname
|
||||||
|
|
||||||
|
| Item | Done when |
|
||||||
|
|------|-----------|
|
||||||
|
| 词库 Random | 单测词表规模与格式 |
|
||||||
|
| PATCH /auth/me | 档案页可改;首页展示 |
|
||||||
|
| Spec account-auth R7–R8 | Active |
|
||||||
|
|
||||||
|
### C — Home daily tips
|
||||||
|
|
||||||
|
| Item | Done when |
|
||||||
|
|------|-----------|
|
||||||
|
| GET /home/daily-tips | llm 或 fallback |
|
||||||
|
| HomeSelfCard | 穿衣/颜色/养生;无恐吓文案 |
|
||||||
|
| Spec home R5–R6 | Active |
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- ECR-010 Closed(另轨 Reviewer)
|
||||||
|
- 运营后台改动
|
||||||
|
|
||||||
|
## Implementation note(现状)
|
||||||
|
|
||||||
|
工作区 **已有 WIP 实现**。Engineer 任务:
|
||||||
|
|
||||||
|
1. Human Approve ECR-011 + BD-2026-011 后,以 Spec/BD 为权威对照 diff
|
||||||
|
2. 缺测补测;清理 `test-results/` 勿提交
|
||||||
|
3. 产出 TEST_REPORT + HANDOFF→reviewer
|
||||||
|
4. 不得再扩 Scope Forbidden 项
|
||||||
|
|
||||||
|
## DoD pointers
|
||||||
|
|
||||||
|
- `.ai/definition-of-done.md`
|
||||||
|
- `docs/PROJECT_RULES.md` L2 矩阵
|
||||||
|
- `python scripts/ess-validate.py --phase coding --ecr ECR-011`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# HANDOFF — ECR-011 Architect → Engineer
|
||||||
|
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**ECR:** ECR-011(**Approved** 2026-08-12)
|
||||||
|
**BD:** BD-2026-011 Approved
|
||||||
|
|
||||||
|
## Task interpretation
|
||||||
|
|
||||||
|
补门禁并收敛 WIP:昵称 · 首页贴士 · Self 唯一/合盘交叉校验。
|
||||||
|
Human 已 Approve;按下方顺序对照 BD 收敛 → 测试产物 → Reviewer。不得扩大 Scope Forbidden。
|
||||||
|
|
||||||
|
## Load
|
||||||
|
|
||||||
|
1. `docs/ECR/ECR-011-identity-home-profile.md`
|
||||||
|
2. `docs/BACKEND_DESIGN/BD-2026-011-identity-home-profile.md`
|
||||||
|
3. `docs/ENGINEERING_SPEC/ECR-011-identity-home-profile.md`
|
||||||
|
4. Spec:`profile` · `star-profile` · `account-auth` · `home`
|
||||||
|
5. `docs/PROJECT_RULES.md` · `.ai/definition-of-done.md`
|
||||||
|
|
||||||
|
## Ordered plan(Approve 后)
|
||||||
|
|
||||||
|
1. **冻结范围** — 仅 Workstream A/B/C;拒绝顺手改 Ops
|
||||||
|
2. **对照 BD** — migration / 40902 / synastry 校验 / PATCH me / daily-tips 与 OpenAPI 一致
|
||||||
|
3. **前端** — `authSession.ensure*`;合盘 `pickableProfiles` 仅 other;昵称卡;自己卡贴士
|
||||||
|
4. **测试** — nickname go test · Synastry/Profile/ImageCard vitest · 手工冒烟清单见下
|
||||||
|
5. **产物** — `docs/TEST_REPORT/ECR-011.md` · `docs/HANDOFF/ECR-011-engineer-to-reviewer.md` · 更新 STATE · TRACEABILITY
|
||||||
|
6. **Gate** — `ess-validate.py --phase coding --ecr ECR-011`
|
||||||
|
|
||||||
|
## Smoke checklist
|
||||||
|
|
||||||
|
- [ ] 档案页仅 1 个「我」;合盘右侧无多个「我」
|
||||||
|
- [ ] 再跑解码不新增 self、不覆盖已改档案名
|
||||||
|
- [ ] 匹配/合盘同名同生日 TA 不刷屏
|
||||||
|
- [ ] 改昵称 → 首页「自己」标题更新
|
||||||
|
- [ ] 有生日 daily-tips;关 key 仍 fallback
|
||||||
|
|
||||||
|
## Owner
|
||||||
|
|
||||||
|
Approve 后:`STATE.owner = engineer`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# HANDOFF — ECR-011 Engineer → Reviewer(re-submit)
|
||||||
|
|
||||||
|
**From:** Engineer
|
||||||
|
**To:** Reviewer
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**ECR:** ECR-011
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- P1:`ecr011_test.go` 断言改为 `clothing` · `clothing_index` · `palette` · `wellness` · `source`
|
||||||
|
- TEST_REPORT 已更新
|
||||||
|
|
||||||
|
## Still SKIP locally
|
||||||
|
|
||||||
|
Docker daemon 不可用 → integration 仍 SKIP。请 Reviewer 在有 Postgres 环境跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deps:up
|
||||||
|
cd apps/api && go test ./internal/integration/ -run TestECR011 -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Owner next
|
||||||
|
|
||||||
|
`STATE.owner = reviewer`
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-011
|
||||||
|
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**ECR:** ECR-011
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
实现与 Approved BD-2026-011 对齐(过程补录后收敛):账号昵称、首页每日贴士、每账号唯一 Self + 合盘交叉校验与 H5 ensure*。
|
||||||
|
|
||||||
|
## Key paths
|
||||||
|
|
||||||
|
| Area | Path |
|
||||||
|
|------|------|
|
||||||
|
| Migration | `apps/api/migrations/000017_unique_self_profile.*.sql` |
|
||||||
|
| Nickname | `apps/api/internal/nickname/` |
|
||||||
|
| Yijing seed | `apps/api/internal/yijing/` |
|
||||||
|
| Auth | `service/auth` · `handler/auth` PatchMe |
|
||||||
|
| Profile | `service/profile` ErrSelfExists · 40902 |
|
||||||
|
| Synastry | `service/report.CreateSynastry` |
|
||||||
|
| Daily tips | `service/home/daily_tips.go` |
|
||||||
|
| H5 session | `apps/user-h5/src/lib/authSession.ts` |
|
||||||
|
| UI | Profile `AccountNicknameCard` · HomeSelfCard · Synastry picks |
|
||||||
|
|
||||||
|
## Deviations from BD
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
## Follow-ups for Reviewer
|
||||||
|
|
||||||
|
Integration 需本机 Postgres;手工冒烟清单见 Architect→Engineer HANDOFF。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# HANDOFF — ECR-011 Reviewer → Engineer(iterate)
|
||||||
|
|
||||||
|
**From:** Reviewer
|
||||||
|
**To:** Engineer
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Verdict:** Request changes(见 `docs/CODE_REVIEW/ECR-011.md`)
|
||||||
|
|
||||||
|
## Required fix
|
||||||
|
|
||||||
|
1. 修正 `apps/api/internal/integration/ecr011_test.go`:断言 `clothing` / `palette` / `wellness`(勿用 `wear` / `colors`)
|
||||||
|
2. `npm run deps:up` 后 `go test ./internal/integration/ -run TestECR011 -count=1 -v` → PASS
|
||||||
|
3. 更新 `docs/TEST_REPORT/ECR-011.md`(integration 不再 SKIP/错断言)
|
||||||
|
4. 回交 Reviewer(STATE.owner=reviewer)
|
||||||
|
|
||||||
|
无 Scope 扩张。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-011
|
||||||
|
|
||||||
|
**ECR:** ECR-011
|
||||||
|
**Status:** Approved
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Approved:** 2026-08-12
|
||||||
|
|
||||||
|
产品权威仍以 `.ai/product/feature-spec/` 为准(双轨)。本文件仅做 ESS 索引。
|
||||||
|
|
||||||
|
| Workstream | Spec | 用户可见结果 |
|
||||||
|
|------------|------|----------------|
|
||||||
|
| A | `profile.md` R7–R8 · `star-profile.md` R11 | 每人只有一个「我」;合盘选 TA 不再出现多个自己 |
|
||||||
|
| B | `account-auth.md` R7–R8 | 注册得昵称;档案可改;首页显示昵称 |
|
||||||
|
| C | `home.md` R5–R6 | 自己卡:穿衣/颜色/养生;无恐吓/疗效承诺 |
|
||||||
|
|
||||||
|
## Out of product scope
|
||||||
|
|
||||||
|
占卜恐吓 · 医疗处方 · 竞品品牌词 · 运营后台本 ECR 不改
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-006 Closed;ECR-007/008 Implemented(待 Closed);ECR-009 Implemented · Human Approved;**ECR-010 Implemented(待 Reviewer Closed)**;ECR-001–005 Closed
|
- ECR: ECR-006 Closed;ECR-007/008/009 Implemented;**ECR-010 Implemented(待 Reviewer Closed)**;**ECR-011 Closed**;ECR-001–005 Closed
|
||||||
- EXP: (无)
|
- EXP: (无)
|
||||||
- STATE: `docs/STATE/ECR-010.md`(及 006–009)
|
- STATE: `docs/STATE/ECR-011.md` · `docs/STATE/ECR-010.md`(及 006–009)
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
||||||
- Active Spec: `.ai/product/feature-spec/ops-system.md`
|
- Active Spec: `ops-system` ·(ECR-011 已 Closed)`profile` · `star-profile` · `account-auth` · `home`
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# STATE — ECR-011
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| ECR | ECR-011 |
|
||||||
|
| Title | 账号昵称 · 首页每日贴士 · 档案唯一 Self / 合盘交叉校验 |
|
||||||
|
| Status | **Closed**(Reviewer Approve) |
|
||||||
|
| Phase | release / done |
|
||||||
|
| Owner | — |
|
||||||
|
| Spec | Active profile · star-profile · account-auth · home |
|
||||||
|
| Backend Design | BD-2026-011 Approved |
|
||||||
|
| Test | TEST_REPORT/ECR-011.md **PASS**(含 integration) |
|
||||||
|
| Review | CODE_REVIEW/ECR-011.md **Approve** |
|
||||||
|
| Updated | 2026-08-12 |
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
ecr: ECR-011
|
||||||
|
title: identity nickname home tips profile integrity
|
||||||
|
status: implemented
|
||||||
|
owner: reviewer
|
||||||
|
change_level: L2
|
||||||
|
artifacts:
|
||||||
|
- docs/ECR/ECR-011-identity-home-profile.md
|
||||||
|
- docs/BACKEND_DESIGN/BD-2026-011-identity-home-profile.md
|
||||||
|
- docs/ENGINEERING_SPEC/ECR-011-identity-home-profile.md
|
||||||
|
- docs/PRODUCT_SPEC/ECR-011-identity-home-profile.md
|
||||||
|
- docs/HANDOFF/ECR-011-architect-to-engineer.md
|
||||||
|
- docs/HANDOFF/ECR-011-engineer-to-reviewer.md
|
||||||
|
- docs/HANDOFF/ECR-011-implementation-report.md
|
||||||
|
- docs/TEST_REPORT/ECR-011.md
|
||||||
|
- .ai/product/feature-spec/profile.md
|
||||||
|
- .ai/product/feature-spec/star-profile.md
|
||||||
|
- .ai/product/feature-spec/account-auth.md
|
||||||
|
- .ai/product/feature-spec/home.md
|
||||||
|
notes: Implemented 2026-08-12; awaiting Reviewer; integration needs deps:up
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# TEST_REPORT — ECR-011 Identity · Home tips · Profile integrity
|
||||||
|
|
||||||
|
**Date:** 2026-08-12
|
||||||
|
**Result:** **PASS**
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up -d postgres
|
||||||
|
cd apps/api && go test ./internal/integration/ -run TestECR011ProfileIntegrityNicknameTips -count=1 -v
|
||||||
|
cd apps/api && go test ./internal/nickname/ ./internal/yijing/ ./internal/service/home/ -count=1
|
||||||
|
cd apps/user-h5 && npx vitest run \
|
||||||
|
src/pages/SynastryPage.spec.ts src/pages/ProfilePage.spec.ts \
|
||||||
|
src/pages/ImageCardPage.spec.ts src/pages/PortraitPage.spec.ts \
|
||||||
|
src/pages/RelationPage.spec.ts src/pages/AskPage.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
| Case | Result |
|
||||||
|
|------|--------|
|
||||||
|
| Second self → HTTP 409 / code 40902 | PASS |
|
||||||
|
| Synastry same id → 400 | PASS |
|
||||||
|
| Synastry self×other → 200 | PASS |
|
||||||
|
| PATCH /auth/me nickname | PASS |
|
||||||
|
| GET /home/daily-tips(clothing/palette/wellness) | PASS |
|
||||||
|
| nickname / yijing / home unit | PASS |
|
||||||
|
| H5 related vitest | PASS(既有) |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Reviewer P1(错字段断言)已修并在 Postgres 上验证
|
||||||
|
- 勿提交 `test-results/` · `config.local.yaml`
|
||||||
@@ -16,3 +16,4 @@
|
|||||||
| ECR-008 | 运营内容 Ops-C(首页宫格 + 测评上下架) | **Implemented** | Spec Active · BD-2026-008 · TEST_REPORT · CODE_REVIEW |
|
| ECR-008 | 运营内容 Ops-C(首页宫格 + 测评上下架) | **Implemented** | Spec Active · BD-2026-008 · TEST_REPORT · CODE_REVIEW |
|
||||||
| ECR-009 | 运营商业加深 Ops-D(订单筛选 · 展示价 · 退款只读) | **Implemented** | Spec ops-commerce · BD-2026-009 · commit `ca3b135` · LOOP-RUN-002 · Gate PASS |
|
| ECR-009 | 运营商业加深 Ops-D(订单筛选 · 展示价 · 退款只读) | **Implemented** | Spec ops-commerce · BD-2026-009 · commit `ca3b135` · LOOP-RUN-002 · Gate PASS |
|
||||||
| ECR-010 | 运营系统 Ops-E(RBAC · 封禁 · 推送占位) | **Implemented** | Spec ops-system · BD-2026-010 · TEST_REPORT · CODE_REVIEW · HANDOFF review |
|
| ECR-010 | 运营系统 Ops-E(RBAC · 封禁 · 推送占位) | **Implemented** | Spec ops-system · BD-2026-010 · TEST_REPORT · CODE_REVIEW · HANDOFF review |
|
||||||
|
| ECR-011 | 昵称 · 首页贴士 · Self 唯一/合盘交叉校验 | **Closed** | TEST_REPORT PASS · CODE_REVIEW Approve · BD-2026-011 |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
GrowthCheckin,
|
GrowthCheckin,
|
||||||
GrowthPlan,
|
GrowthPlan,
|
||||||
GrowthReport,
|
GrowthReport,
|
||||||
|
HomeDailyTips,
|
||||||
ImageCardDrawn,
|
ImageCardDrawn,
|
||||||
ImageCardQuota,
|
ImageCardQuota,
|
||||||
ImageCardScene,
|
ImageCardScene,
|
||||||
@@ -89,6 +90,8 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
call<AuthSession>('/api/v1/auth/login', { method: 'POST', body }),
|
call<AuthSession>('/api/v1/auth/login', { method: 'POST', body }),
|
||||||
authLogout: () => call<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }),
|
authLogout: () => call<{ ok: boolean }>('/api/v1/auth/logout', { method: 'POST' }),
|
||||||
authMe: () => call<AuthUser>('/api/v1/auth/me'),
|
authMe: () => call<AuthUser>('/api/v1/auth/me'),
|
||||||
|
authUpdateMe: (body: { nickname: string }) =>
|
||||||
|
call<AuthUser>('/api/v1/auth/me', { method: 'PATCH', body }),
|
||||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||||
createProfile: (body: CreateProfileBody) =>
|
createProfile: (body: CreateProfileBody) =>
|
||||||
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||||
@@ -222,6 +225,7 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}>
|
}>
|
||||||
}>('/api/v1/home/tools'),
|
}>('/api/v1/home/tools'),
|
||||||
|
getHomeDailyTips: () => call<HomeDailyTips>('/api/v1/home/daily-tips'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,19 @@ export interface SolarTermToday {
|
|||||||
date: string
|
date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首页「自己」卡片:穿衣 / 颜色 / 养生 */
|
||||||
|
export interface HomeDailyTips {
|
||||||
|
clothing_index: number
|
||||||
|
clothing: string
|
||||||
|
color_note: string
|
||||||
|
palette: { name: string; hex: string }[]
|
||||||
|
wellness: string
|
||||||
|
source: 'llm' | 'fallback' | string
|
||||||
|
gua_name?: string
|
||||||
|
day_part?: string
|
||||||
|
need_birth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface MoodEntry {
|
export interface MoodEntry {
|
||||||
id: string
|
id: string
|
||||||
day: string
|
day: string
|
||||||
|
|||||||
+36
-2
@@ -41,6 +41,29 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/auth/me:
|
||||||
|
get:
|
||||||
|
tags: [system]
|
||||||
|
summary: Current account
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
patch:
|
||||||
|
tags: [system]
|
||||||
|
summary: Update account nickname
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [nickname]
|
||||||
|
properties:
|
||||||
|
nickname: { type: string, maxLength: 16 }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
/api/v1/admin/auth/login:
|
/api/v1/admin/auth/login:
|
||||||
post:
|
post:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
@@ -393,6 +416,17 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
|
|
||||||
|
/api/v1/home/daily-tips:
|
||||||
|
get:
|
||||||
|
tags: [system]
|
||||||
|
summary: Homepage self-card daily tips (clothing / colors / wellness)
|
||||||
|
description: >
|
||||||
|
Uses self profile birth + local time hexagram seed and DeepSeek when configured.
|
||||||
|
Falls back to local tips without LLM or birth profile. DeviceAuth required.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
|
||||||
/api/v1/admin/home/tools:
|
/api/v1/admin/home/tools:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
@@ -477,8 +511,8 @@ paths:
|
|||||||
- type: object
|
- type: object
|
||||||
properties:
|
properties:
|
||||||
data: { $ref: '#/components/schemas/Profile' }
|
data: { $ref: '#/components/schemas/Profile' }
|
||||||
|
'409':
|
||||||
/api/v1/profiles/{id}:
|
description: Second self profile (code 40902)
|
||||||
patch:
|
patch:
|
||||||
tags: [profile]
|
tags: [profile]
|
||||||
summary: 更新个人档案
|
summary: 更新个人档案
|
||||||
|
|||||||
Reference in New Issue
Block a user