feat(ECR-011): 昵称、首页贴士与档案 Self 唯一/合盘交叉校验
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-12 22:05:00 +08:00
co-authored by Cursor
parent 5ceb3ce749
commit 13860bf1ef
67 changed files with 1936 additions and 275 deletions
+25
View File
@@ -23,6 +23,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
g.POST("/login", h.Login)
g.POST("/logout", h.Logout)
g.GET("/me", h.Me)
g.PATCH("/me", h.PatchMe)
}
type authBody struct {
@@ -97,6 +98,30 @@ func (h *AuthHandler) Me(c *gin.Context) {
response.OK(c, me)
}
type patchMeBody struct {
Nickname string `json:"nickname"`
}
// PatchMe handles PATCH /auth/me (update nickname).
func (h *AuthHandler) PatchMe(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var body patchMeBody
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
if err != nil {
response.Fail(c, http.StatusBadRequest, 40113, err.Error())
return
}
response.OK(c, me)
}
func bearerToken(c *gin.Context) string {
h := c.GetHeader("Authorization")
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
+12
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
@@ -19,6 +20,7 @@ type HomeHandler struct {
func (h *HomeHandler) Register(api *gin.RouterGroup) {
g := api.Group("/home")
g.GET("/tools", h.Tools)
g.GET("/daily-tips", h.DailyTips)
}
func (h *HomeHandler) Tools(c *gin.Context) {
@@ -32,3 +34,13 @@ func (h *HomeHandler) Tools(c *gin.Context) {
}
response.OK(c, gin.H{"items": items})
}
func (h *HomeHandler) DailyTips(c *gin.Context) {
uid, _ := middleware.UserIDFromContext(c)
tips, err := h.Svc.DailyTips(c.Request.Context(), uid)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50031, "daily tips failed")
return
}
response.OK(c, tips)
}
+5
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"time"
@@ -56,6 +57,10 @@ func (h *ProfileHandler) Create(c *gin.Context) {
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
})
if err != nil {
if errors.Is(err, profile.ErrSelfExists) {
response.Fail(c, http.StatusConflict, 40902, err.Error())
return
}
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
return
}
+5 -1
View File
@@ -62,7 +62,11 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
Reports: reportRepo,
Quotas: &repository.ImageCardRepo{Pool: pool},
}
homeSvc := &homesvc.Service{Repo: &repository.HomeToolsRepo{Pool: pool}}
homeSvc := &homesvc.Service{
Repo: &repository.HomeToolsRepo{Pool: pool},
Profiles: profileRepo,
LLM: llm,
}
analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
adminSvc := &adminsvc.Service{
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
@@ -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)
}
}
+73
View File
@@ -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 (116 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
}
// UpdateNickname sets users.nickname (non-empty).
func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nickname string) error {
_, err := r.Pool.Exec(ctx, `
UPDATE users SET nickname=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, nickname)
return err
}
// TouchPassword updates stored password hash (open-login record).
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
_, err := r.Pool.Exec(ctx, `
@@ -34,6 +34,15 @@ func scanProfile(scan func(dest ...any) error) (*model.Profile, error) {
return p, nil
}
// CountActiveSelf returns non-deleted self profiles for the user.
func (r *ProfileRepo) CountActiveSelf(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM profiles
WHERE user_id=$1 AND relation='self' AND deleted_at IS NULL`, userID).Scan(&n)
return n, err
}
// Create inserts a profile.
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
+36 -8
View File
@@ -12,6 +12,7 @@ import (
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
@@ -46,12 +47,12 @@ func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone,
}
// OpenLogin: any phone+password accepted; persist account; issue session.
func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickIn string) (*SessionResult, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return nil, errors.New("请填写手机号")
}
nickname = strings.TrimSpace(nickname)
nickIn = nickgen.Normalize(nickIn)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -65,8 +66,12 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
}
nick := acc.Nickname
if nickname != "" {
nick = nickname
if nickIn != "" {
nick = nickIn
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
} else if nick == "" {
nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
}
return s.issue(ctx, acc.ID, acc.Phone, nick)
}
@@ -74,15 +79,19 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
return nil, err
}
// New phone: prefer upgrading anonymous device user.
nick := nickIn
if nick == "" {
nick = nickgen.Random()
}
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
uid := deviceUserID
if curErr == nil && cur.Phone == "" {
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil {
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nick); err != nil {
return nil, errors.New("登录失败,请重试")
}
} else {
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nick)
if err != nil {
return nil, errors.New("登录失败,请重试")
}
@@ -90,7 +99,26 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
return s.issue(ctx, uid, phone, nickname)
return s.issue(ctx, uid, phone, nick)
}
// UpdateNickname changes account display nickname (116 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
@@ -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 为 5595 整数;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,
}
}
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
@@ -25,9 +26,11 @@ var allowedIcons = map[string]struct{}{
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
}
// Service serves homepage tool catalog.
// Service serves homepage tool catalog and daily tips.
type Service struct {
Repo *repository.HomeToolsRepo
Repo *repository.HomeToolsRepo
Profiles *repository.ProfileRepo
LLM *deepseek.Client
}
// ListPublic returns enabled tools.
@@ -30,6 +30,9 @@ type CreateInput struct {
BirthPlace *string
}
// ErrSelfExists is returned when creating a second self profile.
var ErrSelfExists = errors.New("self profile already exists")
// Create stores a profile for the user.
func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) {
if in.Relation != "self" && in.Relation != "other" {
@@ -38,6 +41,15 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
if in.BirthDate.IsZero() {
return nil, errors.New("birth_date required")
}
if in.Relation == "self" {
n, err := s.Repo.CountActiveSelf(ctx, userID)
if err != nil {
return nil, err
}
if n > 0 {
return nil, ErrSelfExists
}
}
name := in.DisplayName
if name == "" {
if in.Relation == "self" {
@@ -163,6 +163,9 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
// CreateSynastry builds a multi-chart synastry report for two profiles.
// asOf is the secondary-progression date (defaults to today CST when nil/zero).
func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) {
if profileAID == profileBID {
return nil, errors.New("合盘需要两个不同档案")
}
pa, err := s.Profiles.GetForUser(ctx, userID, profileAID)
if err != nil {
return nil, errors.New("profile a not found")
@@ -174,6 +177,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil
if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID {
return nil, errors.New("profile b not found")
}
} else if pb.Relation != "other" {
return nil, errors.New("合盘对象须为 TA 档案或附近的人")
}
ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace)
if err != nil {
+93
View File
@@ -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
}
+29
View File
@@ -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;
+259 -103
View File
@@ -1,53 +1,118 @@
<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">
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
<button type="button" class="who-btn" @click="$emit('open-profile')">
{{ profileLabel }}
<span class="caret" aria-hidden="true"></span>
</button>
<router-link class="more" to="/profile" @click.stop>更多</router-link>
<router-link class="more" to="/profile">更多</router-link>
</div>
<div class="self-body">
<div class="mood-col">
<div class="mood-label">
今日心情 <em>{{ mood.score }}</em><span></span>
</div>
<p class="mood-text">{{ mood.text }}</p>
</div>
<div class="dims" role="list">
<div
v-for="d in mood.dims"
:key="d.key"
class="dim"
role="listitem"
:style="{ '--bar': d.color, '--h': d.score + '%' }"
>
<b class="dim-score">{{ d.score }}</b>
<div class="dim-track">
<i class="dim-fill" />
<p v-if="loading" class="tips-loading">正在根据生日与此刻节律整理</p>
<template v-else>
<div class="duo">
<!-- 穿衣指数 -->
<div class="wear-panel">
<div class="wear-title">
<span class="ico wear" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path
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"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
</svg>
</span>
穿衣指数
</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>
<span class="dim-label">{{ d.label }}</span>
</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>
</section>
</template>
<script setup lang="ts">
defineProps<{
import { computed } from 'vue'
import type { HomeDailyTips } from '../../composables/useHomeDailyTips'
const props = defineProps<{
profileLabel: string
mood: {
score: number
text: string
dims: { key: string; label: string; score: number; color: string }[]
}
tips: HomeDailyTips
loading?: boolean
}>()
defineEmits<{
'open-portrait': []
'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>
<style scoped>
@@ -59,10 +124,6 @@ defineEmits<{
background: var(--color-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-hero);
transition: transform var(--duration-fast) ease;
}
.self-card:active {
transform: scale(0.99);
}
.self-head {
display: flex;
@@ -95,103 +156,197 @@ defineEmits<{
}
.self-body {
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;
align-items: stretch;
}
.mood-col {
flex: 1;
min-width: 0;
.wear-panel {
display: flex;
flex-direction: column;
justify-content: center;
padding-right: 4px;
align-items: center;
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 {
font-size: 13px;
font-weight: 600;
.wear-title {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
align-self: flex-start;
}
.mood-label em {
font-style: normal;
.wear-ring {
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-size: 34px;
font-size: 20px;
font-weight: 700;
color: var(--color-primary);
margin: 0 1px 0 4px;
letter-spacing: -0.02em;
vertical-align: -4px;
}
.mood-label span {
font-size: 12px;
color: var(--color-text-tertiary);
font-weight: 500;
}
.mood-text {
margin-top: 8px;
font-size: 12px;
color: #777;
line-height: 1.55;
.wear-text {
margin: 0;
font-size: 11px;
color: #7a6f6a;
line-height: 1.45;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dims {
display: flex;
gap: 7px;
align-items: flex-end;
.ico {
width: 18px;
height: 18px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding-bottom: 2px;
}
.dim {
width: 28px;
.ico svg {
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;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 5px;
}
.dim-score {
font-size: 11px;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1;
.chip {
width: 36px;
height: 36px;
border-radius: 11px;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.06),
0 3px 8px rgba(0, 0, 0, 0.08);
}
.dim-track {
width: 12px;
height: 64px;
border-radius: var(--radius-pill);
background: #f3ece8;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.dim-fill {
display: block;
width: 100%;
height: var(--h);
border-radius: var(--radius-pill);
background: linear-gradient(180deg, color-mix(in srgb, var(--bar) 70%, #fff) 0%, var(--bar) 100%);
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
animation: fillUp 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: calc(var(--d, 80ms) + 120ms);
}
@keyframes fillUp {
from {
height: 0;
}
to {
height: var(--h);
}
}
.dim-label {
.chip-name {
font-size: 10px;
font-weight: 600;
color: var(--color-text-secondary);
line-height: 1;
white-space: nowrap;
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--d, 0ms);
@@ -208,8 +363,9 @@ defineEmits<{
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.dim-fill {
.ring-val {
animation: none !important;
transition: none !important;
}
}
</style>
@@ -1,52 +1,36 @@
<template>
<section class="section grid-sec">
<div class="tool-scroll" aria-label="功能入口">
<div class="tool-rows">
<div class="tool-row">
<router-link
v-for="t in row1"
:key="t.to + t.label"
:to="t.to"
class="tool-item"
@click="$emit('track', t.label)"
>
<div class="icon" aria-hidden="true">
<HomeToolIcon :name="t.icon" />
</div>
<div class="label">{{ t.label }}</div>
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
</router-link>
<section class="section grid-sec" aria-label="功能入口">
<div class="tool-grid">
<router-link
v-for="t in tools"
: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" :size="48" />
</div>
<div class="tool-row">
<router-link
v-for="t in row2"
:key="t.to + t.label"
:to="t.to"
class="tool-item"
@click="$emit('track', t.label)"
>
<div class="icon" aria-hidden="true">
<HomeToolIcon :name="t.icon" />
</div>
<div class="label">{{ t.label }}</div>
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
</router-link>
</div>
</div>
<div class="label">{{ t.label }}</div>
<span v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
</router-link>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import HomeToolIcon from '../HomeToolIcon.vue'
import type { HomeTool } from '../../lib/homeCatalog'
defineProps<{
const props = defineProps<{
row1: HomeTool[]
row2: HomeTool[]
}>()
defineEmits<{ track: [label: string] }>()
const tools = computed(() => [...props.row1, ...props.row2])
</script>
<style scoped>
@@ -55,46 +39,31 @@ defineEmits<{ track: [label: string] }>()
margin-top: 18px;
}
.grid-sec {
padding-right: 0;
margin-top: 14px;
}
.tool-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-right: var(--spacing-md);
}
.tool-scroll::-webkit-scrollbar {
display: none;
}
.tool-rows {
display: flex;
flex-direction: column;
gap: 16px;
width: max-content;
.tool-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
row-gap: 16px;
column-gap: 0;
padding: 4px 0 2px;
}
.tool-row {
display: flex;
gap: 2px;
}
.tool-item {
width: 88px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--color-text-primary);
position: relative;
flex-shrink: 0;
min-width: 0;
transition: transform var(--duration-fast) ease;
}
.tool-item:active {
transform: scale(0.9);
}
.tool-item .icon {
width: 75px;
height: 75px;
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
@@ -109,11 +78,16 @@ defineEmits<{ track: [label: string] }>()
text-align: center;
line-height: 1.2;
font-weight: 500;
max-width: 100%;
padding: 0 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
position: absolute;
top: -2px;
right: 6px;
right: max(0px, calc(50% - 38px));
font-size: 9px;
color: #fff;
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>
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
<option v-for="p in selfProfiles" :key="p.id" :value="p.id">
{{ p.display_name || '' }} · {{ birthLabel(p.birth_date) }}
</option>
</select>
<label class="yxg-label">TA档案 B</label>
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
<option v-for="p in otherProfiles" :key="p.id" :value="p.id">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
</option>
<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<{
profileA: string
profileB: string
profiles: Profile[]
selfProfiles: Profile[]
otherProfiles: Profile[]
nearby: { profile: Profile; distance_km: number }[]
asOf: string
birthLabel: (d?: string) => string
@@ -49,7 +49,8 @@
v-if="showClassicSelects"
:profile-a="profileA"
:profile-b="profileB"
:profiles="profiles"
:self-profiles="selfProfiles"
:other-profiles="pickableProfiles"
:nearby="nearby"
:as-of="asOf"
:birth-label="birthLabel"
@@ -65,7 +66,7 @@
:nearby-loading="nearbyLoading"
:invite-path="invitePath"
:nearby-hint="nearbyHint"
:profiles-length="profiles.length"
:profiles-length="pickableProfiles.length + selfProfiles.length"
:nearby-length="nearby.length"
@create-invite="$emit('create-invite')"
@load-nearby="$emit('load-nearby')"
@@ -102,7 +103,7 @@ defineProps<{
loading: boolean
profileA: string
showClassicSelects: boolean
profiles: Profile[]
selfProfiles: Profile[]
asOf: string
inviteHighlight: boolean
inviting: boolean
@@ -3,6 +3,7 @@ import type { GrowthReport, Profile } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureOtherProfile } from '../lib/authSession'
import type { SynastryMainTab } from '../lib/synastryChart'
type NearbyItem = { profile: Profile; distance_km: number }
@@ -37,7 +38,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
r.profiles.value = res.items || []
const self = r.profiles.value.find((p) => p.relation === 'self')
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
} catch (e) {
r.error.value = e instanceof Error ? e.message : '加载档案失败'
@@ -57,8 +58,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
r.error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const p = await api.createProfile({
relation: 'other',
const p = await ensureOtherProfile({
birth_date: birth,
display_name: r.tName.value || 'TA',
})
+2 -2
View File
@@ -2,7 +2,7 @@ import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { ensureAccount, pickableArchiveList } from '../lib/authSession'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
@@ -51,7 +51,7 @@ export function useAskPage() {
}
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
profiles.value = pickableArchiveList(list.items || [])
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
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()))
}
+13 -4
View File
@@ -10,7 +10,7 @@ import {
type HomeTool,
} from '../lib/homeCatalog'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { useHomeMood } from './useHomeMood'
import { useHomeDailyTips } from './useHomeDailyTips'
const ICONS = new Set([
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
@@ -55,15 +55,23 @@ function mapTools(
export function useHomePage() {
const router = useRouter()
const profileLabel = ref('自己')
const profileLabel = ref('访客')
const plusOpen = ref(false)
const mood = useHomeMood()
const { tips, loading: tipsLoading } = useHomeDailyTips()
const grid = reactive({
gridRow1: [...homeGridRow1] as HomeTool[],
gridRow2: [...homeGridRow2] as HomeTool[],
})
onMounted(() => {
void api
.authMe()
.then((me) => {
if (me.nickname) profileLabel.value = me.nickname
})
.catch(() => {
profileLabel.value = '访客'
})
void api
.getHomeTools()
.then((res) => {
@@ -105,7 +113,8 @@ export function useHomePage() {
router,
profileLabel,
plusOpen,
mood,
tips,
tipsLoading,
searchHint,
...toRefs(grid),
feeds: homeFeeds,
@@ -4,7 +4,7 @@ import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
import { ensureAccount, ensureSelfProfile } from '../lib/authSession'
export const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
@@ -101,7 +101,7 @@ export function useImageCardPage() {
drawing.value = true
try {
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({
profile_id: profile.id,
scene: scene.value,
@@ -5,7 +5,7 @@ import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
@@ -69,7 +69,7 @@ export function useLifeRhythmPage() {
needBirth.value = false
try {
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)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
@@ -4,7 +4,7 @@ import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
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'
export function usePortraitPage() {
@@ -45,7 +45,7 @@ export function usePortraitPage() {
needBirth.value = false
try {
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)
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
@@ -1,6 +1,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import {
avatarInitial,
@@ -16,6 +17,7 @@ export function useProfilePage() {
const route = useRoute()
const router = useRouter()
const accountNickname = ref('')
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
@@ -102,13 +104,25 @@ export function useProfilePage() {
() => applyQueryFlags(),
)
function onNicknameUpdated(n: string) {
accountNickname.value = n
}
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) return
try {
const me = await api.authMe()
accountNickname.value = me.nickname || ''
} catch {
accountNickname.value = ''
}
await load()
applyQueryFlags()
})
return {
accountNickname,
onNicknameUpdated,
items,
loading,
error,
@@ -1,9 +1,9 @@
import { computed, ref } from 'vue'
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 { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
import { ensureAccount, ensureOtherProfile, findSelfProfile } from '../lib/authSession'
import type { RelationSharePayload } from '../lib/shareLink'
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() {
if (!(await ensureAccount(router, route.fullPath))) return
loading.value = true
@@ -111,10 +104,10 @@ export function useRelationPage() {
const m = Number(om.value)
const d = Number(od.value)
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 other = await api.createProfile({
relation: 'other',
const other = await ensureOtherProfile({
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: relationType.value,
+2 -3
View File
@@ -1,7 +1,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { ensureAccount, findSelfProfile } from '../lib/authSession'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
@@ -146,8 +146,7 @@ export function useScalePage() {
for (const q of questions.value) {
if (!answers[q.id]) throw new Error('请完成全部题目')
}
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
const self = await findSelfProfile()
if (!self) {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
@@ -5,7 +5,7 @@ import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
import {
ascLonFromSummary,
fortuneBundleFrom,
@@ -111,8 +111,7 @@ export function useStarProfilePage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
const profile = await api.createProfile({
relation: 'self',
const profile = await ensureSelfProfile({
birth_date: birth,
display_name: '我',
birth_time: bt,
@@ -56,7 +56,10 @@ export function useSynastryPage() {
const showQuickAdd = ref(false)
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value))
const 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 p = profiles.value.find((x) => x.id === profileA.value)
return p?.display_name || ''
@@ -178,6 +181,7 @@ export function useSynastryPage() {
showQuickAdd,
needsSubTab,
pickableProfiles,
selfProfiles,
profileAName,
selfInitial,
profileInitial,
+76
View File
@@ -32,6 +32,82 @@ export async function findSelfProfile(): Promise<Profile | 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. */
export async function loadSelfLatest(
type: 'portrait' | 'star' | 'rhythm',
+1 -1
View File
@@ -91,4 +91,4 @@ export const homeFeeds: HomeFeed[] = [
},
]
export const homeSearchHints = ['探索今日心情', '愈心解码', '合盘了解彼此', 'AI 成长助手']
export const homeSearchHints = ['今日穿衣指数', '愈心解码', '合盘了解彼此', 'AI 成长助手']
+4 -3
View File
@@ -14,8 +14,8 @@
<HomeArchiveStrip @add="goPlus('add')" />
<HomeSelfCard
:profile-label="profileLabel"
:mood="mood"
@open-portrait="router.push('/portrait')"
:tips="tips"
:loading="tipsLoading"
@open-profile="router.push('/profile')"
/>
@@ -40,7 +40,8 @@ const {
router,
profileLabel,
plusOpen,
mood,
tips,
tipsLoading,
searchHint,
gridRow1,
gridRow2,
@@ -7,7 +7,9 @@ const { api } = vi.hoisted(() => ({
authMe: vi.fn(),
listImageCardScenes: vi.fn(),
getImageCardQuota: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
updateProfile: vi.fn(),
drawImageCard: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
@@ -35,6 +37,7 @@ describe('ImageCardPage', () => {
})
it('boots scenes and draws a card', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
api.createProfile.mockResolvedValue({ id: 'p1' })
api.drawImageCard.mockResolvedValue({
scene: '情绪整理',
+2 -2
View File
@@ -64,8 +64,8 @@
v-model="nickname"
class="inp"
type="text"
maxlength="32"
placeholder="怎么称呼你"
maxlength="16"
placeholder="不填则自动生成,如心语岛"
/>
</label>
+4 -1
View File
@@ -6,6 +6,7 @@ import ProfilePage from './ProfilePage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
authUpdateMe: vi.fn(),
listProfiles: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
@@ -29,13 +30,15 @@ const RouterLinkStub = defineComponent({
describe('ProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.authMe.mockResolvedValue({ id: 'u1', nickname: '心语岛', phone: '138****0000' })
})
it('shows empty state', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('账号昵称')
expect(w.text()).toContain('心语岛')
expect(w.text()).toContain('暂无档案')
})
+8
View File
@@ -16,6 +16,11 @@
</p>
<template v-else>
<AccountNicknameCard
:nickname="accountNickname"
@updated="onNicknameUpdated"
/>
<ProfileEmpty v-if="!items.length" />
<template v-else>
@@ -85,6 +90,7 @@
<script setup lang="ts">
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import AccountNicknameCard from '../components/profile/AccountNicknameCard.vue'
import ProfileAddForm from '../components/profile/ProfileAddForm.vue'
import ProfileBottomCta from '../components/profile/ProfileBottomCta.vue'
import ProfileEditForm from '../components/profile/ProfileEditForm.vue'
@@ -95,6 +101,8 @@ import ProfileSelfCard from '../components/profile/ProfileSelfCard.vue'
import { useProfilePage } from '../composables/useProfilePage'
const {
accountNickname,
onNicknameUpdated,
items,
loading,
error,
@@ -110,4 +110,20 @@ describe('SynastryPage', () => {
await flushPromises()
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('添加档案')
})
})
+2 -2
View File
@@ -30,7 +30,7 @@
:nearby="nearby"
:adding="adding"
:loading="loading"
:profiles="profiles"
:self-profiles="selfProfiles"
:invite-highlight="inviteHighlight"
:inviting="inviting"
:nearby-loading="nearbyLoading"
@@ -107,7 +107,6 @@ const {
nearbyHint,
invitePath,
inviteHighlight,
profiles,
nearby,
profileA,
profileB,
@@ -126,6 +125,7 @@ const {
showQuickAdd,
needsSubTab,
pickableProfiles,
selfProfiles,
profileAName,
selfInitial,
profileInitial,