refactor(ECR-004): Synastry 再拆、Scale 测、Membership 守卫、OpenAPI 与 CI
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

压合盘贴线文件;补选答单测与 nil 守卫;对齐关键 OpenAPI schemas;加 GitHub Actions 门禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 21:06:14 +08:00
co-authored by Cursor
parent 67701d1f7b
commit 7034c92de7
22 changed files with 1317 additions and 543 deletions
+23 -3
View File
@@ -19,6 +19,14 @@ type ReportHandler struct {
Membership *membership.Service
}
func (h *ReportHandler) requireMembership(c *gin.Context) (*membership.Service, bool) {
if h.Membership == nil {
response.Fail(c, http.StatusInternalServerError, 50000, "membership service unavailable")
return nil, false
}
return h.Membership, true
}
// Register mounts report/commerce routes.
func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.POST("/reports/portrait", h.CreatePortrait)
@@ -202,7 +210,11 @@ func (h *ReportHandler) GetMembership(c *gin.Context) {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
me, err := h.Membership.Get(c.Request.Context(), userID)
msvc, ok := h.requireMembership(c)
if !ok {
return
}
me, err := msvc.Get(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
@@ -217,6 +229,10 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
msvc, ok := h.requireMembership(c)
if !ok {
return
}
var req struct {
Kind string `json:"kind" binding:"required"`
Plan string `json:"plan"`
@@ -235,7 +251,7 @@ func (h *ReportHandler) CreateOrder(c *gin.Context) {
}
rid = &id
}
oid, err := h.Membership.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{
oid, err := msvc.CreateOrder(c.Request.Context(), userID, membership.CreateOrderInput{
Kind: req.Kind, Plan: req.Plan, ReportID: rid,
})
if err != nil {
@@ -252,12 +268,16 @@ func (h *ReportHandler) PayMock(c *gin.Context) {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
msvc, ok := h.requireMembership(c)
if !ok {
return
}
oid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
if err := h.Membership.PayMock(c.Request.Context(), userID, oid); err != nil {
if err := msvc.PayMock(c.Request.Context(), userID, oid); err != nil {
response.Fail(c, http.StatusBadRequest, 30004, err.Error())
return
}
@@ -0,0 +1,62 @@
<template>
<div class="yxg-card soft classic-panel">
<label class="yxg-label">档案 A</label>
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
</option>
</select>
<label class="yxg-label">TA档案 B</label>
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
</option>
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
附近 · {{ n.profile.display_name || '匿名' }} · {{ n.distance_km }}km
</option>
</select>
<label class="yxg-label">推运日期</label>
<input :value="asOf" type="date" class="sel" @input="$emit('update:asOf', ($event.target as HTMLInputElement).value)" />
</div>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
defineProps<{
profileA: string
profileB: string
profiles: Profile[]
nearby: { profile: Profile; distance_km: number }[]
asOf: string
birthLabel: (d?: string) => string
}>()
defineEmits<{
'update:profileA': [value: string]
'update:profileB': [value: string]
'update:asOf': [value: string]
}>()
</script>
<style scoped>
.classic-panel {
margin-bottom: 12px;
}
.yxg-label {
display: block;
margin: 12px 0 6px;
font-size: 13px;
color: #666;
}
.sel {
width: 100%;
padding: 12px;
border: 1.5px solid #eee;
border-radius: 12px;
background: #fdfaf8;
font-size: 14px;
}
</style>
@@ -0,0 +1,180 @@
<template>
<div class="dual-pick">
<div class="pick-side self-side">
<div class="av-ring on">
<span class="av-inner">{{ selfInitial }}</span>
</div>
<span class="pick-label">自己</span>
<span v-if="profileAName" class="pick-name">{{ profileAName }}</span>
</div>
<span class="pick-link" aria-hidden="true">×</span>
<div class="pick-side ta-side">
<div class="scroll-profiles" role="list">
<button
v-for="p in pickableProfiles"
:key="p.id"
type="button"
class="profile-chip"
:class="{ on: profileB === p.id }"
role="listitem"
@click="$emit('update:profileB', p.id)"
>
<span class="av-ring sm" :class="{ on: profileB === p.id }">
<span class="av-inner sm">{{ profileInitial(p) }}</span>
</span>
<span class="chip-name">{{ p.display_name || '未命名' }}</span>
</button>
<button
v-for="n in nearby"
:key="'n' + n.profile.id"
type="button"
class="profile-chip"
:class="{ on: profileB === n.profile.id }"
@click="$emit('update:profileB', n.profile.id)"
>
<span class="av-ring sm nearby" :class="{ on: profileB === n.profile.id }">
<span class="av-inner sm"></span>
</span>
<span class="chip-name">{{ n.profile.display_name || '匿名' }}</span>
</button>
<button type="button" class="profile-chip add-chip" @click="$emit('toggle-quick-add')">
<span class="av-ring sm add">
<span class="av-inner sm">+</span>
</span>
<span class="chip-name">添加档案</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
defineProps<{
selfInitial: string
profileAName: string
pickableProfiles: Profile[]
profileB: string
nearby: { profile: Profile; distance_km: number }[]
profileInitial: (p: Profile) => string
}>()
defineEmits<{
'update:profileB': [value: string]
'toggle-quick-add': []
}>()
</script>
<style scoped>
.dual-pick {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.pick-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.self-side {
flex-shrink: 0;
width: 72px;
}
.ta-side {
flex: 1;
min-width: 0;
align-items: stretch;
}
.pick-link {
flex-shrink: 0;
margin-top: 18px;
font-size: 18px;
color: var(--color-primary);
opacity: 0.5;
}
.pick-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
}
.pick-name {
font-size: 10px;
color: var(--color-text-tertiary);
max-width: 68px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.av-ring {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid transparent;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
}
.av-ring.sm {
width: 44px;
height: 44px;
}
.av-ring.on {
border-color: var(--color-primary);
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-ring.add {
background: #fff;
border: 2px dashed rgba(229, 77, 66, 0.35);
}
.av-ring.nearby {
background: linear-gradient(145deg, #e8f4ff, #b4d2f5);
}
.av-inner {
font-size: 18px;
font-weight: 700;
color: #8a4a3a;
}
.av-inner.sm {
font-size: 14px;
}
.scroll-profiles {
display: flex;
gap: 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 2px 0 4px;
}
.scroll-profiles::-webkit-scrollbar {
display: none;
}
.profile-chip {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
min-width: 52px;
}
.chip-name {
font-size: 10px;
color: #666;
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-chip.on .chip-name {
color: var(--color-primary);
font-weight: 600;
}
</style>
@@ -0,0 +1,61 @@
<template>
<div>
<div class="social-row" :class="{ pulse: inviteHighlight }">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="$emit('create-invite')">
{{ inviting ? '生成中' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="$emit('load-nearby')">
{{ nearbyLoading ? '定位中' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta invite-path">邀请链接{{ invitePath }}可复制分享</p>
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
<p v-if="profilesLength < 2 && nearbyLength === 0" class="yxg-meta">
需要至少两个档案可先去
<router-link class="yxg-link" to="/profile">档案页</router-link>
添加 TA或点添加档案快速创建
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
inviteHighlight: boolean
profileA: string
inviting: boolean
nearbyLoading: boolean
invitePath: string
nearbyHint: string
profilesLength: number
nearbyLength: number
}>()
defineEmits<{
'create-invite': []
'load-nearby': []
}>()
</script>
<style scoped>
.social-row {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.social-row.pulse {
animation: invitePulse 1.2s ease 2;
border-radius: 14px;
padding: 6px;
background: rgba(229, 77, 66, 0.06);
}
@keyframes invitePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 77, 66, 0); }
50% { box-shadow: 0 0 0 4px rgba(229, 77, 66, 0.12); }
}
.social-row .yxg-btn {
flex: 1;
min-width: 120px;
}
.invite-path { word-break: break-all; }
</style>
@@ -2,75 +2,35 @@
<div class="synastry-landing">
<h2 class="hero-title">看看你和 TA 的默契指数</h2>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationTypes"
:key="r"
type="button"
class="rel-chip"
:class="{ on: relationType === r }"
@click="$emit('update:relationType', r)"
>
{{ r }}
</button>
</div>
<SynastryRelationChips
:relation-type="relationType"
@update:relation-type="$emit('update:relationType', $event)"
/>
<div class="dual-pick">
<div class="pick-side self-side">
<div class="av-ring on">
<span class="av-inner">{{ selfInitial }}</span>
</div>
<span class="pick-label">自己</span>
<span v-if="profileAName" class="pick-name">{{ profileAName }}</span>
</div>
<span class="pick-link" aria-hidden="true">×</span>
<div class="pick-side ta-side">
<div class="scroll-profiles" role="list">
<button
v-for="p in pickableProfiles"
:key="p.id"
type="button"
class="profile-chip"
:class="{ on: profileB === p.id }"
role="listitem"
@click="$emit('update:profileB', p.id)"
>
<span class="av-ring sm" :class="{ on: profileB === p.id }">
<span class="av-inner sm">{{ profileInitial(p) }}</span>
</span>
<span class="chip-name">{{ p.display_name || '未命名' }}</span>
</button>
<button
v-for="n in nearby"
:key="'n' + n.profile.id"
type="button"
class="profile-chip"
:class="{ on: profileB === n.profile.id }"
@click="$emit('update:profileB', n.profile.id)"
>
<span class="av-ring sm nearby" :class="{ on: profileB === n.profile.id }">
<span class="av-inner sm"></span>
</span>
<span class="chip-name">{{ n.profile.display_name || '匿名' }}</span>
</button>
<button type="button" class="profile-chip add-chip" @click="$emit('update:showQuickAdd', !showQuickAdd)">
<span class="av-ring sm add">
<span class="av-inner sm">+</span>
</span>
<span class="chip-name">添加档案</span>
</button>
</div>
</div>
</div>
<SynastryDualPick
:self-initial="selfInitial"
:profile-a-name="profileAName"
:pickable-profiles="pickableProfiles"
:profile-b="profileB"
:nearby="nearby"
:profile-initial="profileInitial"
@update:profile-b="$emit('update:profileB', $event)"
@toggle-quick-add="$emit('update:showQuickAdd', !showQuickAdd)"
/>
<div v-if="showQuickAdd" class="yxg-card soft add-form">
<p class="card-title">快速添加 TA</p>
<BirthDateInputs :year="ty" :month="tm" :day="td" @update:year="$emit('update:ty', $event)" @update:month="$emit('update:tm', $event)" @update:day="$emit('update:td', $event)" />
<input :value="tName" class="name-in" placeholder="称呼(如:TA" @input="$emit('update:tName', ($event.target as HTMLInputElement).value)" />
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="$emit('add-temp')">
{{ adding ? '添加中' : '添加档案' }}
</button>
</div>
<SynastryQuickAdd
v-if="showQuickAdd"
:ty="ty"
:tm="tm"
:td="td"
:t-name="tName"
:adding="adding"
@update:ty="$emit('update:ty', $event)"
@update:tm="$emit('update:tm', $event)"
@update:td="$emit('update:td', $event)"
@update:t-name="$emit('update:tName', $event)"
@add-temp="$emit('add-temp')"
/>
<button
class="yxg-btn yxg-btn-block cta-main"
@@ -85,43 +45,31 @@
{{ showClassicSelects ? '收起档案选择' : '直接选择档案' }}
</button>
<div v-if="showClassicSelects" class="yxg-card soft classic-panel">
<label class="yxg-label">档案 A</label>
<select :value="profileA" class="sel" @change="$emit('update:profileA', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
</option>
</select>
<label class="yxg-label">TA档案 B</label>
<select :value="profileB" class="sel" @change="$emit('update:profileB', ($event.target as HTMLSelectElement).value)">
<option disabled value="">请选择</option>
<option v-for="p in profiles" :key="p.id" :value="p.id" :disabled="p.id === profileA">
{{ p.display_name || '未命名' }} · {{ birthLabel(p.birth_date) }}
</option>
<option v-for="n in nearby" :key="'n' + n.profile.id" :value="n.profile.id">
附近 · {{ n.profile.display_name || '匿名' }} · {{ n.distance_km }}km
</option>
</select>
<label class="yxg-label">推运日期</label>
<input :value="asOf" type="date" class="sel" @input="$emit('update:asOf', ($event.target as HTMLInputElement).value)" />
</div>
<SynastryClassicSelects
v-if="showClassicSelects"
:profile-a="profileA"
:profile-b="profileB"
:profiles="profiles"
:nearby="nearby"
:as-of="asOf"
:birth-label="birthLabel"
@update:profile-a="$emit('update:profileA', $event)"
@update:profile-b="$emit('update:profileB', $event)"
@update:as-of="$emit('update:asOf', $event)"
/>
<div class="social-row" :class="{ pulse: inviteHighlight }">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="$emit('create-invite')">
{{ inviting ? '生成中' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="$emit('load-nearby')">
{{ nearbyLoading ? '定位中' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta invite-path">邀请链接{{ invitePath }}可复制分享</p>
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
<p v-if="profiles.length < 2 && nearby.length === 0" class="yxg-meta">
需要至少两个档案可先去
<router-link class="yxg-link" to="/profile">档案页</router-link>
添加 TA或点添加档案快速创建
</p>
<SynastryInviteNearby
:invite-highlight="inviteHighlight"
:profile-a="profileA"
:inviting="inviting"
:nearby-loading="nearbyLoading"
:invite-path="invitePath"
:nearby-hint="nearbyHint"
:profiles-length="profiles.length"
:nearby-length="nearby.length"
@create-invite="$emit('create-invite')"
@load-nearby="$emit('load-nearby')"
/>
<SynastryFeatureCards />
@@ -131,9 +79,12 @@
<script setup lang="ts">
import type { Profile } from '@yuxingu/types'
import BirthDateInputs from '../BirthDateInputs.vue'
import SynastryClassicSelects from './SynastryClassicSelects.vue'
import SynastryDualPick from './SynastryDualPick.vue'
import SynastryFeatureCards from './SynastryFeatureCards.vue'
import { relationTypes } from '../../composables/useSynastryPage'
import SynastryInviteNearby from './SynastryInviteNearby.vue'
import SynastryQuickAdd from './SynastryQuickAdd.vue'
import SynastryRelationChips from './SynastryRelationChips.vue'
defineProps<{
relationType: string
@@ -191,150 +142,6 @@ defineEmits<{
margin: 4px 0 16px;
letter-spacing: 0.04em;
}
.rel-chips {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rel-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
color: #666;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.rel-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
.dual-pick {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.pick-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.self-side {
flex-shrink: 0;
width: 72px;
}
.ta-side {
flex: 1;
min-width: 0;
align-items: stretch;
}
.pick-link {
flex-shrink: 0;
margin-top: 18px;
font-size: 18px;
color: var(--color-primary);
opacity: 0.5;
}
.pick-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
}
.pick-name {
font-size: 10px;
color: var(--color-text-tertiary);
max-width: 68px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.av-ring {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid transparent;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
}
.av-ring.sm {
width: 44px;
height: 44px;
}
.av-ring.on {
border-color: var(--color-primary);
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
}
.av-ring.add {
background: #fff;
border: 2px dashed rgba(229, 77, 66, 0.35);
}
.av-ring.nearby {
background: linear-gradient(145deg, #e8f4ff, #b4d2f5);
}
.av-inner {
font-size: 18px;
font-weight: 700;
color: #8a4a3a;
}
.av-inner.sm {
font-size: 14px;
}
.scroll-profiles {
display: flex;
gap: 10px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 2px 0 4px;
}
.scroll-profiles::-webkit-scrollbar {
display: none;
}
.profile-chip {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
min-width: 52px;
}
.chip-name {
font-size: 10px;
color: #666;
max-width: 52px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-chip.on .chip-name {
color: var(--color-primary);
font-weight: 600;
}
.add-form {
margin-bottom: 12px;
}
.name-in {
width: 100%;
padding: 10px;
border: 1.5px solid #eee;
border-radius: 10px;
margin: 8px 0;
font-size: 14px;
}
.cta-main {
margin-top: 4px;
font-size: 16px;
@@ -355,42 +162,4 @@ defineEmits<{
text-underline-offset: 3px;
cursor: pointer;
}
.classic-panel {
margin-bottom: 12px;
}
.yxg-label {
display: block;
margin: 12px 0 6px;
font-size: 13px;
color: #666;
}
.sel {
width: 100%;
padding: 12px;
border: 1.5px solid #eee;
border-radius: 12px;
background: #fdfaf8;
font-size: 14px;
}
.social-row {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.social-row.pulse {
animation: invitePulse 1.2s ease 2;
border-radius: 14px;
padding: 6px;
background: rgba(229, 77, 66, 0.06);
}
@keyframes invitePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 77, 66, 0); }
50% { box-shadow: 0 0 0 4px rgba(229, 77, 66, 0.12); }
}
.social-row .yxg-btn {
flex: 1;
min-width: 120px;
}
.invite-path { word-break: break-all; }
</style>
@@ -0,0 +1,56 @@
<template>
<div class="yxg-card soft add-form">
<p class="card-title">快速添加 TA</p>
<BirthDateInputs
:year="ty"
:month="tm"
:day="td"
@update:year="$emit('update:ty', $event)"
@update:month="$emit('update:tm', $event)"
@update:day="$emit('update:td', $event)"
/>
<input
:value="tName"
class="name-in"
placeholder="称呼(如:TA"
@input="$emit('update:tName', ($event.target as HTMLInputElement).value)"
/>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="$emit('add-temp')">
{{ adding ? '添加中' : '添加档案' }}
</button>
</div>
</template>
<script setup lang="ts">
import BirthDateInputs from '../BirthDateInputs.vue'
defineProps<{
ty: string
tm: string
td: string
tName: string
adding: boolean
}>()
defineEmits<{
'update:ty': [value: string]
'update:tm': [value: string]
'update:td': [value: string]
'update:tName': [value: string]
'add-temp': []
}>()
</script>
<style scoped>
.add-form {
margin-bottom: 12px;
}
.name-in {
width: 100%;
padding: 10px;
border: 1.5px solid #eee;
border-radius: 10px;
margin: 8px 0;
font-size: 14px;
}
</style>
@@ -0,0 +1,52 @@
<template>
<div class="rel-chips" role="group" aria-label="关系类型">
<button
v-for="r in relationTypes"
:key="r"
type="button"
class="rel-chip"
:class="{ on: relationType === r }"
@click="$emit('update:relationType', r)"
>
{{ r }}
</button>
</div>
</template>
<script setup lang="ts">
import { relationTypes } from '../../lib/synastryChart'
defineProps<{
relationType: string
}>()
defineEmits<{
'update:relationType': [value: string]
}>()
</script>
<style scoped>
.rel-chips {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.rel-chip {
border: 1.5px solid #eee;
background: #fff;
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
color: #666;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.rel-chip.on {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
border-color: transparent;
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
}
</style>
@@ -0,0 +1,157 @@
import type { Ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { SynastryMainTab } from '../lib/synastryChart'
type NearbyItem = { profile: Profile; distance_km: number }
export type SynastryPageActionRefs = {
loading: Ref<boolean>
adding: Ref<boolean>
paying: Ref<boolean>
inviting: Ref<boolean>
nearbyLoading: Ref<boolean>
error: Ref<string>
nearbyHint: Ref<string>
invitePath: Ref<string>
profiles: Ref<Profile[]>
nearby: Ref<NearbyItem[]>
profileA: Ref<string>
profileB: Ref<string>
asOf: Ref<string>
report: Ref<GrowthReport | null>
ty: Ref<string>
tm: Ref<string>
td: Ref<string>
tName: Ref<string>
mainTab: Ref<SynastryMainTab>
showQuickAdd: Ref<boolean>
}
export function createSynastryPageActions(r: SynastryPageActionRefs) {
async function loadProfiles() {
try {
const res = await api.listProfiles()
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)
if (other && !r.profileB.value) r.profileB.value = other.id
} catch (e) {
r.error.value = e instanceof Error ? e.message : '加载档案失败'
}
}
async function addTemp() {
const y = Number(r.ty.value)
const m = Number(r.tm.value)
const d = Number(r.td.value)
const msg = validateBirth(y, m, d)
if (msg) {
r.error.value = msg
return
}
r.adding.value = true
r.error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const p = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: r.tName.value || 'TA',
})
await loadProfiles()
r.profileB.value = p.id
r.showQuickAdd.value = false
} catch (e) {
r.error.value = e instanceof Error ? e.message : '添加失败'
} finally {
r.adding.value = false
}
}
async function generate() {
if (!r.profileA.value || !r.profileB.value) return
r.loading.value = true
r.error.value = ''
try {
r.report.value = await api.createSynastry(r.profileA.value, r.profileB.value, r.asOf.value)
r.mainTab.value = 'compare'
track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' })
} catch (e) {
r.error.value = e instanceof Error ? e.message : '合盘失败'
} finally {
r.loading.value = false
}
}
async function createInvite() {
if (!r.profileA.value) return
r.inviting.value = true
r.error.value = ''
try {
const res = await api.createSynastryInvite(r.profileA.value)
r.invitePath.value = res.path
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
} catch (e) {
r.error.value = e instanceof Error ? e.message : '邀请失败'
} finally {
r.inviting.value = false
}
}
async function loadNearby() {
r.nearbyLoading.value = true
r.nearbyHint.value = ''
r.error.value = ''
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('当前环境不支持定位'))
return
}
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 })
})
const lat = pos.coords.latitude
const lng = pos.coords.longitude
const self = r.profiles.value.find((p) => p.relation === 'self')
if (self) {
await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng })
}
const res = await api.listSynastryNearby(lat, lng, 50)
r.nearby.value = res.items || []
r.nearbyHint.value = r.nearby.value.length
? `找到 ${r.nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。`
: '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。'
track(AnalyticsEvent.SynastryNearbyOpened, { count: r.nearby.value.length })
} catch (e) {
r.nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置'
} finally {
r.nearbyLoading.value = false
}
}
async function buyDeep() {
if (!r.report.value) return
r.paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: r.report.value.id })
await api.payMock(order_id)
r.report.value = await api.getReport(r.report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
r.error.value = e instanceof Error ? e.message : '支付失败'
} finally {
r.paying.value = false
}
}
function reset() {
r.report.value = null
}
return { loadProfiles, addTemp, generate, createInvite, loadNearby, buyDeep, reset }
}
+73 -250
View File
@@ -1,23 +1,27 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
import {
asPlanets,
ascLonFromChart,
birthLabel,
buildRelationSharePayload,
chartBlock,
chartPlanetsFromSummary,
chartTip as chartTipFromCharts,
labelList,
mainTabs,
overlayEntriesFrom,
profileInitial,
relationTypes,
resolveActiveChartKey,
sectionsFromDetail,
type SynastryMainTab,
} from '../lib/synastryChart'
import { createSynastryPageActions } from './synastryPageActions'
export type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
export const relationTypes = ['伴侣', '朋友', '家人', '其他']
export const mainTabs: { key: MainTab; label: string }[] = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks', label: '马克斯' },
{ key: 'overlay', label: '配对' },
]
export type MainTab = SynastryMainTab
export { relationTypes, mainTabs, birthLabel, profileInitial }
export function useSynastryPage() {
const route = useRoute()
@@ -51,22 +55,12 @@ 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 profileAName = computed(() => {
const p = profiles.value.find((x) => x.id === profileA.value)
return p?.display_name || ''
})
const selfInitial = computed(() => {
const name = profileAName.value || '我'
return name.slice(0, 1)
})
function profileInitial(p: Profile) {
return (p.display_name || 'TA').slice(0, 1)
}
const selfInitial = computed(() => (profileAName.value || '我').slice(0, 1))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -79,237 +73,66 @@ export function useSynastryPage() {
const loveNote = computed(() => String(summary.value.love_note || ''))
const asOfLabel = computed(() => String(summary.value.as_of || asOf.value))
function asPlanets(raw: unknown): WheelPlanet[] {
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const o = p as Record<string, unknown>
return {
key: String(o.key),
title: String(o.title),
sign: String(o.sign),
degree: String(o.degree),
house: Number(o.house),
lon: Number(o.lon),
element: o.element != null ? String(o.element) : undefined,
modality: o.modality != null ? String(o.modality) : undefined,
}
})
}
function chartPlanets(key: 'chart_a' | 'chart_b'): WheelPlanet[] {
const c = summary.value[key]
if (!c || typeof c !== 'object') return []
return asPlanets((c as { planets?: unknown }).planets)
}
const planetsA = computed(() => chartPlanets('chart_a'))
const planetsB = computed(() => chartPlanets('chart_b'))
const ascA = computed(() => {
const c = summary.value.chart_a as { asc_lon?: number } | undefined
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
})
const ascB = computed(() => {
const c = summary.value.chart_b as { asc_lon?: number } | undefined
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
})
const aspectPreview = computed(() => {
const raw = summary.value.aspects_preview
return Array.isArray(raw) ? (raw as { label: string }[]) : []
})
function chartBlock(key: string): Record<string, unknown> | null {
const c = charts.value[key]
if (!c || typeof c !== 'object') return null
return c as Record<string, unknown>
}
const planetsA = computed(() => chartPlanetsFromSummary(summary.value, 'chart_a'))
const planetsB = computed(() => chartPlanetsFromSummary(summary.value, 'chart_b'))
const ascA = computed(() => ascLonFromChart(summary.value.chart_a))
const ascB = computed(() => ascLonFromChart(summary.value.chart_b))
const aspectPreview = computed(() => labelList(summary.value.aspects_preview))
function chartTip(key: string): string {
const c = chartBlock(key)
return String(c?.tip || '')
return chartTipFromCharts(charts.value, key)
}
const activeChartKey = computed(() => {
if (mainTab.value === 'composite') {
return subTab.value === 'prog' ? 'composite_progressed' : 'composite'
}
if (mainTab.value === 'davison') {
return subTab.value === 'prog' ? 'davison_progressed' : 'davison'
}
if (mainTab.value === 'marks') {
if (subTab.value === 'prog') return 'marks_progressed'
return marksWho.value === 'other' ? 'marks_other' : 'marks_me'
}
return ''
})
const activePlanets = computed(() => {
const c = chartBlock(activeChartKey.value)
return asPlanets(c?.planets)
})
const activeChartKey = computed(() =>
resolveActiveChartKey(mainTab.value, subTab.value, marksWho.value),
)
const activePlanets = computed(() => asPlanets(chartBlock(charts.value, activeChartKey.value)?.planets))
const activeAsc = computed(() => {
const c = chartBlock(activeChartKey.value)
return typeof c?.asc_lon === 'number' ? (c.asc_lon as number) : null
})
const activeAspectPreview = computed(() => {
const c = chartBlock(activeChartKey.value)
const raw = c?.aspects_preview
return Array.isArray(raw) ? (raw as { label: string }[]) : []
const lon = chartBlock(charts.value, activeChartKey.value)?.asc_lon
return typeof lon === 'number' ? lon : null
})
const activeAspectPreview = computed(() =>
labelList(chartBlock(charts.value, activeChartKey.value)?.aspects_preview),
)
const activeChartTip = computed(() => chartTip(activeChartKey.value))
const overlayTip = computed(() => String(chartBlock(charts.value, 'overlay')?.tip || ''))
const overlayEntries = computed(() => overlayEntriesFrom(charts.value))
const fullAspects = computed(() => labelList(detail.value?.aspects))
const sections = computed(() => sectionsFromDetail(detail.value))
const sharePayload = computed(() =>
buildRelationSharePayload(
report.value,
summary.value,
headline.value,
love.value,
friend.value,
marriage.value,
),
)
const overlayTip = computed(() => String(chartBlock('overlay')?.tip || ''))
const overlayEntries = computed(() => {
const raw = chartBlock('overlay')?.entries
return Array.isArray(raw)
? (raw as { planet: string; sign: string; house: number; house_tip: string }[])
: []
})
const fullAspects = computed(() => {
const raw = detail.value?.aspects
return Array.isArray(raw) ? (raw as { label: string }[]) : []
})
const sections = computed(() => {
const raw = detail.value?.sections
return Array.isArray(raw) ? (raw as { title: string; body: string }[]) : []
})
const sharePayload = computed<RelationSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'relation',
me: String(summary.value.me_name || '我'),
other: String(summary.value.other_name || 'TA'),
diff: headline.value,
keywords: [`恋爱${love.value}`, `友情${friend.value}`, `婚姻${marriage.value}`],
}
})
function birthLabel(d?: string) {
if (!d) return ''
return String(d).slice(0, 10)
}
async function loadProfiles() {
try {
const res = await api.listProfiles()
profiles.value = res.items || []
const self = profiles.value.find((p) => p.relation === 'self')
if (self && !profileA.value) profileA.value = self.id
const other = profiles.value.find((p) => p.id !== profileA.value)
if (other && !profileB.value) profileB.value = other.id
} catch (e) {
error.value = e instanceof Error ? e.message : '加载档案失败'
}
}
async function addTemp() {
const y = Number(ty.value)
const m = Number(tm.value)
const d = Number(td.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
adding.value = true
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const p = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: tName.value || 'TA',
})
await loadProfiles()
profileB.value = p.id
showQuickAdd.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
async function generate() {
if (!profileA.value || !profileB.value) return
loading.value = true
error.value = ''
try {
report.value = await api.createSynastry(profileA.value, profileB.value, asOf.value)
mainTab.value = 'compare'
track(AnalyticsEvent.SynastryCompleted, { source: 'synastry' })
} catch (e) {
error.value = e instanceof Error ? e.message : '合盘失败'
} finally {
loading.value = false
}
}
async function createInvite() {
if (!profileA.value) return
inviting.value = true
error.value = ''
try {
const res = await api.createSynastryInvite(profileA.value)
invitePath.value = res.path
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
} catch (e) {
error.value = e instanceof Error ? e.message : '邀请失败'
} finally {
inviting.value = false
}
}
async function loadNearby() {
nearbyLoading.value = true
nearbyHint.value = ''
error.value = ''
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('当前环境不支持定位'))
return
}
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 8000 })
})
const lat = pos.coords.latitude
const lng = pos.coords.longitude
const self = profiles.value.find((p) => p.relation === 'self')
if (self) {
await api.updateProfile(self.id, { geo_lat: lat, geo_lng: lng })
}
const res = await api.listSynastryNearby(lat, lng, 50)
nearby.value = res.items || []
nearbyHint.value = nearby.value.length
? `找到 ${nearby.value.length} 位附近可合盘对象。若希望别人看到你,请在档案中开启「位置可见」。`
: '附近暂无已开启位置可见的档案;可邀请好友或手动添加。需要被看到时请在档案开启位置可见。'
track(AnalyticsEvent.SynastryNearbyOpened, { count: nearby.value.length })
} catch (e) {
nearbyHint.value = e instanceof Error ? e.message : '定位失败,可手动选城后在档案页开启位置'
} finally {
nearbyLoading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'synastry' })
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
function reset() {
report.value = null
}
const { loadProfiles, addTemp, generate, createInvite, loadNearby, buyDeep, reset } =
createSynastryPageActions({
loading,
adding,
paying,
inviting,
nearbyLoading,
error,
nearbyHint,
invitePath,
profiles,
nearby,
profileA,
profileB,
asOf,
report,
ty,
tm,
td,
tName,
mainTab,
showQuickAdd,
})
onMounted(async () => {
await loadProfiles()
+129
View File
@@ -0,0 +1,129 @@
import type { GrowthReport, Profile } from '@yuxingu/types'
import type { WheelPlanet } from '../components/NatalWheel.vue'
import type { RelationSharePayload } from './shareLink'
export type SynastryMainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
export type SynastrySubTab = 'natal' | 'prog'
export type SynastryMarksWho = 'me' | 'other'
export const relationTypes = ['伴侣', '朋友', '家人', '其他']
export const mainTabs: { key: SynastryMainTab; label: string }[] = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks', label: '马克斯' },
{ key: 'overlay', label: '配对' },
]
export function asPlanets(raw: unknown): WheelPlanet[] {
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const o = p as Record<string, unknown>
return {
key: String(o.key),
title: String(o.title),
sign: String(o.sign),
degree: String(o.degree),
house: Number(o.house),
lon: Number(o.lon),
element: o.element != null ? String(o.element) : undefined,
modality: o.modality != null ? String(o.modality) : undefined,
}
})
}
export function chartBlock(
charts: Record<string, unknown>,
key: string,
): Record<string, unknown> | null {
const c = charts[key]
if (!c || typeof c !== 'object') return null
return c as Record<string, unknown>
}
export function chartTip(charts: Record<string, unknown>, key: string): string {
return String(chartBlock(charts, key)?.tip || '')
}
export function chartPlanetsFromSummary(
summary: Record<string, unknown>,
key: 'chart_a' | 'chart_b',
): WheelPlanet[] {
const c = summary[key]
if (!c || typeof c !== 'object') return []
return asPlanets((c as { planets?: unknown }).planets)
}
export function ascLonFromChart(chart: unknown): number | null {
const c = chart as { asc_lon?: number } | undefined
return typeof c?.asc_lon === 'number' ? c.asc_lon : null
}
export function labelList(raw: unknown): { label: string }[] {
return Array.isArray(raw) ? (raw as { label: string }[]) : []
}
export function resolveActiveChartKey(
mainTab: SynastryMainTab,
subTab: SynastrySubTab,
marksWho: SynastryMarksWho,
): string {
if (mainTab === 'composite') {
return subTab === 'prog' ? 'composite_progressed' : 'composite'
}
if (mainTab === 'davison') {
return subTab === 'prog' ? 'davison_progressed' : 'davison'
}
if (mainTab === 'marks') {
if (subTab === 'prog') return 'marks_progressed'
return marksWho === 'other' ? 'marks_other' : 'marks_me'
}
return ''
}
export type OverlayEntry = {
planet: string
sign: string
house: number
house_tip: string
}
export function overlayEntriesFrom(charts: Record<string, unknown>): OverlayEntry[] {
const raw = chartBlock(charts, 'overlay')?.entries
return Array.isArray(raw) ? (raw as OverlayEntry[]) : []
}
export type SynastrySection = { title: string; body: string }
export function sectionsFromDetail(detail: Record<string, unknown> | null): SynastrySection[] {
const raw = detail?.sections
return Array.isArray(raw) ? (raw as SynastrySection[]) : []
}
export function buildRelationSharePayload(
report: GrowthReport | null,
summary: Record<string, unknown>,
headline: string,
love: number,
friend: number,
marriage: number,
): RelationSharePayload | null {
if (!report) return null
return {
type: 'relation',
me: String(summary.me_name || '我'),
other: String(summary.other_name || 'TA'),
diff: headline,
keywords: [`恋爱${love}`, `友情${friend}`, `婚姻${marriage}`],
}
}
export function birthLabel(d?: string) {
if (!d) return ''
return String(d).slice(0, 10)
}
export function profileInitial(p: Profile) {
return (p.display_name || 'TA').slice(0, 1)
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ScalePage from './ScalePage.vue'
const { api } = vi.hoisted(() => ({
api: {
getScale: vi.fn(),
listProfiles: vi.fn(),
submitScale: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('../lib/scaleDraft', () => ({
loadScaleDraft: () => null,
saveScaleDraft: vi.fn(),
clearScaleDraft: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'mbti-lite' } }),
}))
const scaleDetail = {
slug: 'mbti-lite',
title: 'MBTI 轻测',
description: '探索向',
questions: [
{
id: 'q1',
sort: 1,
body: {
prompt: '第一题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
},
},
{
id: 'q2',
sort: 2,
body: {
prompt: '第二题?',
options: [
{ key: 'a', text: '选项A' },
{ key: 'b', text: '选项B' },
],
},
},
],
}
describe('ScalePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.getScale.mockResolvedValue(scaleDetail)
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.submitScale.mockResolvedValue({
id: 'res1',
result: { label: '探索结果', share_line: '分享一句', summary: '摘要' },
})
})
it('selects answers via emit then submits on last next', async () => {
const w = mount(ScalePage, {
global: { stubs: { RouterLink: true, ShareSheet: true, HomeToolIcon: true, BackButton: true } },
})
await flushPromises()
expect(w.text()).toContain('MBTI 轻测')
await w.find('button.intro-start').trigger('click')
await flushPromises()
expect(w.text()).toContain('第一题?')
const radios = w.findAll('input[type="radio"]')
expect(radios.length).toBeGreaterThan(0)
await radios[0].setValue()
await radios[0].trigger('change')
await flushPromises()
const next = w.findAll('button').find((b) => b.text().includes('下一题'))
expect(next).toBeTruthy()
await next!.trigger('click')
await flushPromises()
expect(w.text()).toContain('第二题?')
const radios2 = w.findAll('input[type="radio"]')
await radios2[0].setValue()
await radios2[0].trigger('change')
await flushPromises()
const submitBtn = w.findAll('button').find((b) => b.text().includes('查看探索结果'))
expect(submitBtn).toBeTruthy()
await submitBtn!.trigger('click')
await flushPromises()
expect(api.submitScale).toHaveBeenCalledWith(
'mbti-lite',
'p1',
expect.objectContaining({ q1: 'a', q2: 'a' }),
)
expect(w.text()).toContain('探索结果')
})
})