feat(ECR-012): 星座对齐收口,并 Closed ECR-007/008

对齐 outlook/分享 type=star/报告页运势面板与测试;流程上关闭 Ops-B/C 两张 ECR。真支付仍后置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 15:48:19 +08:00
co-authored by Cursor
parent 7ab9add5dd
commit d33c8fdfe9
42 changed files with 658 additions and 129 deletions
@@ -88,6 +88,9 @@ func TestFlowStarDeepAccess(t *testing.T) {
if sum["fortune"] != nil {
t.Fatal("legacy fortune key must be removed (ECR-003)")
}
if raw, _ := json.Marshal(sum); strings.Contains(string(raw), `"lucky"`) {
t.Fatal("legacy lucky field must not appear in star summary (ECR-012)")
}
reportID := rep["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
+24 -1
View File
@@ -30,7 +30,27 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
id: 'rep-star',
type: 'star',
has_deep_access: false,
summary: { headline: 'E2E星象', one_liner: '星象摘要', keywords: ['灵活'] },
summary: {
headline: 'E2E星象',
one_liner: '星象摘要',
keywords: ['灵活'],
planets: [
{ key: 'sun', title: '太阳', sign: '双子', degree: '10°', house: 1, lon: 70 },
],
chart: { note: '热带黄道', asc_lon: 70, houses: [{ num: 1, sign: '双子' }] },
aspects_preview: [],
outlook: {
daily: {
title: '今日运势', label: '顺畅', score: 80, tip: 'e2e tip',
focus: '行动', boost: '水', caution: '缓', dims: { love: 3, career: 4, money: 3, mood: 4 },
},
weekly: { title: '本周', label: '稳', score: 70, tip: 'w', focus: 'f', boost: 'b', caution: 'c' },
monthly: { title: '本月', label: '稳', score: 70, tip: 'm', focus: 'f', boost: 'b', caution: 'c' },
yearly: { title: '今年', label: '稳', score: 70, tip: 'y', focus: 'f', boost: 'b', caution: 'c' },
lifetime: { title: '一生运势摘要', label: '稳', score: 70, tip: 'l', focus: 'f', boost: 'b', caution: 'c' },
},
transits: [{ key: 't1', title: '行运提示', aspect: '合相', tip: '推进' }],
},
detail: null,
created_at: new Date().toISOString(),
}))
@@ -86,6 +106,9 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
await page.goto('/psy/star?y=1983&m=6&d=6')
await expect(page.getByText('E2E星象')).toBeVisible()
await expect(page.getByRole('button', { name: /解锁完整分析/ })).toBeVisible()
await page.getByRole('button', { name: '运势' }).click()
await expect(page.getByText('今日运势')).toBeVisible()
await expect(page.getByText('焦点:行动')).toBeVisible()
await page.goto('/psy/rhythm?y=1992&m=6&d=8')
await expect(page.getByText('身心节律').first()).toBeVisible()
+10 -1
View File
@@ -20,7 +20,7 @@ import BrandLogo from './BrandLogo.vue'
withDefaults(
defineProps<{
type?: 'portrait' | 'relation'
type?: 'portrait' | 'star' | 'relation'
title?: string
line?: string
rows?: string[]
@@ -72,4 +72,13 @@ h2{font-size:17px;font-weight:600;margin:0 0 8px;line-height:1.4}
.share-card[data-type="relation"] .cta-label{
background:linear-gradient(135deg,#e0b35a,#c8923a);
}
.share-card[data-type="star"]{
background:
radial-gradient(120% 80% at 100% 0%, rgba(100,120,200,.18), transparent 55%),
linear-gradient(165deg, #f6f8ff 0%, #e8ecff 48%, #fff 100%);
}
.share-card[data-type="star"] .tags span{background:rgba(100,120,200,.12);color:#4a5a9a}
.share-card[data-type="star"] .cta-label{
background:linear-gradient(135deg,#7a8ad4,#4a5a9a);
}
</style>
+10 -4
View File
@@ -32,13 +32,13 @@ const status = ref('')
const card = computed(() => {
const p = props.payload
if (!p) return {}
if (p.type === 'portrait') {
if (p.type === 'portrait' || p.type === 'star') {
return {
type: 'portrait' as const,
type: p.type,
title: p.title,
line: p.line,
keywords: p.keywords,
ctaLabel: '查看完整成长报告',
ctaLabel: p.type === 'star' ? '查看完整星座报告' : '查看完整成长报告',
}
}
return {
@@ -56,6 +56,12 @@ const card = computed(() => {
const canNative = computed(() => typeof navigator !== 'undefined' && typeof navigator.share === 'function')
function nativeShareText(type: SharePayload['type']): string {
if (type === 'relation') return '看看我们的相处方式'
if (type === 'star') return '看看我的星座探索'
return '看看我的个人画像'
}
function absoluteShareURL(): string {
if (!props.payload) return location.origin
return `${location.origin}${buildSharePath(props.payload)}`
@@ -76,7 +82,7 @@ async function nativeShare() {
try {
await navigator.share({
title: '愈心谷',
text: props.payload.type === 'relation' ? '看看我们的相处方式' : '看看我的个人画像',
text: nativeShareText(props.payload.type),
url: absoluteShareURL(),
})
status.value = '已唤起系统分享'
@@ -17,27 +17,13 @@
<p>{{ activeCard.teaser }}</p>
</div>
</template>
<template v-else-if="panelKey === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="$emit('update:fortuneKey', k)"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="spotlight">
<p class="ft-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p>{{ activeFortune.tip }}</p>
</div>
<p v-else class="yxg-hint">暂无运势数据</p>
</template>
<StarFortunePanel
v-else-if="panelKey === 'fortune'"
:fortune-key="fortuneKey"
:fortune-keys="fortuneKeys"
:active-fortune="activeFortune"
@update:fortune-key="$emit('update:fortuneKey', $event)"
/>
<ul v-else class="planet-list">
<li v-for="p in planets" :key="p.key" class="planet-row">
<strong>{{ p.title }}</strong>
@@ -50,10 +36,10 @@
<script setup lang="ts">
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../NatalWheel.vue'
import SignCards, { type SignCard } from '../SignCards.vue'
import { fortuneLabel } from '../../composables/useStarProfilePage'
import StarFortunePanel from '../star/StarFortunePanel.vue'
import type { FortunePeriod } from '../../lib/reportSummary'
import { reportFortuneKeys } from '../../composables/useReportPage'
type FortunePeriod = { title?: string; label?: string; score?: number; tip?: string }
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
defineProps<{
@@ -88,19 +74,6 @@ const fortuneKeys = reportFortuneKeys
}
.spotlight h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
.spotlight p { font-size: 13px; color: #555; line-height: 1.6; }
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.ftab {
flex: 1;
min-width: 52px;
border: 1px solid #eee;
background: #fafafa;
border-radius: 12px;
padding: 10px 4px;
font-size: 12px;
color: #666;
}
.ftab.on { border-color: var(--color-primary); background: #fff5f4; color: #222; font-weight: 600; }
.ft-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.planet-list { list-style: none; padding: 0; margin: 0; }
.planet-row {
display: flex;
@@ -16,7 +16,7 @@
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p class="focus">焦点{{ activeFortune.focus }}</p>
<p v-if="activeFortune.focus" class="focus">焦点{{ activeFortune.focus }}</p>
<p>{{ activeFortune.tip }}</p>
<div v-if="activeFortune.dims" class="dims">
<span>感情 {{ activeFortune.dims.love }}</span>
@@ -24,26 +24,16 @@
<span>财运 {{ activeFortune.dims.money }}</span>
<span>心情 {{ activeFortune.dims.mood }}</span>
</div>
<p class="yxg-meta">今日提示{{ activeFortune.boost }}</p>
<p class="yxg-meta">注意{{ activeFortune.caution }}</p>
<p v-if="activeFortune.boost" class="yxg-meta">今日提示{{ activeFortune.boost }}</p>
<p v-if="activeFortune.caution" class="yxg-meta">注意{{ activeFortune.caution }}</p>
</div>
<p v-else class="yxg-hint">暂无运势数据</p>
</div>
</template>
<script setup lang="ts">
import { fortuneLabel } from '../../composables/useStarProfilePage'
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
boost?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
import type { FortunePeriod } from '../../lib/reportSummary'
defineProps<{
fortuneKey: string
@@ -78,4 +68,5 @@ defineEmits<{
.daily-title { font-weight: 700; color: #222; margin-bottom: 6px; }
.focus { margin: 0 0 8px; color: #666; }
.dims { display: flex; flex-wrap: wrap; gap: 10px; margin: 10px 0; font-size: 12px; color: #555; }
.yxg-hint { margin: 8px 0; font-size: 13px; color: #999; }
</style>
@@ -11,7 +11,7 @@ import {
fortuneBundleFrom,
signCardsFrom,
} from '../lib/reportSummary'
import type { PortraitSharePayload } from '../lib/shareLink'
import type { StarSharePayload } from '../lib/shareLink'
import {
aspectLinesFrom,
aspectsFrom,
@@ -80,9 +80,9 @@ export function useStarProfilePage() {
aspectLinesFrom(report.value?.has_deep_access, fullAspects.value, aspectPreview.value),
)
const gridCells = computed(() => buildGridCells(signCards.value, planets.value))
const sharePayload = computed<PortraitSharePayload | null>(() => {
const sharePayload = computed<StarSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
return { type: 'star', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function onWheelSelect(key: string) {
+24 -1
View File
@@ -135,7 +135,27 @@ export function signCardsFrom(summary: Record<string, unknown>): SignCard[] {
export function fortuneBundleFrom(summary: Record<string, unknown>): Record<string, FortunePeriod> {
const o = summary.outlook
return o && typeof o === 'object' ? (o as Record<string, FortunePeriod>) : {}
if (!o || typeof o !== 'object') return {}
const src = o as Record<string, unknown>
const out: Record<string, FortunePeriod> = {}
for (const k of reportFortuneKeys) {
const raw = src[k]
if (raw && typeof raw === 'object') out[k] = periodFromRaw(raw as Record<string, unknown>)
}
return out
}
function periodFromRaw(p: Record<string, unknown>): FortunePeriod {
return {
title: typeof p.title === 'string' ? p.title : undefined,
label: typeof p.label === 'string' ? p.label : undefined,
score: typeof p.score === 'number' ? p.score : undefined,
tip: typeof p.tip === 'string' ? p.tip : undefined,
focus: typeof p.focus === 'string' ? p.focus : undefined,
boost: typeof p.boost === 'string' ? p.boost : undefined,
caution: typeof p.caution === 'string' ? p.caution : undefined,
dims: p.dims && typeof p.dims === 'object' ? (p.dims as FortunePeriod['dims']) : undefined,
}
}
export function planetsFrom(summary: Record<string, unknown>): PlanetRow[] {
@@ -172,6 +192,9 @@ export function buildReportSharePayload(
keywords,
} as RelationSharePayload
}
if (report.type === 'star') {
return { type: 'star', title: headline, line: oneLiner, keywords }
}
return {
type: 'portrait',
title: headline,
+17
View File
@@ -39,8 +39,25 @@ describe('shareLink', () => {
})
})
it('round-trips star payload', () => {
const path = buildSharePath({
type: 'star',
title: '稳健沉淀者',
line: '星座一句',
keywords: ['金牛'],
})
const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?'))))
expect(parseShareQuery(q)).toEqual({
type: 'star',
title: '稳健沉淀者',
line: '星座一句',
keywords: ['金牛'],
})
})
it('returns null for empty/invalid', () => {
expect(parseShareQuery({})).toBeNull()
expect(parseShareQuery({ type: 'portrait' })).toBeNull()
expect(parseShareQuery({ type: 'star' })).toBeNull()
})
})
+22 -7
View File
@@ -7,6 +7,14 @@ export type PortraitSharePayload = {
keywords: string[]
}
/** Same card shape as portrait; type discriminates 星座 landing copy. */
export type StarSharePayload = {
type: 'star'
title: string
line: string
keywords: string[]
}
export type RelationSharePayload = {
type: 'relation'
me: string
@@ -15,15 +23,22 @@ export type RelationSharePayload = {
keywords: string[]
}
export type SharePayload = PortraitSharePayload | RelationSharePayload
export type SharePayload = PortraitSharePayload | StarSharePayload | RelationSharePayload
function appendTitleLineKeywords(
q: URLSearchParams,
payload: PortraitSharePayload | StarSharePayload,
) {
if (payload.title) q.set('t', payload.title)
if (payload.line) q.set('l', payload.line)
if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(','))
}
export function buildSharePath(payload: SharePayload): string {
const q = new URLSearchParams()
q.set('type', payload.type)
if (payload.type === 'portrait') {
if (payload.title) q.set('t', payload.title)
if (payload.line) q.set('l', payload.line)
if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(','))
if (payload.type === 'portrait' || payload.type === 'star') {
appendTitleLineKeywords(q, payload)
} else {
if (payload.me) q.set('me', payload.me)
if (payload.other) q.set('ot', payload.other)
@@ -39,11 +54,11 @@ export function parseShareQuery(query: Record<string, unknown>): SharePayload |
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (type === 'portrait') {
if (type === 'portrait' || type === 'star') {
const title = String(query.t || '')
const line = String(query.l || '')
if (!title && !line && !keywords.length) return null
return { type: 'portrait', title, line, keywords }
return { type: type as 'portrait' | 'star', title, line, keywords }
}
if (type === 'relation') {
const me = String(query.me || '')
+29 -10
View File
@@ -20,7 +20,7 @@
<template v-else>
<div class="hero-block">
<HomeToolIcon :name="payload.type === 'relation' ? 'relation' : 'portrait'" :size="64" />
<HomeToolIcon :name="heroIcon" :size="64" />
<h1 class="hero-title">{{ shareTitle }}</h1>
<p class="hero-sub">{{ shareSub }}</p>
</div>
@@ -52,24 +52,35 @@ const payload = computed(() => parseShareQuery(route.query as Record<string, unk
const shareTitle = computed(() => {
const p = payload.value
if (!p) return '分享'
return p.type === 'relation' ? '关系理解分享' : '个人画像分享'
if (p.type === 'relation') return '关系理解分享'
if (p.type === 'star') return '星座分享'
return '个人画像分享'
})
const shareSub = computed(() => {
const p = payload.value
if (!p) return ''
return p.type === 'relation' ? '朋友分享了一段关系理解' : '朋友分享了一份个人画像'
if (p.type === 'relation') return '朋友分享了一段关系理解'
if (p.type === 'star') return '朋友分享了一份星座探索'
return '朋友分享了一份个人画像'
})
const heroIcon = computed(() => {
const t = payload.value?.type
if (t === 'relation') return 'relation'
if (t === 'star') return 'star'
return 'portrait'
})
const cardProps = computed(() => {
const p = payload.value
if (!p) return {}
if (p.type === 'portrait') {
if (p.type === 'portrait' || p.type === 'star') {
return {
type: 'portrait' as const,
type: p.type,
title: p.title,
line: p.line,
keywords: p.keywords,
ctaLabel: '查看完整成长报告',
ctaLabel: p.type === 'star' ? '查看完整星座报告' : '查看完整成长报告',
}
}
return {
@@ -81,10 +92,18 @@ const cardProps = computed(() => {
}
})
const ctaTo = computed(() => (payload.value?.type === 'relation' ? '/relation' : '/'))
const ctaText = computed(() =>
payload.value?.type === 'relation' ? '开始关系理解' : '生成我的个人画像',
)
const ctaTo = computed(() => {
const t = payload.value?.type
if (t === 'relation') return '/relation'
if (t === 'star') return '/star'
return '/'
})
const ctaText = computed(() => {
const t = payload.value?.type
if (t === 'relation') return '开始关系理解'
if (t === 'star') return '生成我的星座'
return '生成我的个人画像'
})
</script>
<style scoped>
+19 -9
View File
@@ -4,8 +4,11 @@ import StarProfilePage from './StarProfilePage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
createStar: vi.fn(),
getLatestReport: vi.fn(),
getReport: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
@@ -15,8 +18,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: routeQuery, fullPath: '/star?y=1990&m=5&d=12' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn(), push: vi.fn() }),
}))
const summary = {
@@ -39,22 +42,29 @@ const summary = {
keywords: ['金牛'],
}
const starReport = {
id: 'r1',
type: 'star',
has_deep_access: false,
summary,
detail: null,
}
describe('StarProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createStar.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary,
detail: null,
})
api.getLatestReport.mockResolvedValue(starReport)
api.createStar.mockResolvedValue(starReport)
})
it('shows natal wheel and free chart content', async () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(api.createStar).toHaveBeenCalled()
expect(api.createProfile).toHaveBeenCalled()
expect(api.getLatestReport).toHaveBeenCalledWith('p1', 'star')
await w.findAll('button').find((b) => b.text() === '星盘')!.trigger('click')
await flushPromises()
expect(w.find('svg.wheel').exists()).toBe(true)