refactor(ECR-004): Synastry 再拆、Scale 测、Membership 守卫、OpenAPI 与 CI
压合盘贴线文件;补选答单测与 nil 守卫;对齐关键 OpenAPI schemas;加 GitHub Actions 门禁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
h5:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:h5
|
||||
- run: npm run test:h5
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: apps/user-h5
|
||||
run: npx playwright install --with-deps chromium
|
||||
- run: npm run test:e2e -w @yuxingu/user-h5
|
||||
env:
|
||||
CI: true
|
||||
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
- name: Unit tests (no integration DB)
|
||||
working-directory: apps/api
|
||||
run: go test $(go list ./... | grep -v /internal/integration) -count=1
|
||||
|
||||
ess-docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ESS artifact presence (ECR-004)
|
||||
run: |
|
||||
test -f docs/ECR/ECR-004-hygiene-followup.md
|
||||
test -f docs/TASKS/TASK-20260805-ECR004.yaml
|
||||
test -f docs/TEST_REPORT/ECR-004.md
|
||||
test -f docs/STATE/ECR-004.md
|
||||
echo "ESS docs gate OK"
|
||||
- name: ess-validate when ESS_ROOT available
|
||||
env:
|
||||
ESS_ROOT: ${{ vars.ESS_ROOT }}
|
||||
run: |
|
||||
if [ -n "$ESS_ROOT" ] && [ -f "$ESS_ROOT/scripts/ess-validate.py" ]; then
|
||||
python scripts/ess-validate.py --phase coding --ecr ECR-004 --task docs/TASKS/TASK-20260805-ECR004.yaml
|
||||
else
|
||||
echo "ESS_ROOT not configured in CI — skipped full ess-validate"
|
||||
fi
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,238 +73,67 @@ 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 { 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,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfiles()
|
||||
if (route.query.invite === '1') {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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('探索结果')
|
||||
})
|
||||
})
|
||||
@@ -12,3 +12,4 @@
|
||||
- **ECR-001 Phase F + H5**:types/sdk 对齐 OpenAPI;拆分 8 个超标页(均 ≤400);test:h5 35/35 绿
|
||||
- QA:修 Playwright `/psy/` 路径与过时断言;e2e 2/2 绿(见 TEST_REPORT/ECR-001-phaseF-qa.md)
|
||||
- **ECR-001 Closed**;**ECR-002 Closed**;**ECR-003**:删 JSON `fortune` 双写,`lucky`→`boost`,H5 改读 `outlook`
|
||||
- **ECR-004**:Synastry 再拆 · Scale vitest · Membership nil 守卫 · OpenAPI schemas · GitHub CI
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# CODE_REVIEW — ECR-004
|
||||
|
||||
**Decision: PASS / Approve**
|
||||
|
||||
## Blockers
|
||||
无
|
||||
|
||||
## Nits
|
||||
- OpenAPI 仍有大量 path 仅 `description: OK`(关键 schema 已补;可后续全挂 `$ref`)
|
||||
- CI `ess-validate` 依赖仓库变量 `ESS_ROOT`;未配置时仅跑文档存在性门禁
|
||||
|
||||
## Gate
|
||||
PASS
|
||||
@@ -0,0 +1,35 @@
|
||||
# ECR-004
|
||||
|
||||
**Title:** 结构卫生跟进(Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI)
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-05
|
||||
**Change Level:** L2(结构 / 契约文档 / 工程门禁;无产品扩行为)
|
||||
|
||||
## Change
|
||||
|
||||
1. 拆分贴线合盘:`useSynastryPage` / `SynastryLanding`
|
||||
2. Scale 选答交互 vitest
|
||||
3. `ReportHandler` Membership nil 守卫
|
||||
4. OpenAPI 补关键 schemas(对齐 `packages/types`)并挂关键 path `$ref`
|
||||
5. GitHub Actions CI:build/test H5 · API unit · ESS 文档门禁
|
||||
|
||||
## Forbidden
|
||||
|
||||
- 改公共 URL / 计分算法 / 产品文案改版
|
||||
- Docker-only 本地工作流
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] SynastryLanding / useSynastryPage ≤300 行
|
||||
- [x] ScalePage.spec 覆盖选答→提交
|
||||
- [x] Membership nil → 500 信封而非 panic
|
||||
- [x] OpenAPI 含 Profile / GrowthReport / MembershipMe / Scale* / AskQuota 等
|
||||
- [x] `.github/workflows/ci.yml` 存在
|
||||
- [x] `test:h5` · star/handler 相关 go test 绿
|
||||
|
||||
**Status:** coding/review PASS · awaiting Human commit
|
||||
|
||||
## Linked
|
||||
|
||||
- ENGINEERING_SPEC / TASK / STATE / TEST_REPORT under ECR-004
|
||||
- Successor of ECR-001 residual nits
|
||||
@@ -0,0 +1,11 @@
|
||||
# ENGINEERING_SPEC — ECR-004 Hygiene Follow-up
|
||||
|
||||
Move/extract only. See ECR-004.
|
||||
|
||||
## Steps done in coding
|
||||
|
||||
1. Synastry: `lib/synastryChart.ts` · `synastryPageActions.ts` · Landing 子组件
|
||||
2. `ScalePage.spec.ts`
|
||||
3. `handler/report.go` `requireMembership`
|
||||
4. `proto/openapi.yaml` schemas + key `$ref`
|
||||
5. `.github/workflows/ci.yml`
|
||||
@@ -45,9 +45,9 @@
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: **ECR-003** outlook JSON hygiene(Approved · coding);ECR-001/002 **Closed**
|
||||
- ECR: **ECR-004** hygiene follow-up(Approved);ECR-001/002/003 Closed / shipped
|
||||
- EXP: (无)
|
||||
- STATE: `docs/STATE/ECR-003.md`
|
||||
- STATE: `docs/STATE/ECR-004.md`
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# STATE — ECR-004
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| ECR | ECR-004 |
|
||||
| Title | Hygiene follow-up |
|
||||
| Phase | review PASS · awaiting commit |
|
||||
| Owner | human |
|
||||
| Updated | 2026-08-05 |
|
||||
@@ -0,0 +1,28 @@
|
||||
# docs/TASKS/TASK-20260805-ECR004.yaml
|
||||
task_id: ECR-004-hygiene
|
||||
role: engineer
|
||||
phase: coding
|
||||
tool: cursor
|
||||
status: coded
|
||||
|
||||
input:
|
||||
- docs/ECR/ECR-004-hygiene-followup.md
|
||||
- .ai/coding.md
|
||||
|
||||
output:
|
||||
- apps/user-h5/src/components/synastry/**
|
||||
- apps/user-h5/src/composables/useSynastryPage.ts
|
||||
- apps/user-h5/src/pages/ScalePage.spec.ts
|
||||
- apps/api/internal/handler/report.go
|
||||
- proto/openapi.yaml
|
||||
- .github/workflows/ci.yml
|
||||
- docs/TEST_REPORT/ECR-004.md
|
||||
|
||||
forbidden:
|
||||
- redesign_scope
|
||||
- api_url_change
|
||||
- behavior_change
|
||||
|
||||
next_agent: reviewer
|
||||
notes: >
|
||||
Synastry split, Scale vitest, membership nil guard, OpenAPI schemas, CI. Tests green.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Test Report — ECR-004
|
||||
|
||||
## Cases
|
||||
- [x] SynastryLanding 165 · useSynastryPage 213(≤300)
|
||||
- [x] ScalePage.spec 选答→提交 PASS
|
||||
- [x] SynastryPage.spec PASS
|
||||
- [x] Membership `requireMembership` nil 守卫
|
||||
- [x] OpenAPI schemas + key path `$ref`
|
||||
- [x] CI workflow `.github/workflows/ci.yml`
|
||||
- [x] `npm run test:h5` · `go test ./internal/handler/... ./internal/star/...` · `build:h5`
|
||||
|
||||
## Gate
|
||||
coding PASS · awaiting review/commit
|
||||
@@ -9,3 +9,4 @@
|
||||
| ECR-001-F/H5 | types/sdk + 8 超标页拆分 | Closed | TEST_REPORT · CODE_REVIEW/ECR-001-phaseF-h5.md |
|
||||
| ECR-002 | fortune→outlook package | **Closed** | `docs/ECR/ECR-002-fortune-hygiene.md` |
|
||||
| ECR-003 | outlook JSON hygiene(删 fortune / lucky→boost) | review PASS | TEST_REPORT · CODE_REVIEW/ECR-003.md |
|
||||
| ECR-004 | Synastry 再拆 · Scale 测 · Membership 守卫 · OpenAPI · CI | review PASS | TEST_REPORT · CODE_REVIEW/ECR-004.md |
|
||||
|
||||
+191
-1
@@ -50,7 +50,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Envelope'
|
||||
$ref: '#/components/schemas/EnvelopeProfileList'
|
||||
post:
|
||||
tags: [profile]
|
||||
summary: 创建个人档案
|
||||
@@ -372,6 +372,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeScaleList'
|
||||
|
||||
/api/v1/scales/{slug}:
|
||||
get:
|
||||
@@ -416,6 +420,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeAskQuota'
|
||||
|
||||
/api/v1/ask/threads:
|
||||
post:
|
||||
@@ -505,6 +513,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EnvelopeMembershipMe'
|
||||
|
||||
/api/v1/orders:
|
||||
post:
|
||||
@@ -566,3 +578,181 @@ components:
|
||||
kind: { type: string, enum: [membership, deep_access] }
|
||||
plan: { type: string, description: month|quarter|year when membership }
|
||||
report_id: { type: string, format: uuid, description: when deep_access }
|
||||
|
||||
# --- Aligned with packages/types (ECR-004) ---
|
||||
Profile:
|
||||
type: object
|
||||
required: [id, user_id, relation, display_name, birth_date, created_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
relation: { type: string, enum: [self, other] }
|
||||
display_name: { type: string }
|
||||
birth_date: { type: string, format: date }
|
||||
birth_time: { type: string, nullable: true }
|
||||
birth_place: { type: string, nullable: true }
|
||||
relation_type: { type: string, nullable: true }
|
||||
geo_lat: { type: number, nullable: true }
|
||||
geo_lng: { type: number, nullable: true }
|
||||
geo_visible: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
GrowthReport:
|
||||
type: object
|
||||
required: [id, user_id, profile_id, type, summary, has_deep_access, created_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
profile_id: { type: string, format: uuid }
|
||||
type: { type: string }
|
||||
summary: { type: object, additionalProperties: true }
|
||||
detail: { type: object, nullable: true, additionalProperties: true }
|
||||
has_deep_access: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
MembershipMe:
|
||||
type: object
|
||||
required: [active, status]
|
||||
properties:
|
||||
active: { type: boolean }
|
||||
plan: { type: string }
|
||||
status: { type: string }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
ask_quota_left: { type: integer }
|
||||
|
||||
ScaleSummary:
|
||||
type: object
|
||||
required: [slug, title, description]
|
||||
properties:
|
||||
slug: { type: string }
|
||||
title: { type: string }
|
||||
description: { type: string }
|
||||
|
||||
ScaleQuestionOption:
|
||||
type: object
|
||||
required: [key, text]
|
||||
properties:
|
||||
key: { type: string }
|
||||
text: { type: string }
|
||||
|
||||
ScaleQuestion:
|
||||
type: object
|
||||
required: [id, sort, body]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
sort: { type: integer }
|
||||
body:
|
||||
type: object
|
||||
required: [prompt, options]
|
||||
properties:
|
||||
prompt: { type: string }
|
||||
options:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ScaleQuestionOption' }
|
||||
|
||||
ScaleDetail:
|
||||
type: object
|
||||
required: [slug, title, description, questions]
|
||||
properties:
|
||||
slug: { type: string }
|
||||
title: { type: string }
|
||||
description: { type: string }
|
||||
questions:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ScaleQuestion' }
|
||||
|
||||
ScaleSubmitResult:
|
||||
type: object
|
||||
required: [id, result]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
result: { type: object, additionalProperties: true }
|
||||
|
||||
AskQuota:
|
||||
type: object
|
||||
required: [active_membership, remaining, free_limit, source]
|
||||
properties:
|
||||
active_membership: { type: boolean }
|
||||
remaining: { type: integer }
|
||||
free_limit: { type: integer }
|
||||
source: { type: string }
|
||||
|
||||
AskThread:
|
||||
type: object
|
||||
required: [id, user_id, profile_id, created_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
profile_id: { type: string, format: uuid }
|
||||
scene: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
AskMessage:
|
||||
type: object
|
||||
required: [id, thread_id, role, content, created_at]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
thread_id: { type: string, format: uuid }
|
||||
role: { type: string, enum: [user, assistant] }
|
||||
content: { type: string }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
ImageCardScene:
|
||||
type: object
|
||||
required: [key, label]
|
||||
properties:
|
||||
key: { type: string }
|
||||
label: { type: string }
|
||||
|
||||
ImageCardQuota:
|
||||
type: object
|
||||
required: [remaining, daily_free, unlimited]
|
||||
properties:
|
||||
remaining: { type: integer }
|
||||
daily_free: { type: integer }
|
||||
unlimited: { type: boolean }
|
||||
|
||||
EnvelopeProfileList:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Profile' }
|
||||
|
||||
EnvelopeGrowthReport:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/GrowthReport' }
|
||||
|
||||
EnvelopeMembershipMe:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/MembershipMe' }
|
||||
|
||||
EnvelopeScaleList:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/ScaleSummary' }
|
||||
|
||||
EnvelopeAskQuota:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Envelope'
|
||||
- type: object
|
||||
properties:
|
||||
data: { $ref: '#/components/schemas/AskQuota' }
|
||||
|
||||
Reference in New Issue
Block a user