feat(ECR-011): 昵称、首页贴士与档案 Self 唯一/合盘交叉校验
每账号仅一条 self(migration 40902);合盘只选 TA;账号昵称可改;首页穿衣/颜色/养生贴士。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,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()))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -91,4 +91,4 @@ export const homeFeeds: HomeFeed[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const homeSearchHints = ['探索今日心情', '愈心解码', '合盘了解彼此', 'AI 成长助手']
|
||||
export const homeSearchHints = ['今日穿衣指数', '愈心解码', '合盘了解彼此', 'AI 成长助手']
|
||||
|
||||
@@ -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: '情绪整理',
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
v-model="nickname"
|
||||
class="inp"
|
||||
type="text"
|
||||
maxlength="32"
|
||||
placeholder="怎么称呼你"
|
||||
maxlength="16"
|
||||
placeholder="不填则自动生成,如心语岛"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -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('暂无档案')
|
||||
})
|
||||
|
||||
|
||||
@@ -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('添加档案')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user