feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具

落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+6
View File
@@ -0,0 +1,6 @@
# GA4 Measurement ID(留空则埋点 no-op,应用仍可用)
# 静态站历史示例:G-LVVXH3TL04
VITE_GA_MEASUREMENT_ID=
# 开发时在控制台打印 [analytics] 日志:设为 1
VITE_ANALYTICS_DEBUG=0
+106
View File
@@ -0,0 +1,106 @@
import { test, expect } from '@playwright/test'
function ok(data: unknown) {
return {
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'success', data }),
headers: { 'X-Device-Key': 'e2e-device' },
}
}
test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
await page.route('**/api/v1/**', async (route) => {
const url = route.request().url()
const method = route.request().method()
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill(ok({
id: 'prof-e2e',
user_id: 'u1',
relation: 'self',
display_name: '我',
birth_date: '1983-06-06',
created_at: new Date().toISOString(),
}))
return
}
if (url.includes('/reports/star') && method === 'POST') {
await route.fulfill(ok({
id: 'rep-star',
type: 'star',
has_deep_access: false,
summary: { headline: 'E2E星象', one_liner: '星象摘要', keywords: ['灵活'] },
detail: null,
created_at: new Date().toISOString(),
}))
return
}
if (url.includes('/reports/rhythm') && method === 'POST') {
await route.fulfill(ok({
id: 'rep-rhythm',
type: 'rhythm',
has_deep_access: false,
summary: { headline: 'E2E节律', one_liner: '节律摘要' },
detail: null,
created_at: new Date().toISOString(),
}))
return
}
if (url.includes('/image-cards/scenes')) {
await route.fulfill(ok({ items: [{ key: '情绪整理', label: '情绪整理' }] }))
return
}
if (url.includes('/image-cards/quota')) {
await route.fulfill(ok({ remaining: 3, daily_free: 3, unlimited: false }))
return
}
if (url.includes('/image-cards/draw') && method === 'POST') {
await route.fulfill(ok({
scene: '情绪整理',
quota_left: 2,
cards: [{
id: 'c01', title: 'E2E卡片', imagery: '意象', prompt: '反思?', tip: '一小步',
}],
report: {
id: 'rep-card',
type: 'image_card',
has_deep_access: false,
summary: { headline: '意象探索' },
detail: null,
},
}))
return
}
if (url.includes('/solar-terms/today')) {
await route.fulfill(ok({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' }))
return
}
if (url.includes('/moods/today')) {
await route.fulfill(ok({ mood: null }))
return
}
await route.fulfill(ok({}))
})
await page.goto('/star?y=1983&m=6&d=6')
await expect(page.getByRole('heading', { name: '星象性格' })).toBeVisible()
await expect(page.getByText('E2E星象')).toBeVisible()
await expect(page.getByRole('button', { name: /深度版/ })).toBeVisible()
await page.goto('/rhythm?y=1992&m=6&d=8')
await expect(page.getByRole('heading', { name: '身心节律' })).toBeVisible()
await expect(page.getByText('E2E节律')).toBeVisible()
await page.goto('/cards')
await expect(page.getByRole('heading', { name: '意象卡片' })).toBeVisible()
await page.locator('input[type="number"]').nth(0).fill('1990')
await page.locator('input[type="number"]').nth(1).fill('5')
await page.locator('input[type="number"]').nth(2).fill('12')
await page.getByRole('button', { name: '抽取卡片' }).click()
await expect(page.getByText('E2E卡片')).toBeVisible()
await page.goto('/companion')
await expect(page.getByText('立秋')).toBeVisible()
await expect(page.getByText('今日心情')).toBeVisible()
})
@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test'
/** L3: Home → Portrait path with mocked API (membership lexicon + deep gate). */
test('home birth form opens portrait with summary and deep CTA', async ({ page }) => {
await page.route('**/api/v1/**', async (route) => {
const req = route.request()
const url = req.url()
const method = req.method()
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'success',
data: {
id: 'prof-e2e',
user_id: 'u1',
relation: 'self',
display_name: '我',
birth_date: '1990-05-12',
created_at: new Date().toISOString(),
},
}),
headers: { 'X-Device-Key': 'e2e-device' },
})
return
}
if (url.includes('/reports/portrait') && method === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'success',
data: {
id: 'rep-e2e',
has_deep_access: false,
type: 'portrait',
summary: {
headline: 'E2E画像',
one_liner: '模拟摘要',
life_tip: '好好睡觉',
keywords: ['稳'],
},
detail: null,
created_at: new Date().toISOString(),
},
}),
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'success', data: {} }),
})
})
await page.goto('/')
await expect(page.getByRole('img', { name: '愈心谷' }).first()).toBeVisible()
await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible()
await page.goto('/portrait?y=1990&m=5&d=12')
await expect(page.getByRole('img', { name: '愈心谷' }).first()).toBeVisible()
await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible()
await expect(page.getByRole('heading', { name: '个人画像' })).toBeVisible()
await expect(page.getByText('E2E画像')).toBeVisible()
await expect(page.getByText('模拟摘要')).toBeVisible()
await expect(page.getByRole('button', { name: /深度版/ })).toBeVisible()
})
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no,viewport-fit=cover" />
<title>愈心谷</title>
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
+9 -1
View File
@@ -7,21 +7,29 @@
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@yuxingu/sdk": "0.1.0",
"@yuxingu/types": "0.1.0",
"@yuxingu/ui": "0.1.0",
"@yuxingu/utils": "0.1.0",
"element-china-area-data": "^6.1.0",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vitest": "^3.0.5",
"vue-tsc": "^2.2.0"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test'
/**
* L3 smoke: Vite preview + network-mocked API (no Go required).
* Run: npm run test:e2e -w @yuxingu/user-h5
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Pixel 7'],
// Optional: PW_CHANNEL=chrome 改用系统 Chrome
...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}),
},
},
],
webServer: {
// Always rebuild so new routes/pages are in dist (reuseExistingServer can hide stale builds).
command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: false,
timeout: 120_000,
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+13 -3
View File
@@ -1,15 +1,25 @@
<template>
<div class="app-shell">
<div class="app-shell" :class="{ 'is-home': isHome }">
<AppHeader v-if="!isHome" />
<router-view />
<TabBar v-if="showTab" />
<TabBar />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from './components/AppHeader.vue'
import TabBar from './components/TabBar.vue'
const route = useRoute()
const showTab = computed(() => route.meta.tab !== false)
/** 首页自带测测式顶栏,避免与 sticky Logo 叠两层品牌 */
const isHome = computed(() => route.path === '/')
</script>
<style scoped>
.app-shell.is-home{
/* 首页自带完整暖色底,避免再叠一层全局洗色 */
background:transparent;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<header class="app-header">
<router-link to="/" class="logo-link" aria-label="愈心谷首页">
<BrandLogo size="header" />
</router-link>
</header>
</template>
<script setup lang="ts">
import BrandLogo from './BrandLogo.vue'
</script>
<style scoped>
.app-header{
position:sticky;top:0;z-index:50;
display:flex;justify-content:center;align-items:center;
padding:10px 16px 6px;
background:linear-gradient(180deg, rgba(255,209,199,.92) 0%, rgba(255,209,199,.55) 70%, transparent 100%);
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
}
.logo-link{display:flex;justify-content:center;line-height:0;background:transparent}
.logo-link :deep(.header),
.logo-link :deep(.brand-logo){
width:min(120px,42vw);
background:transparent !important;
mix-blend-mode:normal;
}
</style>
@@ -0,0 +1,35 @@
<template>
<button class="back" type="button" @click="go">
<span class="icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="label">返回</span>
</button>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function go() {
if (window.history.length > 1) router.back()
else router.push('/')
}
</script>
<style scoped>
.back{
display:inline-flex;align-items:center;gap:4px;
border:none;background:rgba(255,255,255,.78);color:#333;
border-radius:var(--radius-sm);
padding:8px 12px 8px 10px;margin:0 0 12px;font-size:14px;font-weight:500;
box-shadow:0 1px 4px rgba(0,0,0,.05);cursor:pointer;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
}
.back:active{opacity:.75}
.icon{display:inline-flex;line-height:0;color:#333}
.label{color:#333}
</style>
@@ -0,0 +1,75 @@
<template>
<div class="bd" :class="{ compact }">
<input
class="bd-y"
type="number"
inputmode="numeric"
placeholder="1990"
:value="year"
@input="emit('update:year', str($event))"
/>
<span class="bd-sep"></span>
<input
class="bd-md"
type="number"
inputmode="numeric"
placeholder="1"
:value="month"
@input="emit('update:month', str($event))"
/>
<span class="bd-sep"></span>
<input
class="bd-md"
type="number"
inputmode="numeric"
placeholder="1"
:value="day"
@input="emit('update:day', str($event))"
/>
<span class="bd-sep"></span>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
year?: string | number
month?: string | number
day?: string | number
/** tighter spacing inside cards */
compact?: boolean
}>(),
{ year: '', month: '', day: '', compact: false },
)
const emit = defineEmits<{
'update:year': [string]
'update:month': [string]
'update:day': [string]
}>()
function str(e: Event) {
return (e.target as HTMLInputElement).value
}
</script>
<style scoped>
.bd{
display:flex;gap:6px;align-items:center;justify-content:center;flex-wrap:wrap;
}
.bd input{
padding:11px 6px;border:1.5px solid #eee;border-radius:12px;
font-size:15px;text-align:center;outline:none;background:#fdfaf8;
-moz-appearance:textfield;
}
.bd input::-webkit-outer-spin-button,
.bd input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.bd input:focus{border-color:#f0b8b0;background:#fff}
.bd-y{width:72px}
.bd-md{width:52px}
.bd-sep{font-size:12px;color:#bbb;flex-shrink:0}
.bd.compact{justify-content:flex-start}
.bd.compact input{padding:10px 4px;font-size:14px}
.bd.compact .bd-y{width:64px}
.bd.compact .bd-md{width:48px}
</style>
@@ -0,0 +1,90 @@
<template>
<div class="bp">
<select class="yxg-select" :value="province" @change="onProvince">
<option value=""></option>
<option v-for="p in provinces" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<select class="yxg-select" :value="city" :disabled="!province" @change="onCity">
<option value=""></option>
<option v-for="c in cities" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
<select class="yxg-select" :value="district" :disabled="!city" @change="onDistrict">
<option value="">/</option>
<option v-for="d in districts" :key="d.value" :value="d.value">{{ d.label }}</option>
</select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { pcaTextArr } from 'element-china-area-data'
type Node = { label: string; value: string; children?: Node[] }
const props = defineProps<{ modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const tree = pcaTextArr as Node[]
const province = ref('')
const city = ref('')
const district = ref('')
const provinces = computed(() => tree)
const cities = computed(() => {
const p = tree.find((x) => x.value === province.value)
return p?.children || []
})
const districts = computed(() => {
const c = cities.value.find((x) => x.value === city.value)
return c?.children || []
})
function parse(v?: string) {
const parts = (v || '').split(/[/\s]+/).filter(Boolean)
return { p: parts[0] || '', c: parts[1] || '', d: parts[2] || '' }
}
function syncFromModel(v?: string) {
const { p, c, d } = parse(v)
province.value = p
city.value = c
district.value = d
}
watch(
() => props.modelValue,
(v) => {
const cur = [province.value, city.value, district.value].filter(Boolean).join(' ')
if ((v || '') !== cur) syncFromModel(v)
},
{ immediate: true },
)
function emitValue() {
const parts = [province.value, city.value, district.value].filter(Boolean)
emit('update:modelValue', parts.join(' '))
}
function onProvince(e: Event) {
province.value = (e.target as HTMLSelectElement).value
city.value = ''
district.value = ''
emitValue()
}
function onCity(e: Event) {
city.value = (e.target as HTMLSelectElement).value
district.value = ''
emitValue()
}
function onDistrict(e: Event) {
district.value = (e.target as HTMLSelectElement).value
emitValue()
}
</script>
<style scoped>
.bp{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px}
.yxg-select{width:100%;margin:0}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<img
class="brand-logo"
:class="size"
src="/logo.png?v=3"
alt="愈心谷"
decoding="async"
/>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
/** hero | header | card */
size?: 'hero' | 'header' | 'card'
}>(),
{ size: 'header' },
)
</script>
<style scoped>
.brand-logo{
display:block;object-fit:contain;object-position:center;
user-select:none;-webkit-user-drag:none;
background:transparent;
}
.hero{width:min(220px,72vw);height:auto;margin:0 auto}
.header{width:min(168px,56vw);height:auto;max-height:40px}
.card{width:min(160px,70%);height:auto}
</style>
+364
View File
@@ -0,0 +1,364 @@
<template>
<div class="decode">
<p v-if="!hasDecodeShape && oneLiner" class="legacy-note">
{{ personTitle ? `${personTitle} · ` : '' }}{{ oneLiner }}
</p>
<!-- 数字性格 -->
<section class="card">
<span class="tag tag-num">数字性格密码</span>
<div class="hex">{{ mainNumber }}</div>
<div class="p-title">
<template v-if="mainNumber !== '—'">{{ mainNumber }}号人 · </template>{{ personTitle || '愈心解码' }}
</div>
<div v-if="oneLiner" class="p-oneline">{{ oneLiner }}</div>
<div v-if="hasPyramid" class="pyr-wrap">
<NumberPyramid :pyramid="pyramid" />
</div>
<PayLock
:unlocked="deep"
:paying="paying"
title="解锁你的完整密码"
desc="天赋优势 · 人生课题 · 适合方向 · 联合码深度解读"
@unlock="$emit('unlock')"
>
<template v-if="deep && numberDeep.talent">
<p><b>天赋优势</b>{{ numberDeep.talent }}</p>
<p><b>人生课题</b>{{ numberDeep.topic }}</p>
<p><b>适合方向</b>{{ numberDeep.direction }}</p>
<p><b>能量解读</b>{{ numberDeep.desc }}</p>
</template>
<p v-else>{{ lockTeaserNumber }}</p>
<template v-if="deep && missing.length">
<p class="sub-h">缺失数字</p>
<p v-for="m in missing" :key="'m' + m.n">· {{ m.n }}{{ m.msg }}</p>
</template>
<template v-if="deep && joints.length">
<p class="sub-h">联合码分组</p>
<div v-for="(g, i) in joints" :key="'j' + i" class="joint">
<strong>{{ g.label }}</strong>
<p class="muted">{{ g.desc }}</p>
<p v-for="(c, j) in g.codes || []" :key="'c' + j" class="muted">
{{ c.code }} · {{ c.label }}
</p>
</div>
</template>
</PayLock>
</section>
<!-- 五行体质 -->
<section class="card">
<span class="tag tag-ys">五行体质画像</span>
<div class="ys-type">{{ wuxing.primary || '—' }}</div>
<div class="ys-dir">核心养护{{ wuxing.care_dir || careDir }}</div>
<WuxingBars v-if="bars.length" :bars="bars" />
<div v-if="emotionText" class="p-oneline">情绪画像{{ emotionText }}</div>
<PayLock
:unlocked="deep"
:paying="paying"
title="解锁五行体质深度分析"
desc="脏腑画像 · 易感倾向 · 食疗 · 运动作息方案"
@unlock="$emit('unlock')"
>
<template v-if="deep && constitutionDeep.body">
<p><b>体质特征</b>{{ constitutionDeep.body }}</p>
<p><b>调养要点</b>{{ constitutionDeep.tips }}</p>
<p><b>易感倾向</b>{{ constitutionDeep.risk }}</p>
<p><b>季节风险</b>{{ constitutionDeep.season_risk }}</p>
<p><b>食疗方向</b>{{ constitutionDeep.food }}</p>
<p><b>作息节律</b>{{ constitutionDeep.rhythm }}</p>
</template>
<p v-else>{{ lockTeaserWuxing }}</p>
</PayLock>
<p class="disc-mini">{{ constitutionDisclaimer }}</p>
</section>
<!-- 今日锦囊始终免费 -->
<section class="card jn">
<span class="tag tag-ys">今日养生锦囊</span>
<div class="st">
当前节气{{ todayTip.solar_term || '—' }}
<template v-if="todayTip.season_el"> · 时令 {{ todayTip.season_el }} 当令</template>
</div>
<h4>今日调养建议</h4>
<div class="row"><b></b>{{ todayTip.yi || '—' }}</div>
<div class="row"><b></b>{{ todayTip.ji || '—' }}</div>
</section>
<div class="deep-links">
<button type="button" class="d-num" @click="showDeepExtra = !showDeepExtra">
{{ showDeepExtra ? '收起数字深解' : '完整数字报告' }}
</button>
<router-link class="d-ys" to="/relation">测测契合度 </router-link>
</div>
<div v-if="showDeepExtra && deep" class="card extra">
<p class="muted">主数 {{ mainNumber }} · {{ personTitle }} · 五行 {{ numberDeep.elem }}</p>
<p>{{ numberDeep.desc }}</p>
</div>
<div v-else-if="showDeepExtra && !deep" class="card extra">
<p class="muted">解锁深度版后可展开完整数字能量与联合码解读</p>
<button type="button" class="yxg-btn" :disabled="paying" @click="$emit('unlock')">
解锁完整分析模拟支付
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import NumberPyramid from './NumberPyramid.vue'
import PayLock from './PayLock.vue'
import WuxingBars, { type WuxingBar } from './WuxingBars.vue'
const props = defineProps<{
summary: Record<string, unknown>
detail: Record<string, unknown> | null
deep: boolean
paying?: boolean
}>()
defineEmits<{ unlock: [] }>()
const showDeepExtra = ref(false)
const mainNumber = computed(() => {
const n = Number(props.summary.main_number)
if (Number.isFinite(n) && n > 0) return n
const pk = Number(props.summary.pattern_key)
if (Number.isFinite(pk) && pk > 0) return pk
return '—'
})
const personTitle = computed(() =>
String(props.summary.person_title || props.summary.style_label || props.summary.style_badge || ''),
)
const oneLiner = computed(() =>
String(props.summary.one_liner || props.summary.headline || props.summary.overview || ''),
)
const pyramid = computed(() => (props.summary.pyramid || {}) as Record<string, number>)
const hasPyramid = computed(() => Object.keys(pyramid.value).length > 0)
const hasDecodeShape = computed(
() => props.summary.main_number != null || props.summary.pyramid != null || props.summary.wuxing != null,
)
const wuxing = computed(() => (props.summary.wuxing || {}) as Record<string, unknown>)
const bars = computed<WuxingBar[]>(() => {
const raw = wuxing.value.bars
if (!Array.isArray(raw)) return []
return raw.map((b) => {
const o = b as Record<string, unknown>
return { el: String(o.el || ''), pct: Number(o.pct || 0), color: String(o.color || '#999') }
})
})
const careDir = computed(() => {
const s = wuxing.value.strong
const w = wuxing.value.weak
if (s && w) return `${s} · 需呵护${w}`
return '—'
})
const emotionText = computed(() => {
const e = wuxing.value.emotion
return Array.isArray(e) ? e.join(' · ') : ''
})
const todayTip = computed(() => (props.summary.today_tip || {}) as Record<string, string>)
const constitutionDisclaimer = computed(
() =>
String(props.summary.disclaimer_constitution || '') ||
'体质与调养内容为生活方式参考,非医疗建议。',
)
const lockTeaserNumber = computed(
() =>
String(props.summary.lock_teaser_number || '') ||
'天赋优势与人生课题、适合方向与能量长文,解锁后查看。',
)
const lockTeaserWuxing = computed(
() =>
String(props.summary.lock_teaser_wuxing || '') ||
'体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看。',
)
const numberDeep = computed(() => {
const d = (props.detail?.number_deep || {}) as Record<string, unknown>
return {
talent: String(d.talent || ''),
topic: String(d.topic || ''),
direction: String(d.direction || ''),
desc: String(d.desc || ''),
elem: String(d.elem || ''),
}
})
const constitutionDeep = computed(() => {
const d = (props.detail?.constitution_deep || {}) as Record<string, unknown>
return {
body: String(d.body || ''),
tips: String(d.tips || ''),
risk: String(d.risk || ''),
season_risk: String(d.season_risk || ''),
food: String(d.food || ''),
rhythm: String(d.rhythm || ''),
}
})
const missing = computed(() => {
const d = (props.detail?.number_deep || {}) as Record<string, unknown>
const arr = d.missing
if (!Array.isArray(arr)) return [] as { n: number; msg: string }[]
return arr.map((x) => {
const o = x as Record<string, unknown>
return { n: Number(o.n), msg: String(o.msg || '') }
})
})
const joints = computed(() => {
const d = (props.detail?.number_deep || {}) as Record<string, unknown>
const arr = d.joints
if (!Array.isArray(arr)) return [] as { label: string; desc: string; codes: { code: string; label: string }[] }[]
return arr as { label: string; desc: string; codes: { code: string; label: string }[] }[]
})
</script>
<style scoped>
.decode {
--num: #c8923a;
--num-d: #8a6d3b;
--ys: #5cb85c;
--ys-d: #3f8f45;
color: #333;
}
.legacy-note {
font-size: 14px;
line-height: 1.6;
color: #555;
margin: 8px 0 4px;
padding: 12px 14px;
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05);
}
.card {
background: #fff;
border-radius: 18px;
padding: 20px;
margin: 14px 0;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06);
}
.tag {
display: inline-block;
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
color: #fff;
font-weight: 600;
margin-bottom: 10px;
}
.tag-num {
background: var(--num);
}
.tag-ys {
background: var(--ys);
}
.hex {
width: 76px;
height: 76px;
margin: 6px auto 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, var(--num), var(--num-d));
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
.p-title {
text-align: center;
font-size: 18px;
font-weight: 700;
color: var(--num-d);
}
.p-oneline {
text-align: center;
font-size: 13px;
color: #999;
margin-top: 4px;
line-height: 1.55;
}
.pyr-wrap {
margin: 16px 0 4px;
}
.ys-type {
text-align: center;
font-size: 20px;
font-weight: 700;
color: var(--ys-d);
margin: 4px 0;
}
.ys-dir {
text-align: center;
font-size: 13px;
color: #999;
}
.disc-mini {
font-size: 11px;
color: #aaa;
text-align: center;
margin-top: 12px;
line-height: 1.5;
}
.jn h4 {
font-size: 14px;
color: var(--ys-d);
margin: 8px 0;
}
.jn .row {
font-size: 13px;
line-height: 2;
color: #555;
}
.jn .row b {
color: var(--ys-d);
}
.jn .st {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.deep-links {
display: flex;
gap: 10px;
margin: 16px 0;
}
.deep-links a,
.deep-links button {
flex: 1;
text-align: center;
padding: 13px 6px;
border-radius: 14px;
color: #fff;
text-decoration: none;
font-size: 13px;
font-weight: 600;
border: none;
cursor: pointer;
}
.d-num {
background: linear-gradient(135deg, var(--num), var(--num-d));
}
.d-ys {
background: linear-gradient(135deg, var(--ys), var(--ys-d));
}
.sub-h {
margin-top: 10px;
font-weight: 700;
color: #555;
}
.joint {
margin-top: 8px;
}
.muted {
color: #888;
font-size: 12px;
line-height: 1.7;
}
.extra .yxg-btn {
margin-top: 10px;
width: 100%;
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="match">
<div v-if="fitLabel" class="fit">
<span class="badge">{{ fitLabel }}</span>
<span v-if="harmony != null" class="score">默契 {{ harmony }}</span>
</div>
<div v-if="love != null || friend != null || marriage != null" class="indices">
<div class="idx"><em>恋爱</em><strong>{{ love ?? '—' }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friend ?? '—' }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriage ?? '—' }}</strong></div>
</div>
<div class="pair">
<div class="who">
<span class="tag"></span>
<strong>{{ meStyle }}</strong>
</div>
<div class="vs">×</div>
<div class="who">
<span class="tag">TA</span>
<strong>{{ otherStyle }}</strong>
</div>
</div>
<ul v-if="starCompare.length" class="stars">
<li v-for="s in starCompare" :key="s.key">
<span class="k">{{ s.title }}</span>
<span>{{ s.me }} · {{ s.other }}</span>
<span class="n">{{ s.note }}</span>
</li>
</ul>
<div v-for="d in dimensions" :key="d.key" class="dim">
<div class="dh">
<strong>{{ d.title }}</strong>
<span> {{ d.me_score }} · TA {{ d.other_score }}</span>
</div>
<div class="bars">
<i class="me" :style="{ width: Math.min(100, Number(d.me_score || 0)) + '%' }" />
<i class="ot" :style="{ width: Math.min(100, Number(d.other_score || 0)) + '%' }" />
</div>
<p v-if="d.note" class="note">{{ d.note }}</p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
meStyle: string
otherStyle: string
fitLabel?: string
harmony?: number | null
love?: number | null
friend?: number | null
marriage?: number | null
starCompare?: { key: string; title: string; me: string; other: string; note?: string }[]
dimensions?: {
key: string
title: string
me_score?: number
other_score?: number
note?: string
}[]
}>()
</script>
<style scoped>
.match{margin:8px 0 14px}
.fit{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap}
.badge{
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));color:#fff;
font-size:12px;font-weight:700;padding:4px 10px;border-radius:999px;
}
.score{font-size:13px;color:var(--yxg-gold);font-weight:650}
.indices{
display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px;
}
.idx{
background:#fff5f4;border:1px solid #ffe0dc;border-radius:14px;padding:12px 8px;text-align:center;
}
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
.idx strong{font-size:22px;color:var(--yxg-pri);font-family:var(--font-display)}
.pair{
display:grid;grid-template-columns:1fr auto 1fr;gap:8px;align-items:center;
background:#fafafa;border-radius:16px;padding:14px;margin-bottom:12px;
}
.who{text-align:center}
.tag{display:block;font-size:11px;color:#999;margin-bottom:4px}
.who strong{font-size:15px;color:#222}
.vs{color:#ccc;font-size:18px}
.stars{list-style:none;margin:0 0 14px;padding:0}
.stars li{
display:grid;grid-template-columns:48px 1fr;gap:2px 10px;
padding:10px 0;border-bottom:1px solid #f3f3f3;font-size:13px;color:#555;
}
.stars .k{font-weight:650;color:#222}
.stars .n{grid-column:2;font-size:12px;color:#999}
.dim{margin-bottom:12px}
.dh{display:flex;justify-content:space-between;font-size:13px;margin-bottom:6px}
.dh span{color:#999;font-size:12px}
.bars{height:8px;background:#f0f0f0;border-radius:999px;position:relative;overflow:hidden}
.bars i{display:block;height:50%;border-radius:999px}
.bars .me{background:linear-gradient(90deg,#ff9a8f,var(--yxg-pri))}
.bars .ot{background:linear-gradient(90deg,#b0d0f5,#4a90e2)}
.note{font-size:12px;color:#888;margin-top:4px}
</style>
+264
View File
@@ -0,0 +1,264 @@
<template>
<div class="wheel-wrap">
<svg
class="wheel"
viewBox="0 0 320 320"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="本命星盘"
@click="onBgClick"
>
<circle cx="160" cy="160" r="150" fill="#1a1f3a" />
<circle cx="160" cy="160" r="118" fill="#12162b" stroke="#3d4570" stroke-width="1" />
<!-- sign wedges -->
<g v-for="(s, i) in signLabels" :key="'s' + i">
<path :d="wedgePath(i)" :fill="i % 2 ? '#1e2444' : '#181d36'" stroke="#2a3158" stroke-width="0.5" />
<text :x="labelPos(i).x" :y="labelPos(i).y" class="sign-lab" text-anchor="middle" dominant-baseline="middle">
{{ s }}
</text>
</g>
<!-- house ticks from ASC -->
<line
v-for="n in 12"
:key="'h' + n"
:x1="160"
:y1="160"
:x2="tick(n - 1, 118).x"
:y2="tick(n - 1, 118).y"
stroke="#445"
stroke-width="0.6"
opacity="0.5"
/>
<!-- aspect lines (preview / filtered) -->
<line
v-for="(a, i) in visibleAspects"
:key="'as' + i"
:x1="planetXY(a.a).x"
:y1="planetXY(a.a).y"
:x2="planetXY(a.b).x"
:y2="planetXY(a.b).y"
:stroke="aspectColor(a.type)"
stroke-width="1"
opacity="0.55"
/>
<!-- ASC line -->
<line
v-if="ascLon != null"
:x1="160"
:y1="160"
:x2="polar(ascLon, 148).x"
:y2="polar(ascLon, 148).y"
stroke="#E54D42"
stroke-width="1.5"
/>
<!-- planets -->
<g
v-for="p in visiblePlanets"
:key="p.key"
class="planet"
:class="{ on: selected === p.key }"
@click.stop="select(p.key)"
>
<circle :cx="planetXY(p.key).x" :cy="planetXY(p.key).y" r="11" :fill="planetColor(p.key)" />
<text :x="planetXY(p.key).x" :y="planetXY(p.key).y + 1" class="p-lab" text-anchor="middle" dominant-baseline="middle">
{{ shortGlyph(p.key) }}
</text>
</g>
<circle cx="160" cy="160" r="28" fill="#0e1224" stroke="#3d4570" />
<text x="160" y="156" class="center" text-anchor="middle">本命</text>
<text x="160" y="172" class="center-sub" text-anchor="middle"></text>
</svg>
<div v-if="selectedBody" class="sel-card">
<strong>{{ selectedBody.title }}</strong>
<span>{{ selectedBody.sign }} {{ selectedBody.degree }} · {{ selectedBody.house }}</span>
<p v-if="selectedBody.element" class="meta">{{ selectedBody.element }} · {{ selectedBody.modality }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
export type WheelPlanet = {
key: string
title: string
sign: string
degree: string
house: number
lon: number
element?: string
modality?: string
}
export type WheelAspect = {
a: string
b: string
type: string
orb?: number
label?: string
}
const props = withDefaults(
defineProps<{
planets: WheelPlanet[]
aspects?: WheelAspect[]
ascLon?: number | null
showOuter?: boolean
tightOrb?: boolean
}>(),
{ aspects: () => [], ascLon: null, showOuter: true, tightOrb: false },
)
const emit = defineEmits<{ select: [key: string] }>()
const selected = ref('')
const signLabels = ['白', '金', '双', '巨', '狮', '处', '秤', '蝎', '射', '摩', '瓶', '鱼']
const outerKeys = new Set(['uranus', 'neptune', 'pluto'])
const visiblePlanets = computed(() =>
props.planets.filter((p) => props.showOuter || !outerKeys.has(p.key)),
)
const lonMap = computed(() => {
const m: Record<string, number> = {}
for (const p of props.planets) m[p.key] = Number(p.lon)
return m
})
const visibleAspects = computed(() => {
const maxOrb = props.tightOrb ? 4 : 8
const keys = new Set(visiblePlanets.value.map((p) => p.key))
return (props.aspects || []).filter((a) => {
if (!keys.has(a.a) || !keys.has(a.b)) return false
if (a.orb != null && a.orb > maxOrb) return false
return true
})
})
const selectedBody = computed(() => visiblePlanets.value.find((p) => p.key === selected.value) || null)
watch(
() => props.planets,
(list) => {
if (!list.length) return
if (!selected.value || !list.some((p) => p.key === selected.value)) {
selected.value = list.find((p) => p.key === 'sun')?.key || list[0].key
}
},
{ immediate: true },
)
function select(key: string) {
selected.value = key
emit('select', key)
}
function onBgClick() {
/* keep selection */
}
function polar(lon: number, r: number) {
// 0° Aries at left (ASC traditional-ish): angle from +x axis, ecliptic grows CCW from Aries
const rad = ((180 - lon) * Math.PI) / 180
return { x: 160 + r * Math.cos(rad), y: 160 - r * Math.sin(rad) }
}
function planetXY(key: string) {
const lon = lonMap.value[key]
if (lon == null) return { x: 160, y: 160 }
return polar(lon, 95)
}
function tick(i: number, r: number) {
const base = props.ascLon ?? 0
return polar(base + i * 30, r)
}
function wedgePath(i: number) {
const a0 = polar(i * 30, 150)
const a1 = polar((i + 1) * 30, 150)
const b0 = polar(i * 30, 118)
const b1 = polar((i + 1) * 30, 118)
return `M ${a0.x} ${a0.y} A 150 150 0 0 0 ${a1.x} ${a1.y} L ${b1.x} ${b1.y} A 118 118 0 0 1 ${b0.x} ${b0.y} Z`
}
function labelPos(i: number) {
return polar(i * 30 + 15, 134)
}
function shortGlyph(key: string) {
const m: Record<string, string> = {
sun: '日', moon: '月', rise: '升', mercury: '水', venus: '金',
mars: '火', jupiter: '木', saturn: '土', uranus: '天', neptune: '海', pluto: '冥',
}
return m[key] || '·'
}
function planetColor(key: string) {
const m: Record<string, string> = {
sun: '#E8A838', moon: '#C8D0E0', rise: '#E54D42', mercury: '#8ab4f8',
venus: '#e89bb5', mars: '#e05a4a', jupiter: '#c9a227', saturn: '#9a8b6e',
uranus: '#6ec8d4', neptune: '#5b7cfa', pluto: '#8b6bb0',
}
return m[key] || '#888'
}
function aspectColor(type: string) {
if (type === 'trine' || type === 'sextile') return '#5CB85C'
if (type === 'square' || type === 'opposition') return '#E54D42'
return '#C8923A'
}
</script>
<style scoped>
.wheel-wrap {
margin: 8px 0 12px;
}
.wheel {
display: block;
width: 100%;
max-width: 340px;
margin: 0 auto;
border-radius: 16px;
}
.sign-lab {
fill: #8a93b8;
font-size: 9px;
font-weight: 600;
}
.p-lab {
fill: #111;
font-size: 9px;
font-weight: 800;
pointer-events: none;
}
.planet {
cursor: pointer;
}
.planet.on circle {
stroke: #fff;
stroke-width: 2;
}
.center {
fill: #c8d0e8;
font-size: 11px;
font-weight: 700;
}
.center-sub {
fill: #7a849f;
font-size: 10px;
}
.sel-card {
margin-top: 10px;
padding: 12px 14px;
background: #faf8f6;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: #444;
}
.sel-card strong {
color: #222;
font-size: 15px;
}
.meta {
color: #999;
font-size: 12px;
margin: 0;
}
</style>
@@ -0,0 +1,66 @@
<template>
<svg class="pyr" :viewBox="`0 0 ${W} ${H}`" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="数字三角">
<rect
v-for="c in cells"
:key="c.key"
:x="c.x"
:y="c.y"
:width="CW"
:height="CH"
rx="8"
:fill="c.hl ? '#C8923A' : '#fff8ef'"
stroke="#e0c48a"
/>
<text
v-for="c in cells"
:key="c.key + 't'"
:x="c.x + CW / 2"
:y="c.y + CH / 2 + 5"
text-anchor="middle"
font-size="17"
font-weight="700"
:fill="c.hl ? '#fff' : '#8a6d3b'"
>
{{ c.n }}
</text>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
pyramid: Record<string, number>
}>()
const CW = 42
const CH = 40
const GY = 8
const W = CW * 4 + GY * 3
const H = CH * 3 + GY * 2
const cx = W / 2
const cells = computed(() => {
const p = props.pyramid || {}
const n = (k: string) => (p[k] != null ? Number(p[k]) : '')
return [
{ key: 'O', x: cx - CW / 2, y: 0, n: n('O'), hl: true },
{ key: 'M', x: cx - CW - GY / 2, y: CH + GY, n: n('M'), hl: false },
{ key: 'N', x: cx + GY / 2, y: CH + GY, n: n('N'), hl: false },
{ key: 'I', x: 0, y: (CH + GY) * 2, n: n('I'), hl: false },
{ key: 'J', x: CW + GY, y: (CH + GY) * 2, n: n('J'), hl: false },
{ key: 'K', x: (CW + GY) * 2, y: (CH + GY) * 2, n: n('K'), hl: false },
{ key: 'L', x: (CW + GY) * 3, y: (CH + GY) * 2, n: n('L'), hl: false },
]
})
</script>
<style scoped>
.pyr {
display: block;
width: 220px;
max-width: 100%;
height: auto;
margin: 0 auto;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<header class="page-title">
<h1>
<span v-if="mark" class="mark" :class="toneClass" aria-hidden="true">{{ mark }}</span>
<span class="text">{{ title }}</span>
</h1>
<p v-if="sub" class="sub">{{ sub }}</p>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { FeatureIcon, type FeatureIconKey, type IconTone, toneClass as tc } from '../lib/featureIcons'
const props = defineProps<{
title: string
sub?: string
/** Prefer feature key so colors match yuxingu.html */
feature?: FeatureIconKey
/** Or pass mark + tone directly */
icon?: string
tone?: IconTone
}>()
const mark = computed(() => {
if (props.feature) return FeatureIcon[props.feature].mark
return props.icon || ''
})
const toneClass = computed(() => {
if (props.feature) return tc(FeatureIcon[props.feature].tone)
if (props.tone) return tc(props.tone)
return 'icon-gold'
})
</script>
<style scoped>
.page-title{margin-bottom:4px}
h1{
font-family:var(--font-display);
font-size:22px;font-weight:700;letter-spacing:.04em;
display:flex;align-items:center;gap:10px;line-height:1.3;
color:#222;
}
.mark{
width:40px;height:40px;border-radius:14px;flex-shrink:0;
display:inline-flex;align-items:center;justify-content:center;
font-size:20px;font-weight:600;letter-spacing:0;
font-family:var(--font-sans);
box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
}
.text{min-width:0}
.sub{
color:var(--color-text-secondary);
font-size:13px;margin:8px 0 4px;line-height:1.55;
letter-spacing:.02em;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<div class="lock" :class="{ unlocked }">
<div class="blur">
<slot />
</div>
<div v-if="!unlocked" class="cover">
<div class="lk-t">{{ title }}</div>
<div class="lk-d">{{ desc }}</div>
<button class="unlock-btn" type="button" :disabled="paying" @click="$emit('unlock')">
{{ paying ? '解锁中' : cta }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
unlocked?: boolean
paying?: boolean
title?: string
desc?: string
cta?: string
}>(),
{
unlocked: false,
paying: false,
title: '解锁完整分析',
desc: '天赋优势 · 人生课题 · 体质深析',
cta: '解锁完整分析(模拟支付)',
},
)
defineEmits<{ unlock: [] }>()
</script>
<style scoped>
.lock {
position: relative;
margin-top: 14px;
border-radius: 14px;
overflow: hidden;
border: 1px solid #f2e6d0;
}
.blur {
filter: blur(10px);
opacity: 0.35;
pointer-events: none;
user-select: none;
padding: 16px;
font-size: 13px;
line-height: 1.9;
color: #666;
text-align: left;
}
.unlocked .blur {
filter: none;
opacity: 1;
pointer-events: auto;
user-select: auto;
}
.cover {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.92));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
}
.lk-t {
font-size: 15px;
font-weight: 700;
color: #8a6d3b;
}
.lk-d {
font-size: 12px;
color: #999;
margin: 6px 0 12px;
}
.unlock-btn {
background: var(--yxg-pri, #e54d42);
color: #fff;
border: none;
padding: 11px 26px;
border-radius: 24px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.35);
}
.unlock-btn:disabled {
opacity: 0.65;
cursor: wait;
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="rich">
<div v-if="overview" class="card">
<h2><span class="hm" aria-hidden="true"></span>总览</h2>
<p>{{ overview }}</p>
<p v-if="lifeTip" class="tip">生活建议{{ lifeTip }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k">{{ k }}</span>
</div>
</div>
<div v-if="dimensions.length" class="card">
<h2><span class="hm" aria-hidden="true"></span>多维速览</h2>
<div v-for="d in dimensions" :key="d.key || d.title" class="dim">
<div class="dim-head">
<strong>{{ d.title }}</strong>
<span v-if="d.score != null" class="score">{{ d.score }}</span>
</div>
<div v-if="d.score != null" class="bar"><i :style="{ width: Math.min(100, Number(d.score)) + '%' }" /></div>
<p v-if="d.teaser || d.note" class="muted">{{ d.teaser || d.note }}</p>
<template v-if="d.me_score != null">
<p class="compare"> {{ d.me_score }} · TA {{ d.other_score }}</p>
<p v-if="d.note" class="muted">{{ d.note }}</p>
</template>
</div>
</div>
<div v-if="strengths.length" class="card">
<h2><span class="hm" aria-hidden="true"></span>优势速览</h2>
<ul>
<li v-for="(s, i) in strengths" :key="'s' + i">{{ s }}</li>
</ul>
</div>
<div v-if="watchouts.length" class="card">
<h2><span class="hm" aria-hidden="true"></span>需要留意</h2>
<ul>
<li v-for="(s, i) in watchouts" :key="'w' + i">{{ s }}</li>
</ul>
</div>
<template v-if="deep">
<div v-for="(sec, i) in sections" :key="'sec' + i" class="card deep">
<h2><span class="hm" aria-hidden="true">{{ sectionMark(sec.title) }}</span>{{ sec.title }}</h2>
<p v-if="sec.body">{{ sec.body }}</p>
<ul v-if="sec.bullets?.length">
<li v-for="(b, j) in sec.bullets" :key="j">{{ b }}</li>
</ul>
</div>
<div v-if="growthPlan.length" class="card deep">
<h2><span class="hm" aria-hidden="true"></span>成长路径</h2>
<div v-for="(g, i) in growthPlan" :key="'g' + i" class="plan">
<strong>{{ g.phase }}</strong>
<p>{{ g.focus }}</p>
</div>
</div>
<div v-if="scripts.length" class="card deep">
<h2><span class="hm" aria-hidden="true"></span>可以这样说</h2>
<ul>
<li v-for="(s, i) in scripts" :key="'c' + i">{{ s }}</li>
</ul>
</div>
<div v-if="tips.length" class="card deep">
<h2><span class="hm" aria-hidden="true"></span>可执行建议</h2>
<ul>
<li v-for="(t, i) in tips" :key="'t' + i">{{ t }}</li>
</ul>
</div>
<div v-if="weekly.length" class="card deep">
<h2><span class="hm" aria-hidden="true"></span>本周练习</h2>
<ul>
<li v-for="(t, i) in weekly" :key="'wk' + i">{{ t }}</li>
</ul>
</div>
<div v-if="faq.length" class="card deep">
<h2><span class="hm" aria-hidden="true">?</span>常见疑问</h2>
<div v-for="(f, i) in faq" :key="'f' + i" class="faq">
<p class="q">{{ f.q }}</p>
<p>{{ f.a }}</p>
</div>
</div>
<!-- legacy flat fields -->
<div v-if="!sections.length && legacyBlocks.length" class="card deep">
<h2><span class="hm" aria-hidden="true"></span>完整分析</h2>
<p v-for="(b, i) in legacyBlocks" :key="'l' + i">
<strong>{{ b.title }}</strong> {{ b.body }}
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { sectionMark } from '../lib/featureIcons'
const props = defineProps<{
summary?: Record<string, unknown> | null
detail?: Record<string, unknown> | null
deep?: boolean
}>()
function asStringArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') as string[] : []
}
function asDimList(v: unknown): Record<string, unknown>[] {
return Array.isArray(v) ? (v as Record<string, unknown>[]) : []
}
const overview = computed(() => String(props.summary?.overview || props.summary?.chemistry || ''))
const lifeTip = computed(() => String(props.summary?.life_tip || ''))
const keywords = computed(() => {
const k = asStringArray(props.summary?.keywords)
if (k.length) return k
return [...asStringArray(props.summary?.me_keywords), ...asStringArray(props.summary?.other_keywords)]
})
const dimensions = computed(() => {
const a = asDimList(props.summary?.dimensions)
if (a.length) return a
return asDimList(props.summary?.dimension_compare)
})
const strengths = computed(() => {
if (props.deep) {
const full = asStringArray(props.detail?.strengths || props.detail?.strengths_together)
if (full.length) return full
}
return asStringArray(props.summary?.strengths_preview || props.summary?.strengths)
})
const watchouts = computed(() => {
if (props.deep) {
const full = asStringArray(props.detail?.blind_spots || props.detail?.friction_points)
if (full.length) return full
}
return asStringArray(props.summary?.watchouts_preview || props.summary?.blind_spots_preview || props.summary?.watchouts)
})
const sections = computed(() => {
if (!props.deep || !props.detail) return [] as { title: string; body?: string; bullets?: string[] }[]
const raw = props.detail.sections
if (!Array.isArray(raw)) return []
return raw.map((s) => {
const m = s as Record<string, unknown>
return {
title: String(m.title || ''),
body: m.body != null ? String(m.body) : undefined,
bullets: asStringArray(m.bullets),
}
})
})
const growthPlan = computed(() => {
if (!props.deep) return [] as { phase: string; focus: string }[]
const raw = props.detail?.growth_plan
if (!Array.isArray(raw)) return []
return raw.map((g) => {
const m = g as Record<string, unknown>
return { phase: String(m.phase || ''), focus: String(m.focus || '') }
})
})
const scripts = computed(() => (props.deep ? asStringArray(props.detail?.conversation_scripts || props.detail?.scripts) : []))
const tips = computed(() => {
if (!props.deep || !props.detail) return [] as string[]
if (asStringArray(props.detail.tips).length) return asStringArray(props.detail.tips)
const a = asStringArray(props.detail.communication)
const b = asStringArray(props.detail.interaction)
const c = asStringArray(props.detail.maintenance)
const d = asStringArray(props.detail.daily_suggestions)
return [...a, ...b, ...c, ...d]
})
const weekly = computed(() => (props.deep ? asStringArray(props.detail?.weekly_practice) : []))
const faq = computed(() => {
if (!props.deep || !Array.isArray(props.detail?.faq)) return [] as { q: string; a: string }[]
return (props.detail!.faq as Record<string, unknown>[]).map((f) => ({
q: String(f.q || ''),
a: String(f.a || ''),
}))
})
const legacyBlocks = computed(() => {
if (!props.deep || !props.detail) return [] as { title: string; body: string }[]
const d = props.detail
const out: { title: string; body: string }[] = []
if (d.behavior_pattern) out.push({ title: '行为模式', body: String(d.behavior_pattern) })
if (d.relation_style) out.push({ title: '关系特点', body: String(d.relation_style) })
if (d.growth_direction) out.push({ title: '成长方向', body: String(d.growth_direction) })
return out
})
</script>
<style scoped>
.card{
background:#fafafa;border-radius:var(--radius-lg);padding:16px;
font-size:14px;line-height:1.75;color:#555;margin-bottom:12px;
}
h2{
font-size:15px;font-weight:700;color:#222;margin:0 0 10px;
display:flex;align-items:center;gap:8px;letter-spacing:.02em;
}
.hm{
width:28px;height:28px;border-radius:10px;flex-shrink:0;
display:inline-flex;align-items:center;justify-content:center;
font-size:13px;background:#fff3f1;color:var(--yxg-pri);font-weight:600;
box-shadow:inset 0 -1px 0 rgba(0,0,0,.03);
}
.tip{margin-top:10px;color:#666}
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.tags span{
background:var(--color-primary-soft);color:var(--yxg-pri);
padding:4px 10px;border-radius:var(--radius-pill);font-size:12px;
}
.dim{margin-bottom:14px}
.dim:last-child{margin-bottom:0}
.dim-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.score{font-size:12px;color:var(--yxg-pri);font-weight:700}
.bar{height:6px;background:#f0f0f0;border-radius:999px;margin:6px 0;overflow:hidden}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#ff9a8f,var(--yxg-pri));border-radius:999px}
.muted{font-size:13px;color:#888;margin:4px 0 0}
.compare{font-size:12px;color:#999;margin:4px 0 0}
ul{padding-left:18px;margin:0}
li{margin:6px 0}
.plan{margin-bottom:10px}
.plan strong{color:var(--yxg-pri);font-size:13px}
.faq{margin-bottom:12px}
.faq .q{font-weight:650;color:#222;margin-bottom:4px}
.deep p{margin:0 0 8px}
</style>
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ShareCard from './ShareCard.vue'
describe('ShareCard', () => {
it('renders brand and portrait content', () => {
const w = mount(ShareCard, {
props: {
type: 'portrait',
title: '探索者风格',
line: '一句结论',
keywords: ['稳'],
ctaLabel: '查看完整成长报告',
},
})
expect(w.find('img.brand-logo').attributes('alt')).toBe('愈心谷')
expect(w.text()).toContain('探索者风格')
expect(w.text()).toContain('查看完整成长报告')
expect(w.text()).not.toContain('运势')
expect(w.text()).not.toContain('算命')
})
it('renders relation rows', () => {
const w = mount(ShareCard, {
props: {
type: 'relation',
title: '我的方式 vs TA 的方式',
rows: ['我:理性型', 'TA:感受型'],
ctaLabel: '了解彼此 → 查看关系理解',
},
})
expect(w.text()).toContain('理性型')
expect(w.text()).toContain('查看关系理解')
})
})
+75
View File
@@ -0,0 +1,75 @@
<template>
<article class="share-card" :data-type="type">
<BrandLogo size="card" />
<p class="tag">认识自己 · 理解他人</p>
<h2 v-if="title">{{ title }}</h2>
<p v-if="line" class="line">{{ line }}</p>
<ul v-if="rows.length" class="rows">
<li v-for="(r, i) in rows" :key="i">{{ r }}</li>
</ul>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k">{{ k }}</span>
</div>
<p class="cta-label">{{ ctaLabel }}</p>
<p class="disc">自我探索参考非医疗或占卜预测</p>
</article>
</template>
<script setup lang="ts">
import BrandLogo from './BrandLogo.vue'
withDefaults(
defineProps<{
type?: 'portrait' | 'relation'
title?: string
line?: string
rows?: string[]
keywords?: string[]
ctaLabel?: string
}>(),
{
type: 'portrait',
title: '',
line: '',
rows: () => [],
keywords: () => [],
ctaLabel: '查看完整成长报告',
},
)
</script>
<style scoped>
.share-card{
position:relative;overflow:hidden;border-radius:20px;padding:22px 20px 18px;
background:
radial-gradient(120% 80% at 100% 0%, rgba(229,77,66,.18), transparent 55%),
linear-gradient(165deg, #fff8f6 0%, #ffe8e4 48%, #fff 100%);
color:#3a3030;box-shadow:0 10px 28px rgba(229,77,66,.12);
}
.tag{font-size:12px;color:#a08080;margin:8px 0 14px}
h2{font-size:17px;font-weight:600;margin:0 0 8px;line-height:1.4}
.line{font-size:14px;line-height:1.65;color:#555;margin:0 0 10px}
.rows{margin:0 0 12px;padding:0;list-style:none}
.rows li{font-size:14px;line-height:1.7;padding:4px 0;border-bottom:1px dashed rgba(229,77,66,.15)}
.rows li:last-child{border-bottom:none}
.tags{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 14px}
.tags span{
background:rgba(229,77,66,.1);color:var(--yxg-pri,#E54D42);
padding:4px 10px;border-radius:999px;font-size:12px;
}
.cta-label{
display:inline-block;margin-top:4px;padding:8px 14px;border-radius:20px;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri,#E54D42));
color:#fff;font-size:13px;font-weight:600;
}
.disc{margin-top:12px;font-size:10px;color:#bbb;line-height:1.4}
.share-card[data-type="relation"]{
background:
radial-gradient(120% 80% at 0% 0%, rgba(200,146,58,.2), transparent 55%),
linear-gradient(165deg, #fffaf0 0%, #fff3d6 50%, #fff 100%);
}
.share-card[data-type="relation"] .tags span{background:#fff3d6;color:#c8923a}
.share-card[data-type="relation"] .cta-label{
background:linear-gradient(135deg,#e0b35a,#c8923a);
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div v-if="open" class="mask" @click.self="$emit('close')">
<div class="sheet">
<header>
<h3>分享卡</h3>
<button type="button" class="x" @click="$emit('close')">关闭</button>
</header>
<ShareCard v-bind="card" />
<p v-if="status" class="status">{{ status }}</p>
<div class="btns">
<button type="button" :disabled="busy" @click="copyLink">复制链接</button>
<button v-if="canNative" type="button" class="ghost" :disabled="busy" @click="nativeShare">系统分享</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import ShareCard from './ShareCard.vue'
import { buildSharePath, copyText, type SharePayload } from '../lib/shareLink'
const props = defineProps<{
open: boolean
payload: SharePayload | null
}>()
defineEmits<{ close: [] }>()
const busy = ref(false)
const status = ref('')
const card = computed(() => {
const p = props.payload
if (!p) return {}
if (p.type === 'portrait') {
return {
type: 'portrait' as const,
title: p.title,
line: p.line,
keywords: p.keywords,
ctaLabel: '查看完整成长报告',
}
}
return {
type: 'relation' as const,
title: '我的方式 vs TA 的方式',
rows: [
p.me ? `我的沟通方式:${p.me}` : '',
p.other ? `TA 的沟通方式:${p.other}` : '',
p.diff ? `了解彼此 → ${p.diff}` : '了解彼此 → 查看关系理解',
].filter(Boolean),
keywords: p.keywords,
ctaLabel: '了解彼此 → 查看关系理解',
}
})
const canNative = computed(() => typeof navigator !== 'undefined' && typeof navigator.share === 'function')
function absoluteShareURL(): string {
if (!props.payload) return location.origin
return `${location.origin}${buildSharePath(props.payload)}`
}
async function copyLink() {
busy.value = true
status.value = ''
const ok = await copyText(absoluteShareURL())
status.value = ok ? '链接已复制' : '复制失败,请手动复制地址栏'
busy.value = false
}
async function nativeShare() {
if (!props.payload || !navigator.share) return
busy.value = true
status.value = ''
try {
await navigator.share({
title: '愈心谷',
text: props.payload.type === 'relation' ? '看看我们的相处方式' : '看看我的个人画像',
url: absoluteShareURL(),
})
status.value = '已唤起系统分享'
} catch {
status.value = ''
} finally {
busy.value = false
}
}
</script>
<style scoped>
.mask{
position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;
display:flex;align-items:flex-end;justify-content:center;padding:12px;
}
.sheet{
width:100%;max-width:430px;background:#fff5f3;border-radius:20px 20px 12px 12px;
padding:16px;max-height:90vh;overflow:auto;
}
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
h3{font-size:16px;margin:0}
.x{border:none;background:transparent;color:#999;font-size:13px}
.btns{display:flex;gap:8px;margin-top:14px}
.btns button{
flex:1;border:none;border-radius:22px;padding:11px;color:#fff;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
}
.btns button.ghost{background:#fff;color:var(--yxg-pri);border:1px solid #f0c0bc}
.btns button:disabled{opacity:.6}
.status{font-size:12px;color:#888;margin-top:10px;text-align:center}
</style>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="sign-cards">
<button
v-for="c in cards"
:key="c.key"
type="button"
class="card"
:class="{ on: modelValue === c.key }"
@click="$emit('update:modelValue', c.key)"
>
<span class="role">{{ c.title }}</span>
<strong class="label">{{ c.label }}</strong>
<span class="meta">{{ c.element }} · {{ c.modality }}</span>
</button>
</div>
</template>
<script setup lang="ts">
export type SignCard = {
key: string
title: string
label: string
element?: string
modality?: string
teaser?: string
}
defineProps<{
cards: SignCard[]
modelValue: string
}>()
defineEmits<{ 'update:modelValue': [string] }>()
</script>
<style scoped>
.sign-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:8px 0 14px}
.card{
border:1px solid #eee;background:#fafafa;border-radius:14px;
padding:12px 8px;text-align:center;cursor:pointer;
}
.card.on{border-color:var(--yxg-pri);background:#fff5f4;box-shadow:0 2px 10px rgba(229,77,66,.12)}
.role{display:block;font-size:11px;color:#999;margin-bottom:4px}
.label{
display:block;font-family:var(--font-display);
font-size:18px;font-weight:700;color:#222;letter-spacing:.06em;
}
.meta{display:block;font-size:11px;color:#bbb;margin-top:4px}
</style>
+55 -17
View File
@@ -5,10 +5,10 @@
:key="item.to"
:to="item.to"
class="item"
:class="{ ask: item.ask, on: isActive(item.to) }"
:class="{ ask: item.ask, on: isActive(item.key) }"
>
<span class="ni">{{ item.icon }}</span>
{{ item.label }}
<span class="lb">{{ item.label }}</span>
</router-link>
</nav>
</template>
@@ -18,36 +18,74 @@ import { useRoute } from 'vue-router'
const route = useRoute()
const items = [
{ to: '/', icon: '⌂', label: '首页' },
{ to: '/explore', icon: '◎', label: '探索' },
{ to: '/ask', icon: '问', label: '问答', ask: true },
{ to: '/companion', icon: '♡', label: '陪伴' },
{ to: '/mine', icon: '◍', label: '我的' },
]
{ key: 'home', to: '/', icon: '⌂', label: '首页' },
{ key: 'explore', to: '/explore', icon: '◎', label: '探索' },
{ key: 'ask', to: '/ask', icon: '问', label: '问答', ask: true },
{ key: 'companion', to: '/companion', icon: '♡', label: '陪伴' },
{ key: 'mine', to: '/mine', icon: '◍', label: '我的' },
] as const
function isActive(to: string): boolean {
if (to === '/') return route.path === '/'
return route.path.startsWith(to)
function activeKey(): string {
const p = route.path
if (p.startsWith('/ask')) return 'ask'
if (p.startsWith('/companion')) return 'companion'
if (
p.startsWith('/mine') ||
p.startsWith('/profile') ||
p.startsWith('/membership') ||
p.startsWith('/reports')
) {
return 'mine'
}
if (
p.startsWith('/explore') ||
p.startsWith('/relation') ||
p.startsWith('/scales') ||
p.startsWith('/portrait') ||
p.startsWith('/star') ||
p.startsWith('/synastry') ||
p.startsWith('/rhythm') ||
p.startsWith('/cards') ||
p.startsWith('/growth-plan') ||
p.startsWith('/share')
) {
return 'explore'
}
if (p === '/') return 'home'
return 'home'
}
function isActive(key: string): boolean {
return activeKey() === key
}
</script>
<style scoped>
.tabbar{
position:fixed;bottom:0;left:50%;transform:translateX(-50%);
width:100%;max-width:var(--yxg-max-w);background:#fff;
width:100%;max-width:var(--yxg-max-w);
background:rgba(255,255,255,.96);
backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
display:flex;justify-content:space-around;
padding:6px 0 calc(10px + env(safe-area-inset-bottom,0));
border-top:1px solid #f0f0f0;z-index:100;
border-top:1px solid var(--color-border);z-index:100;
box-shadow:var(--shadow-nav);
}
.item{
flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;
font-size:10px;color:var(--yxg-sub);padding:2px 0;
font-size:10px;color:var(--color-text-secondary);padding:2px 0;
letter-spacing:.02em;
}
.item.on{color:var(--yxg-pri);font-weight:600}
.ni{font-size:16px;line-height:1.2}
.ni{font-size:17px;line-height:1.2}
.lb{line-height:1.2}
.item.ask .ni{
width:36px;height:36px;border-radius:50%;background:var(--yxg-pri);color:#fff;
display:flex;align-items:center;justify-content:center;font-size:14px;margin-top:-10px;
width:40px;height:40px;border-radius:50%;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:14px;font-weight:700;margin-top:-12px;
box-shadow:0 4px 12px rgba(229,77,66,.35);
}
.item.ask.on{color:var(--yxg-pri)}
</style>
@@ -0,0 +1,54 @@
<template>
<div class="bars">
<div v-for="b in bars" :key="b.el" class="bar-row">
<span class="lab" :style="{ color: b.color }">{{ b.el }}</span>
<div class="bar-track">
<div class="bar-fill" :style="{ width: Math.min(100, b.pct) + '%', background: b.color }" />
</div>
<span class="val">{{ b.pct }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
export type WuxingBar = { el: string; pct: number; color: string }
defineProps<{
bars: WuxingBar[]
}>()
</script>
<style scoped>
.bars {
margin: 14px 0 6px;
}
.bar-row {
display: flex;
align-items: center;
gap: 8px;
margin: 7px 0;
font-size: 13px;
}
.lab {
width: 20px;
text-align: center;
font-weight: 700;
}
.bar-track {
flex: 1;
height: 14px;
background: #f0f0f0;
border-radius: 8px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 8px;
transition: width 0.6s;
}
.val {
width: 34px;
text-align: right;
color: #999;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AnalyticsEvent, track, trackPageView } from './analytics'
describe('analytics.track', () => {
beforeEach(() => {
window.gtag = vi.fn()
})
afterEach(() => {
delete window.gtag
})
it('calls gtag with event name and params', () => {
track(AnalyticsEvent.HomeCtaPortrait)
expect(window.gtag).toHaveBeenCalledWith('event', 'home_cta_portrait', {})
})
it('omits undefined params', () => {
track(AnalyticsEvent.PortraitCompleted, { source: 'query', report_id: undefined })
expect(window.gtag).toHaveBeenCalledWith('event', 'portrait_completed', { source: 'query' })
})
it('does not throw when gtag missing', () => {
delete window.gtag
expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow()
})
it('trackPageView sends page_path', () => {
trackPageView('/portrait', '个人画像')
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', {
page_path: '/portrait',
page_title: '个人画像',
})
})
})
+83
View File
@@ -0,0 +1,83 @@
/**
* P1 growth analytics — see .ai/product/feature-spec/analytics.md
* Pages MUST use track(); do not call window.gtag directly.
*/
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
/** Frozen P1 core funnel events (+ page_view via router). */
export const AnalyticsEvent = {
HomeCtaPortrait: 'home_cta_portrait',
PortraitCompleted: 'portrait_completed',
DeepAccessClicked: 'deep_access_clicked',
PurchaseCompleted: 'purchase_completed',
RelationCompleted: 'relation_completed',
SynastryCompleted: 'synastry_completed',
SynastryInviteCreated: 'synastry_invite_created',
SynastryInviteAccepted: 'synastry_invite_accepted',
SynastryNearbyOpened: 'synastry_nearby_opened',
StarWheelViewed: 'star_wheel_viewed',
PageView: 'page_view',
} as const
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
declare global {
interface Window {
dataLayer?: unknown[]
gtag?: (...args: unknown[]) => void
}
}
let gaReady = false
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */
export function initAnalytics(): void {
const id = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim()
if (!id || typeof document === 'undefined') return
if (gaReady) return
gaReady = true
window.dataLayer = window.dataLayer || []
window.gtag = function gtag(...args: unknown[]) {
window.dataLayer!.push(args)
}
window.gtag('js', new Date())
window.gtag('config', id, { send_page_view: false })
const s = document.createElement('script')
s.async = true
s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`
document.head.appendChild(s)
}
function debugEnabled(): boolean {
return import.meta.env.DEV && String(import.meta.env.VITE_ANALYTICS_DEBUG || '') === '1'
}
/** Fire a custom event. No-op when gtag missing; never throws. */
export function track(event: string, params?: AnalyticsParams): void {
try {
const clean: Record<string, string | number | boolean> = {}
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) clean[k] = v
}
}
if (debugEnabled()) {
console.debug('[analytics]', event, clean)
}
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('event', event, clean)
}
} catch {
/* never block UX */
}
}
export function trackPageView(path: string, title?: string): void {
track(AnalyticsEvent.PageView, {
page_path: path,
page_title: title || document.title,
})
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Icon glyphs + tones from legacy yuxingu.html / css/home.css
* (geometric marks — no emoji)
*/
export type IconTone =
| 'pink'
| 'blue'
| 'purple'
| 'orange'
| 'green'
| 'rose'
| 'teal'
| 'gold'
| 'night'
| 'mute'
export const FeatureIcon = {
home: { mark: '⌂', tone: 'mute' as IconTone },
portrait: { mark: '△', tone: 'gold' as IconTone }, // 愈心解码
star: { mark: '✦', tone: 'night' as IconTone }, // 星座星盘
rhythm: { mark: '☯', tone: 'green' as IconTone },
cards: { mark: '◈', tone: 'teal' as IconTone },
relation: { mark: '♡', tone: 'rose' as IconTone },
ask: { mark: '◎', tone: 'gold' as IconTone },
companion: { mark: '♡', tone: 'green' as IconTone },
explore: { mark: '◆', tone: 'blue' as IconTone },
more: { mark: '⋯', tone: 'mute' as IconTone },
membership: { mark: '⑨', tone: 'purple' as IconTone },
profile: { mark: '◍', tone: 'mute' as IconTone },
reports: { mark: '▣', tone: 'orange' as IconTone },
scale: { mark: '◆', tone: 'blue' as IconTone },
mood: { mark: '○', tone: 'green' as IconTone },
tip: { mark: '※', tone: 'gold' as IconTone },
share: { mark: '↗', tone: 'pink' as IconTone },
growth: { mark: '▽', tone: 'teal' as IconTone },
} as const
export type FeatureIconKey = keyof typeof FeatureIcon
/** Section titles inside ReportRich — tones stay soft red via CSS default */
export function sectionMark(title: string): string {
if (/总览|摘要/.test(title)) return '◈'
if (/多维|维度/.test(title)) return '◆'
if (/优势/.test(title)) return '△'
if (/留意|盲区|摩擦/.test(title)) return '◇'
if (/成长|路径|计划/.test(title)) return '▽'
if (/可以这样说|对话|沟通/.test(title)) return '◎'
if (/建议|练习|本周/.test(title)) return '※'
if (/疑问|FAQ/i.test(title)) return '?'
if (/太阳|星象|月亮|上升/.test(title)) return '✦'
if (/节律|五行|作息/.test(title)) return '☯'
if (/意象|卡片/.test(title)) return '◈'
return '·'
}
export function toneClass(tone: IconTone): string {
return `icon-${tone}`
}
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from 'vitest'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft, scaleDraftKey } from './scaleDraft'
describe('scaleDraft', () => {
afterEach(() => {
clearScaleDraft('demo')
})
it('saves and loads answers', () => {
saveScaleDraft('demo', { q1: 'A', q2: 'B' })
expect(loadScaleDraft('demo')).toEqual({ q1: 'A', q2: 'B' })
expect(localStorage.getItem(scaleDraftKey('demo'))).toBeTruthy()
})
it('clears draft', () => {
saveScaleDraft('demo', { q1: 'A' })
clearScaleDraft('demo')
expect(loadScaleDraft('demo')).toBeNull()
})
it('ignores empty values', () => {
saveScaleDraft('demo', { q1: 'A', q2: '' })
expect(loadScaleDraft('demo')).toEqual({ q1: 'A' })
})
})
+50
View File
@@ -0,0 +1,50 @@
/** Local draft for exploration-test answers — see feature-spec/explore-test.md */
const prefix = 'yuxingu_scale_draft_v1:'
export function scaleDraftKey(slug: string): string {
return prefix + slug
}
export function loadScaleDraft(slug: string): Record<string, string> | null {
if (!slug || typeof localStorage === 'undefined') return null
try {
const raw = localStorage.getItem(scaleDraftKey(slug))
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string' && v) out[k] = v
}
return Object.keys(out).length ? out : null
} catch {
return null
}
}
export function saveScaleDraft(slug: string, answers: Record<string, string>): void {
if (!slug || typeof localStorage === 'undefined') return
try {
const clean: Record<string, string> = {}
for (const [k, v] of Object.entries(answers)) {
if (v) clean[k] = v
}
if (!Object.keys(clean).length) {
localStorage.removeItem(scaleDraftKey(slug))
return
}
localStorage.setItem(scaleDraftKey(slug), JSON.stringify(clean))
} catch {
/* quota / private mode */
}
}
export function clearScaleDraft(slug: string): void {
if (!slug || typeof localStorage === 'undefined') return
try {
localStorage.removeItem(scaleDraftKey(slug))
} catch {
/* ignore */
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { buildSharePath, parseShareQuery } from './shareLink'
describe('shareLink', () => {
it('round-trips portrait payload', () => {
const path = buildSharePath({
type: 'portrait',
title: '偏稳的互动风格',
line: '一句画像',
keywords: ['稳', '细'],
})
expect(path.startsWith('/share?')).toBe(true)
const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?'))))
const parsed = parseShareQuery(q)
expect(parsed).toEqual({
type: 'portrait',
title: '偏稳的互动风格',
line: '一句画像',
keywords: ['稳', '细'],
})
})
it('round-trips relation payload', () => {
const path = buildSharePath({
type: 'relation',
me: '理性型',
other: '感受型',
diff: '节奏不同',
keywords: ['我·稳'],
})
const q = Object.fromEntries(new URLSearchParams(path.slice(path.indexOf('?'))))
const parsed = parseShareQuery(q)
expect(parsed).toEqual({
type: 'relation',
me: '理性型',
other: '感受型',
diff: '节奏不同',
keywords: ['我·稳'],
})
})
it('returns null for empty/invalid', () => {
expect(parseShareQuery({})).toBeNull()
expect(parseShareQuery({ type: 'portrait' })).toBeNull()
})
})
+80
View File
@@ -0,0 +1,80 @@
/** Build / parse client-side share landing query (no server). */
export type PortraitSharePayload = {
type: 'portrait'
title: string
line: string
keywords: string[]
}
export type RelationSharePayload = {
type: 'relation'
me: string
other: string
diff: string
keywords: string[]
}
export type SharePayload = PortraitSharePayload | RelationSharePayload
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(','))
} else {
if (payload.me) q.set('me', payload.me)
if (payload.other) q.set('ot', payload.other)
if (payload.diff) q.set('d', payload.diff)
if (payload.keywords.length) q.set('k', payload.keywords.slice(0, 6).join(','))
}
return `/share?${q.toString()}`
}
export function parseShareQuery(query: Record<string, unknown>): SharePayload | null {
const type = String(query.type || '')
const keywords = String(query.k || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (type === 'portrait') {
const title = String(query.t || '')
const line = String(query.l || '')
if (!title && !line && !keywords.length) return null
return { type: 'portrait', title, line, keywords }
}
if (type === 'relation') {
const me = String(query.me || '')
const other = String(query.ot || '')
const diff = String(query.d || '')
if (!me && !other && !diff) return null
return { type: 'relation', me, other, diff, keywords }
}
return null
}
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
/* fall through */
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
+7
View File
@@ -2,7 +2,14 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { initAnalytics, trackPageView } from './lib/analytics'
import '@yuxingu/ui/tokens.css'
import './styles/app.css'
initAnalytics()
router.afterEach((to) => {
trackPageView(to.fullPath, typeof to.name === 'string' ? to.name : undefined)
})
createApp(App).use(createPinia()).use(router).mount('#app')
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import AskPage from './AskPage.vue'
const { api } = vi.hoisted(() => ({
api: {
listProfiles: vi.fn(),
getAskQuota: vi.fn(),
createAskThread: vi.fn(),
sendAskMessage: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
}))
const RouterLinkStub = defineComponent({
props: ['to'],
setup(props, { slots }) {
return () => h('a', { href: String(props.to) }, slots.default?.())
},
})
describe('AskPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows empty state when no profiles', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('去首页探索')
})
it('loads profiles and sends a message', async () => {
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' })
api.sendAskMessage.mockResolvedValue({
user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' },
assistant_message: {
id: 'm2',
role: 'assistant',
content: '结合档案的回复',
thread_id: 't1',
created_at: '',
},
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
})
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('剩余 3 次')
await w.find('textarea').setValue('你好')
await w.find('form').trigger('submit')
await flushPromises()
expect(api.createAskThread).toHaveBeenCalled()
expect(w.text()).toContain('结合档案的回复')
expect(w.text()).toContain('剩余 2 次')
})
it('shows boot error with retry', async () => {
api.listProfiles.mockRejectedValueOnce(new Error('网络错误'))
api.getAskQuota.mockRejectedValueOnce(new Error('网络错误'))
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('网络错误')
api.listProfiles.mockResolvedValue({ items: [] })
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
await w.find('button.link').trigger('click')
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
})
})
+206 -10
View File
@@ -1,17 +1,213 @@
<template>
<main class="page">
<h1>问答</h1>
<p class="sub">AI 成长助手 · 结合你的档案回答占位</p>
<div class="card">
预置场景认识自己 · 理解关系 · 职业探索 · 情绪整理 · 生活建议
可切换我的档案 / TA 的档案深度分析为会员权益
<main class="yxg-page ask">
<div class="yxg-hero">
<PageTitle feature="ask" title="问答" sub="AI 成长助手 · 结合档案回答" />
<p v-if="quota" class="yxg-meta quota">
剩余 {{ quota.remaining }}
<span v-if="!quota.active_membership">免费 {{ quota.free_limit }} </span>
</p>
</div>
<div class="yxg-sheet ask-sheet">
<p v-if="bootLoading" class="yxg-hint pad">加载中</p>
<p v-else-if="bootError" class="yxg-err pad">
{{ bootError }}
<button type="button" class="yxg-link" @click="boot">重试</button>
</p>
<template v-else-if="!profiles.length">
<div class="yxg-card empty">
<p>还没有个人档案请先完成性格探索再来提问</p>
<router-link class="yxg-btn" to="/">去首页探索</router-link>
</div>
</template>
<template v-else>
<div class="yxg-card controls">
<label class="yxg-label">选择档案</label>
<select class="yxg-select" v-model="profileId" @change="onProfileChange">
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
</option>
</select>
<label class="yxg-label">场景</label>
<div class="scenes">
<button
v-for="s in scenes"
:key="s.key"
type="button"
class="yxg-chip yxg-chip-solid"
:class="{ on: scene === s.key }"
@click="pickScene(s)"
>
<span aria-hidden="true">{{ s.icon }} </span>{{ s.label }}
</button>
</div>
</div>
<div class="thread" ref="threadEl">
<p v-if="!messages.length && !sending" class="yxg-hint empty-msg">
选一个场景或直接输入你的问题
</p>
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
<p>{{ m.content }}</p>
</div>
<p v-if="sending" class="yxg-hint pad">助手正在回复</p>
</div>
<p v-if="sendError" class="yxg-err pad">
{{ sendError }}
<router-link v-if="quotaExhausted" class="yxg-link" to="/membership">开通成长会员</router-link>
</p>
<form class="composer" @submit.prevent="send">
<textarea
class="yxg-textarea"
v-model="draft"
rows="2"
placeholder="例如:我和 TA 沟通时容易卡住,该怎么调整?"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
/>
<button
class="yxg-btn"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
const scenes = [
{ key: 'self', icon: '△', label: '认识自己', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', icon: '♡', label: '理解关系', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'career', icon: '▽', label: '职业探索', prompt: '从我的特点看,职业选择上可以注意什么?' },
{ key: 'emotion', icon: '○', label: '情绪整理', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'life', icon: '☯', label: '生活建议', prompt: '想改善作息和日常节奏,有什么建议?' },
{ key: 'dream', icon: '✦', label: '梦境整理', prompt: '昨晚的梦让我有些在意,想从感受层面整理一下,不是解梦预测。' },
{ key: 'family', icon: '◎', label: '原生家庭', prompt: '想梳理原生家庭模式对我沟通与边界的影响。' },
{ key: 'values', icon: '◈', label: '价值澄清', prompt: '我想弄清现阶段什么对我真正重要。' },
] as const
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref<Profile[]>([])
const profileId = ref('')
const scene = ref('self')
const threadId = ref('')
const messages = ref<AskMessage[]>([])
const draft = ref('')
const sending = ref(false)
const sendError = ref('')
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
const threadEl = ref<HTMLElement | null>(null)
async function boot() {
bootLoading.value = true
bootError.value = ''
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bootLoading.value = false
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
sendError.value = ''
}
function pickScene(s: (typeof scenes)[number]) {
scene.value = s.key
draft.value = s.prompt
threadId.value = ''
messages.value = []
}
async function ensureThread(): Promise<string> {
if (threadId.value) return threadId.value
if (!profileId.value) throw new Error('请先选择档案')
const th = await api.createAskThread({ profile_id: profileId.value, scene: scene.value })
threadId.value = th.id
return th.id
}
async function scrollBottom() {
await nextTick()
const el = threadEl.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
const content = draft.value.trim()
if (!content || sending.value) return
sending.value = true
sendError.value = ''
quotaExhausted.value = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
await scrollBottom()
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
} finally {
sending.value = false
}
}
onMounted(boot)
</script>
<style scoped>
.page{padding:24px 16px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#666;line-height:1.6}
.ask{padding-bottom:24px}
.quota{margin-top:4px}
.ask-sheet{padding-bottom:100px;min-height:60vh}
.pad{padding:0 16px}
.controls{margin-top:8px}
.controls .yxg-label:first-child{margin-top:0}
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}
.scenes .yxg-chip.on{
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;border-color:transparent;
}
.thread{overflow-y:auto;max-height:42vh;padding:4px 16px 12px}
.bubble{
margin:8px 0;padding:12px 14px;border-radius:16px;
font-size:14px;line-height:1.65;white-space:pre-wrap;
box-shadow:var(--shadow-card);
}
.bubble.user{background:#fff;margin-left:28px;color:#333}
.bubble.assistant{background:#fff5f4;margin-right:28px;color:#444;box-shadow:none}
.empty-msg{text-align:center;padding:28px 16px}
.empty .yxg-btn{margin-top:12px}
.composer{
display:flex;gap:8px;align-items:flex-end;
position:sticky;bottom:72px;
margin:0 16px;padding:10px 0 4px;
background:linear-gradient(180deg,transparent, #fff 28%);
}
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
</style>
@@ -0,0 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import CompanionPage from './CompanionPage.vue'
const { api } = vi.hoisted(() => ({
api: {
getSolarTermsToday: vi.fn(),
getMoodToday: vi.fn(),
saveMood: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/companion' }),
useRouter: () => ({ push: vi.fn() }),
}))
describe('CompanionPage', () => {
beforeEach(() => vi.clearAllMocks())
it('shows solar term and saves mood', async () => {
api.getSolarTermsToday.mockResolvedValue({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })
api.getMoodToday.mockResolvedValue({ mood: null })
api.saveMood.mockResolvedValue({ id: 'm1', day: '2026-08-02', score: 4 })
const w = mount(CompanionPage)
await flushPromises()
expect(w.text()).toContain('立秋')
expect(w.text()).toContain('收敛节奏')
await w.findAll('button.score')[3].trigger('click') // score 4
await w.find('button.save').trigger('click')
await flushPromises()
expect(api.saveMood).toHaveBeenCalledWith({ score: 4, note: undefined })
expect(w.text()).toContain('已保存')
})
})
+154 -8
View File
@@ -1,14 +1,160 @@
<template>
<main class="page">
<h1>陪伴</h1>
<p class="sub">节气生活 · 心情记录占位</p>
<div class="card">今日节气与生活建议将由 API 下发第二阶段完善心情与成长记录</div>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="companion" title="陪伴" sub="节气生活 · 心情记录" />
</div>
<div class="yxg-sheet">
<div class="yxg-card term-card">
<p class="term"><span class="tm icon-green" aria-hidden="true"></span>今日 · {{ term.name }}</p>
<p class="yxg-meta date">{{ todayLabel }}</p>
<p class="tip">{{ term.tip }}</p>
</div>
<div class="yxg-card">
<p class="sec-title"><span aria-hidden="true"> </span>今日心情</p>
<div class="scores">
<button
v-for="n in 5"
:key="n"
type="button"
class="score"
:class="{ on: score === n }"
@click="score = n"
>{{ moodMarks[n - 1] }}</button>
</div>
<p class="yxg-meta score-hint">{{ score ? `已选 ${score}` : '点选此刻感受(15' }}</p>
<textarea
class="yxg-textarea"
v-model="note"
rows="3"
maxlength="200"
placeholder="可选:写一两句此刻的感受"
/>
<button type="button" class="yxg-btn yxg-btn-soft save" :disabled="saving || !score" @click="save">
{{ saving ? '保存中' : saved ? '已保存' : '保存心情' }}
</button>
<p v-if="moodErr" class="yxg-err">{{ moodErr }}</p>
</div>
<div v-if="recent.length" class="yxg-card">
<p class="sec-title"><span aria-hidden="true"> </span>近七日心情</p>
<ul class="trail">
<li v-for="m in recent" :key="m.id">
<span class="d">{{ m.day }}</span>
<span class="s">{{ m.score ?? '—' }}</span>
<span class="n">{{ m.note || '' }}</span>
</li>
</ul>
</div>
<div class="yxg-card soft links">
<p>想继续整理感受可以去问答聊几句</p>
<router-link class="yxg-link-plain" to="/ask"><span aria-hidden="true"> </span> AI 成长助手 </router-link>
<router-link class="yxg-link-plain" to="/rhythm"><span aria-hidden="true"> </span>身心节律探索 </router-link>
<router-link class="yxg-link-plain" to="/growth-plan"><span aria-hidden="true"> </span>成长计划 </router-link>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import { track } from '../lib/analytics'
const moodMarks = ['◦', '○', '◎', '●', '◉']
const term = ref({ name: '…', tip: '加载中…', date: '' })
const score = ref<number | null>(null)
const note = ref('')
const saving = ref(false)
const saved = ref(false)
const moodErr = ref('')
const recent = ref<{ id: string; day: string; score?: number; note?: string }[]>([])
const today = new Date()
const todayLabel = computed(() =>
today.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }),
)
async function load() {
track('companion_viewed')
try {
term.value = await api.getSolarTermsToday()
} catch {
term.value = { name: '今日', tip: '给身心留一点缓冲,慢一点也很好。', date: '' }
}
try {
const res = await api.getMoodToday()
if (res.mood?.score) score.value = res.mood.score
if (res.mood?.note) note.value = res.mood.note
if (res.mood) saved.value = true
} catch {
/* optional */
}
try {
const trail = await api.getMoodsRecent()
recent.value = trail.items || []
} catch {
recent.value = []
}
}
async function save() {
if (!score.value) return
saving.value = true
moodErr.value = ''
saved.value = false
try {
await api.saveMood({ score: score.value, note: note.value || undefined })
saved.value = true
track('mood_saved', { score: score.value })
const trail = await api.getMoodsRecent()
recent.value = trail.items || []
} catch (e) {
moodErr.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:24px 16px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#666;line-height:1.6}
.term-card{margin-top:8px}
.term{
font-family:var(--font-display);
font-size:18px;font-weight:700;color:#3d6b45;
display:flex;align-items:center;gap:8px;letter-spacing:.04em;
}
.tm{
width:28px;height:28px;border-radius:10px;display:inline-flex;align-items:center;justify-content:center;
font-size:15px;font-family:var(--font-sans);box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
}
.date{margin:6px 0 10px}
.tip{color:#444;line-height:1.7}
.sec-title{font-size:15px;font-weight:650;color:#222;margin-bottom:12px}
.scores{display:flex;gap:8px;margin-bottom:6px}
.score{
width:44px;height:44px;border-radius:14px;border:1px solid #eee;background:#fafafa;
font-weight:600;color:#666;font-size:16px;cursor:pointer;
}
.score.on{
border-color:var(--color-accent-green);
background:var(--color-accent-green-soft);
color:#3d6b45;
}
.score-hint{margin-bottom:10px}
.save{margin-top:12px}
.links{display:flex;flex-direction:column;gap:8px;color:#666}
.trail{list-style:none;margin:0;padding:0;font-size:13px}
.trail li{
display:grid;grid-template-columns:96px 28px 1fr;gap:8px;
padding:10px 0;border-bottom:1px solid #f3f3f3;color:#666;
}
.trail li:last-child{border-bottom:none}
.trail .d{color:#999}
.trail .s{font-weight:700;color:#3d6b45}
</style>
@@ -0,0 +1,91 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle
v-if="cat"
:icon="cat.icon"
:tone="(cat.tone as any)"
:title="cat.title"
:sub="cat.description"
/>
</div>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">{{ error }}</p>
<ul v-else-if="cat" class="yxg-list">
<li v-for="it in cat.items" :key="it.key">
<router-link :to="it.path">
<span class="yxg-mark" :class="'icon-' + it.tone" aria-hidden="true">{{ it.icon }}</span>
<span class="yxg-body">
<strong>
{{ it.title }}
<span v-if="it.badge" class="badge">{{ it.badge }}</span>
</strong>
<span class="desc">{{ it.description }}</span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
type Item = {
key: string
title: string
description: string
path: string
icon: string
tone: string
badge?: string
}
type Cat = {
key: string
title: string
description: string
icon: string
tone: string
items: Item[]
}
const route = useRoute()
const cat = ref<Cat | null>(null)
const loading = ref(true)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
cat.value = null
try {
cat.value = await api.getExploreCategory(String(route.params.category))
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.category, load)
</script>
<style scoped>
.pad{padding:8px 16px}
.yxg-list{margin-top:8px}
.badge{
font-size:10px;background:linear-gradient(135deg,#FF7A6E,var(--yxg-pri));
color:#fff;padding:1px 6px;border-radius:6px;margin-left:4px;font-weight:600;
vertical-align:middle;
}
</style>
+48 -24
View File
@@ -1,42 +1,66 @@
<template>
<main class="page">
<h1>探索</h1>
<p class="sub">性格探索 · 探索测试 · 关系理解 · 个人画像</p>
<p v-if="error" class="err">{{ error }}</p>
<ul class="list">
<li><router-link to="/portrait">性格探索 / 个人画像</router-link></li>
<li><router-link to="/relation">关系理解</router-link></li>
<li v-for="s in scales" :key="s.slug">
<router-link :to="`/scales/${s.slug}`">{{ s.title }}</router-link>
<span class="desc">{{ s.description }}</span>
</li>
</ul>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="explore" title="探索" sub="六大分类 · 找到适合你的自我理解方式" />
</div>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<ul v-else class="yxg-list">
<li v-for="c in categories" :key="c.key">
<router-link :to="`/explore/${c.key}`">
<span class="yxg-mark" :class="'icon-' + c.tone" aria-hidden="true">{{ c.icon }}</span>
<span class="yxg-body">
<strong>{{ c.title }}</strong>
<span class="desc">{{ c.description }} · {{ c.items?.length || 0 }} </span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
const scales = ref<{ slug: string; title: string; description: string }[]>([])
type Cat = {
key: string
title: string
description: string
icon: string
tone: string
items?: unknown[]
}
const categories = ref<Cat[]>([])
const loading = ref(true)
const error = ref('')
onMounted(async () => {
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listScales()
scales.value = res.items || []
const res = await api.getExploreCatalog()
categories.value = res.categories || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
})
}
onMounted(load)
</script>
<style scoped>
.page{padding:24px 16px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
.err{color:var(--yxg-pri);font-size:13px}
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:1.8;font-size:14px;color:#555}
.list a{color:inherit;text-decoration:none;font-weight:600}
.desc{display:block;font-size:12px;color:#aaa;font-weight:400;margin-bottom:8px}
.pad{padding:8px 16px}
.yxg-list{margin-top:8px}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="growth" title="成长计划" sub="小目标 · 每日打卡(生活成长,非运势)" />
</div>
<div class="yxg-sheet">
<div class="yxg-card">
<label class="yxg-label">新计划标题</label>
<input class="yxg-input" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<label class="yxg-label">焦点可选</label>
<input class="yxg-input" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
<button class="yxg-btn" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
<p v-if="err" class="yxg-err">{{ err }}</p>
</div>
<p v-if="loading" class="yxg-hint pad">加载中</p>
<ul v-else class="yxg-list plans">
<li v-for="p in plans" :key="p.id" class="plan">
<strong> {{ p.title }}</strong>
<p v-if="p.focus" class="focus">{{ p.focus }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="checking === p.id" @click="checkin(p.id)">
{{ checking === p.id ? '记录中' : '今日打卡' }}
</button>
<ul v-if="checkins[p.id]?.length" class="cks">
<li v-for="c in checkins[p.id]" :key="c.id">{{ c.day }}{{ c.note ? ' · ' + c.note : '' }}</li>
</ul>
</li>
<li v-if="!plans.length" class="empty">还没有计划先创建一个小目标吧</li>
</ul>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
const plans = ref<{ id: string; title: string; focus: string }[]>([])
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
const title = ref('')
const focus = ref('')
const loading = ref(true)
const saving = ref(false)
const checking = ref('')
const err = ref('')
async function load() {
loading.value = true
try {
const res = await api.listGrowthPlans()
plans.value = res.items || []
for (const p of plans.value) {
const ck = await api.listGrowthCheckins(p.id)
checkins.value[p.id] = ck.items || []
}
} catch (e) {
err.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function create() {
saving.value = true
err.value = ''
try {
await api.createGrowthPlan({ title: title.value.trim(), focus: focus.value.trim() || undefined })
title.value = ''
focus.value = ''
await load()
} catch (e) {
err.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function checkin(id: string) {
checking.value = id
err.value = ''
try {
await api.growthCheckin(id, {})
const ck = await api.listGrowthCheckins(id)
checkins.value[id] = ck.items || []
} catch (e) {
err.value = e instanceof Error ? e.message : '打卡失败'
} finally {
checking.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.yxg-card{margin-top:8px}
.yxg-card .yxg-label:first-child{margin-top:0}
.yxg-btn{margin-top:12px}
.pad{padding:0 16px}
.plans{list-style:none}
.plan{display:block}
.plan strong{font-size:15px;color:#222}
.focus{font-size:13px;color:#888;margin:6px 0}
.cks{margin-top:8px;padding-left:16px;font-size:12px;color:#999}
.empty{color:#999;font-size:13px;padding:8px 4px}
</style>
+319 -73
View File
@@ -1,65 +1,174 @@
<template>
<!-- 测测式首页暖色顶区 建档卡 chips sheet金刚区 / feed / 横滑 -->
<main class="home">
<header class="brand">
<div class="word"><span></span></div>
<p class="tag">认识自己 · 理解他人</p>
</header>
<span class="glow g1" aria-hidden="true" />
<span class="glow g2" aria-hidden="true" />
<section class="portrait-card">
<h2>性格探索</h2>
<p class="sub">填写生日生成你的成长画像</p>
<div class="row">
<input v-model="year" type="number" placeholder="1990" />
<span></span>
<input v-model="month" type="number" placeholder="1" />
<span></span>
<input v-model="day" type="number" placeholder="1" />
<span></span>
<button type="button" @click="goPortrait">开始</button>
<header class="top">
<router-link to="/profile" class="avatar" aria-label="个人档案"></router-link>
<div class="brand">
<BrandLogo size="header" class="home-logo" />
</div>
<router-link to="/reports" class="side-link" aria-label="我的报告">报告</router-link>
</header>
<p class="tagline">愈见自己 · 遇见更好</p>
<section class="decode">
<div class="di-title">愈心解码</div>
<div class="di-sub">一个生日读懂性格与节奏</div>
<div class="di-inputs">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button type="button" @click="goPortrait">生成</button>
</div>
<p v-if="err" class="err">{{ err }}</p>
</section>
<section class="block">
<div class="head"><h3>快捷入口</h3><router-link to="/explore">全部</router-link></div>
<div class="grid">
<router-link v-for="t in entries" :key="t.to" :to="t.to" class="cell">
<div class="icon" :style="{ background: t.bg, color: t.fg }">{{ t.icon }}</div>
<span>{{ t.label }}</span>
</router-link>
</div>
</section>
<nav class="chips" aria-label="快捷分类">
<a
v-for="c in chips"
:key="c.key"
href="#"
class="chip"
:class="{ on: chipOn === c.key }"
@click.prevent="onChip(c)"
>{{ c.label }}</a>
</nav>
<section class="block">
<div class="head"><h3>服务状态</h3></div>
<p class="api">API{{ apiStatus }}</p>
</section>
<div class="sheet">
<section class="section" id="grid">
<div class="sec-head">
<span class="title">认识自己</span>
<router-link class="more" to="/explore">全部</router-link>
</div>
<div class="test-grid">
<router-link v-for="t in entries" :key="t.to" :to="t.to" class="test-item">
<div class="icon" :class="'icon-' + t.tone" aria-hidden="true">{{ t.icon }}</div>
<div class="label">{{ t.label }}</div>
<span v-if="t.badge" class="badge">{{ t.badge }}</span>
</router-link>
</div>
</section>
<section class="section">
<div class="sec-head">
<span class="title">热门推荐</span>
<router-link class="more" to="/explore">更多</router-link>
</div>
<div class="feed">
<router-link v-for="f in feeds" :key="f.to" :to="f.to" class="feed-card" :class="f.tone">
<span v-if="f.tag" class="feed-tag">{{ f.tag }}</span>
<div class="feed-cover" aria-hidden="true">{{ f.icon }}</div>
<div class="feed-body">
<div class="name">{{ f.title }}</div>
<div class="meta">{{ f.meta }}</div>
<div class="stat">{{ f.stat }}</div>
</div>
</router-link>
</div>
</section>
<section class="section">
<div class="sec-head"><span class="title">精选工具</span></div>
<div class="scroll-row">
<router-link v-for="m in minis" :key="m.to" :to="m.to" class="mini-card" :class="m.tone">
<div class="mi" aria-hidden="true">{{ m.icon }}</div>
<div class="mt">{{ m.title }}</div>
<div class="md">{{ m.desc }}</div>
</router-link>
</div>
</section>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BrandLogo from '../components/BrandLogo.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { IconTone } from '../lib/featureIcons'
const router = useRouter()
const year = ref('')
const month = ref('')
const day = ref('')
const err = ref('')
const apiStatus = ref('检测中…')
const chipOn = ref('decode')
const entries = [
{ to: '/portrait', icon: '△', label: '性格探索', bg: '#FFF3D6', fg: '#C8923A' },
{ to: '/explore', icon: '◆', label: '人格测评', bg: '#E3EEFF', fg: '#4A90E2' },
{ to: '/relation', icon: '◇', label: '关系理解', bg: '#FFE8D6', fg: '#E8985A' },
{ to: '/ask', icon: '问', label: 'AI问答', bg: '#FFE4E4', fg: '#E54D42' },
{ to: '/companion', icon: '☯', label: '节气陪伴', bg: '#E0F6E4', fg: '#5CB85C' },
{ to: '/profile', icon: '◍', label: '个人档案', bg: '#F0F4F8', fg: '#5A6A7A' },
{ to: '/membership', icon: '◇', label: '成长会员', bg: '#F5F0FF', fg: '#8B5CF6' },
const chips = [
{ key: 'decode', label: '愈心解码', to: '/portrait' },
{ key: 'star', label: '星座', to: '/star' },
{ key: 'synastry', label: '合盘', to: '/synastry' },
{ key: 'match', label: '人格匹配', to: '/relation' },
{ key: 'ask', label: 'AI 问答', to: '/ask' },
{ key: 'more', label: '更多测评', to: '/explore/tests' },
]
/** 金刚区:四核心优先;星座为单入口(太阳/月亮/上升在结果页内分维) */
const entries: { to: string; icon: string; label: string; tone: IconTone; badge?: string }[] = [
{ to: '/portrait', icon: '△', label: '愈心解码', tone: 'gold', badge: '热' },
{ to: '/star', icon: '✦', label: '星座', tone: 'night', badge: 'AI' },
{ to: '/synastry', icon: '✧', label: '合盘', tone: 'night', badge: '新' },
{ to: '/relation', icon: '♡', label: '人格匹配', tone: 'rose', badge: '热' },
{ to: '/ask', icon: '◎', label: 'AI问答', tone: 'gold' },
{ to: '/rhythm', icon: '☯', label: '身心节律', tone: 'green' },
{ to: '/cards', icon: '◈', label: '意象卡片', tone: 'teal' },
{ to: '/companion', icon: '♡', label: '节气陪伴', tone: 'pink' },
{ to: '/growth-plan', icon: '▽', label: '成长计划', tone: 'teal' },
{ to: '/membership', icon: '⑨', label: '成长会员', tone: 'purple' },
{ to: '/reports', icon: '☰', label: '我的报告', tone: 'mute' },
{ to: '/profile', icon: '◍', label: '个人档案', tone: 'orange' },
{ to: '/explore/tests', icon: '⋯', label: '更多测评', tone: 'mute' },
]
const feeds = [
{
to: '/portrait',
icon: '△',
title: '愈心解码',
meta: '一个生日,读懂性格与身心节奏',
stat: '核心入口',
tone: 'fc-e',
tag: '热',
},
{
to: '/star',
icon: '✦',
title: '星座排盘',
meta: '圆形本命盘 · 相位 · 日周月年与一生运势',
stat: '本周热门',
tone: 'fc-b',
tag: '新',
},
{
to: '/synastry',
icon: '✧',
title: '合盘',
meta: '比较盘 · 恋爱 / 友情 / 婚姻指数',
stat: '了解彼此',
tone: 'fc-c',
tag: '新',
},
]
const minis = [
{ to: '/portrait', icon: '△', title: '愈心解码', desc: '生日生成性格解码', tone: 'mc-sand' },
{ to: '/star', icon: '✦', title: '星座', desc: '排盘与运势', tone: 'mc-sky' },
{ to: '/synastry', icon: '✧', title: '合盘', desc: '五主盘 · 推运 · 三指数', tone: 'mc-coral' },
{ to: '/ask', icon: '◎', title: 'AI 问答', desc: '结合档案聊聊卡住的事', tone: 'mc-mint' },
]
function onChip(c: (typeof chips)[number]) {
chipOn.value = c.key
if (c.to.startsWith('#')) {
document.querySelector(c.to)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
router.push(c.to)
}
function goPortrait() {
const y = Number(year.value)
const m = Number(month.value)
@@ -70,49 +179,186 @@ function goPortrait() {
return
}
err.value = ''
track(AnalyticsEvent.HomeCtaPortrait)
router.push({ path: '/portrait', query: { y: String(y), m: String(m), d: String(d) } })
}
onMounted(async () => {
try {
const data = await api.healthz()
apiStatus.value = data.status === 'ok' ? '已连接' : '异常'
} catch {
apiStatus.value = '未连接(请启动 apps/api'
}
})
</script>
<style scoped>
.home{padding:18px 16px 8px}
.brand{text-align:center;margin-bottom:14px}
.word{font-size:28px;font-weight:700;letter-spacing:.18em}
.word span{color:var(--yxg-pri)}
.tag{font-size:12px;color:rgba(0,0,0,.38);margin-top:6px;letter-spacing:.12em}
.portrait-card{
background:#fff;border-radius:20px;padding:18px 14px;text-align:center;
box-shadow:0 8px 28px rgba(229,77,66,.12);
.home{
position:relative;overflow-x:hidden;padding-bottom:8px;
background:
radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.55) 0%, transparent 55%),
radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.7) 0%, transparent 50%),
linear-gradient(180deg, #ffd6cc 0%, #ffe8e0 28%, #fff6f2 55%, #fff9f7 100%);
margin-top:-4px;
}
.portrait-card h2{font-size:17px;color:var(--yxg-gold);letter-spacing:.1em}
.sub{font-size:12px;color:#aaa;margin:4px 0 14px}
.row{display:flex;gap:6px;align-items:center;justify-content:center;flex-wrap:wrap}
.row input{
width:56px;padding:10px 4px;border:1.5px solid #eee;border-radius:12px;
text-align:center;font-size:15px;outline:none;
.glow{
position:absolute;pointer-events:none;border-radius:50%;filter:blur(2px);z-index:0;
}
.row button{
padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
.glow.g1{width:140px;height:140px;top:40px;right:-30px;background:rgba(255,255,255,.45)}
.glow.g2{width:90px;height:90px;top:100px;left:-20px;background:rgba(255,200,180,.4)}
/* 顶栏:左档案 · 中品牌 · 右报告(测测式工具条,非营销大标题) */
.top{
display:grid;grid-template-columns:48px 1fr 48px;align-items:center;
padding:10px 14px 0;position:relative;z-index:2;
}
.avatar,.side-link{
width:36px;height:36px;border-radius:12px;
display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,.72);border:1px solid rgba(255,255,255,.9);
font-size:12px;color:#666;backdrop-filter:blur(6px);
}
.avatar{font-size:16px}
.side-link{font-size:11px;font-weight:600;letter-spacing:.02em}
.brand{
display:flex;align-items:center;justify-content:center;min-height:36px;
}
.brand :deep(img.brand-logo){
width:min(160px,54vw);height:auto;max-height:36px;
object-fit:contain;
}
.tagline{
text-align:center;margin:6px 0 0;font-size:12px;
color:rgba(0,0,0,.38);letter-spacing:.12em;position:relative;z-index:2;
}
/* 建档主卡 */
.decode{
margin:14px 16px 0;position:relative;z-index:2;
padding:18px 16px 16px;background:#fff;border-radius:20px;
box-shadow:0 8px 28px rgba(229,77,66,.12);text-align:center;
animation:bdayIn .5s ease both;
}
.di-title{
font-family:var(--font-display);
font-size:17px;font-weight:700;color:#c8923a;letter-spacing:.1em;
}
.di-sub{font-size:12px;color:#aaa;margin:4px 0 14px}
.di-inputs{display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap}
.di-inputs button{
padding:11px 18px;border:none;border-radius:22px;color:#fff;font-size:14px;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
box-shadow:0 6px 16px rgba(229,77,66,.28);
}
.err{color:var(--yxg-pri);font-size:12px;margin-top:8px}
.block{margin-top:22px;background:#fff;border-radius:18px;padding:16px;box-shadow:0 2px 10px rgba(0,0,0,.04)}
.head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.head h3{font-size:16px}
.head a{font-size:12px;color:#bbb}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 8px}
.cell{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:11px;color:#555}
.icon{
width:52px;height:52px;border-radius:18px;display:flex;align-items:center;justify-content:center;font-size:20px;
@keyframes bdayIn{
from{opacity:0;transform:translateY(8px)}
to{opacity:1;transform:translateY(0)}
}
/* chipssheet 外、暖色底上 */
.chips{
display:flex;gap:8px;padding:14px 16px 4px;
overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;
position:relative;z-index:2;
}
.chips::-webkit-scrollbar{display:none}
.chip{
flex-shrink:0;padding:7px 14px;border-radius:999px;
background:rgba(255,255,255,.72);border:1px solid rgba(255,255,255,.9);
font-size:12px;color:#666;text-decoration:none;
backdrop-filter:blur(6px);
}
.chip.on{background:#fff;color:var(--yxg-pri);font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.04)}
/* 白 sheet */
.sheet{
margin-top:14px;background:#fff;
border-radius:22px 22px 0 0;
padding:8px 0 28px;
box-shadow:0 -4px 24px rgba(180,100,80,.06);
position:relative;z-index:3;
animation:sheetUp .55s cubic-bezier(.22,.8,.28,1) both;
}
@keyframes sheetUp{
from{opacity:0;transform:translateY(24px)}
to{opacity:1;transform:translateY(0)}
}
.section{padding:0 16px;margin-top:20px}
.sec-head{
display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;
}
.sec-head .title{font-size:17px;font-weight:700;color:#222;letter-spacing:.02em}
.sec-head .more{font-size:12px;color:#bbb;text-decoration:none}
.sec-head .more::after{content:" "}
.test-grid{
display:grid;grid-template-columns:repeat(4,1fr);gap:14px 8px;padding:4px 2px 2px;
}
.test-item{
display:flex;flex-direction:column;align-items:center;gap:8px;
color:#333;position:relative;
}
.test-item:active{transform:scale(.92)}
.test-item .icon{
width:54px;height:54px;border-radius:18px;
display:flex;align-items:center;justify-content:center;
font-size:22px;box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
}
.test-item .label{
font-size:11px;color:#555;text-align:center;line-height:1.25;
max-width:68px;word-break:keep-all;
}
.badge{
position:absolute;top:-4px;right:6px;
font-size:9px;background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
color:#fff;padding:1px 5px;border-radius:6px;line-height:1.4;
box-shadow:0 2px 6px rgba(229,77,66,.3);
}
.feed{display:flex;flex-direction:column;gap:12px}
.feed-card{
display:flex;gap:14px;align-items:stretch;
padding:12px;background:#fafafa;border-radius:16px;
color:#333;position:relative;overflow:hidden;
}
.feed-card:active{transform:scale(.985);background:#f5f5f5}
.feed-cover{
width:72px;height:72px;border-radius:14px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:28px;position:relative;overflow:hidden;
}
.feed-cover::after{
content:"";position:absolute;inset:0;
background:linear-gradient(160deg,rgba(255,255,255,.35),transparent 50%);
}
.feed-body{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;padding:2px 0}
.feed-body .name{font-size:15px;font-weight:650;color:#222;line-height:1.35}
.feed-body .meta{font-size:12px;color:#999;margin-top:4px;line-height:1.4}
.feed-body .stat{font-size:11px;color:#c4c4c4;margin-top:6px}
.feed-tag{
position:absolute;top:10px;right:10px;
font-size:10px;color:#fff;background:var(--yxg-pri);
padding:2px 7px;border-radius:8px;
}
.fc-a .feed-cover{background:linear-gradient(145deg,#ffd0c8,#ffb0a4);color:#c23b32}
.fc-b .feed-cover{background:linear-gradient(145deg,#e4d8f8,#c9b6f0);color:#5b4a8a}
.fc-c .feed-cover{background:linear-gradient(145deg,#d6e8ff,#b0d0f5);color:#3a6fb0}
.fc-e .feed-cover{background:linear-gradient(145deg,#ffe6c8,#f5c98a);color:#b07a28}
.scroll-row{
display:flex;gap:10px;overflow-x:auto;padding:2px 0 4px;
-webkit-overflow-scrolling:touch;scrollbar-width:none;
}
.scroll-row::-webkit-scrollbar{display:none}
.mini-card{
flex:0 0 148px;padding:14px 14px 16px;border-radius:16px;color:#333;
}
.mini-card:active{transform:scale(.97)}
.mini-card .mi{font-size:22px;margin-bottom:8px}
.mini-card .mt{font-size:14px;font-weight:650}
.mini-card .md{font-size:11px;color:rgba(0,0,0,.4);margin-top:3px;line-height:1.35}
.mc-coral{background:linear-gradient(160deg,#ffe8e2,#ffd4ca)}
.mc-mint{background:linear-gradient(160deg,#e4f6ea,#d0edd8)}
.mc-sky{background:linear-gradient(160deg,#e6f0ff,#d4e4ff)}
.mc-sand{background:linear-gradient(160deg,#fff3e0,#ffe4c2)}
@media (max-width:380px){
.word{font-size:18px}
.test-item .icon{width:48px;height:48px;font-size:20px;border-radius:16px}
.test-item .label{font-size:10px}
.feed-cover{width:64px;height:64px}
}
.api{font-size:13px;color:var(--yxg-sub)}
</style>
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ImageCardPage from './ImageCardPage.vue'
const { api } = vi.hoisted(() => ({
api: {
listImageCardScenes: vi.fn(),
getImageCardQuota: vi.fn(),
createProfile: vi.fn(),
drawImageCard: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
getReport: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
}))
describe('ImageCardPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.listImageCardScenes.mockResolvedValue({
items: [
{ key: '情绪整理', label: '情绪整理' },
{ key: '关系', label: '关系' },
],
})
api.getImageCardQuota.mockResolvedValue({ remaining: 3, daily_free: 3, unlimited: false })
})
it('boots scenes and draws a card', async () => {
api.createProfile.mockResolvedValue({ id: 'p1' })
api.drawImageCard.mockResolvedValue({
scene: '情绪整理',
quota_left: 2,
cards: [
{
id: 'c01',
title: '微光小路',
imagery: '一条小路',
prompt: '你想靠近什么?',
tip: '走一小步',
},
],
report: {
id: 'r-card',
has_deep_access: false,
summary: { headline: '意象·探索' },
detail: null,
},
})
const w = mount(ImageCardPage)
await flushPromises()
expect(w.text()).toContain('今日剩余')
expect(w.text()).toContain('情绪整理')
const inputs = w.findAll('input[type="number"]')
await inputs[0].setValue('1990')
await inputs[1].setValue('5')
await inputs[2].setValue('12')
await w.findAll('button').find((b) => b.text().includes('抽取卡片'))!.trigger('click')
await flushPromises()
expect(api.drawImageCard).toHaveBeenCalled()
expect(w.text()).toContain('微光小路')
expect(w.text()).toContain('走一小步')
})
})
+178
View File
@@ -0,0 +1,178 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="cards" title="意象卡片" sub="选一个场景,抽取一张(或组合)卡片做反思练习" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="bootError" class="yxg-err">{{ bootError }}</p>
<template v-else>
<p class="yxg-meta quota">今日剩余 {{ quotaLabel }}</p>
<div class="scenes">
<button
v-for="s in scenes"
:key="s.key"
type="button"
class="yxg-chip yxg-chip-solid scene"
:class="{ on: scene === s.key }"
@click="selectScene(s.key)"
><span class="si icon-teal" aria-hidden="true"></span>{{ s.label }}</button>
</div>
<div class="yxg-card">
<label class="yxg-label">生日用于关联档案</label>
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<label class="chk"><input v-model="wantDepth" type="checkbox" /> 请求三卡组合会员直接解锁否则可模拟支付</label>
<button class="yxg-btn yxg-btn-block" type="button" :disabled="drawing" @click="draw">
{{ drawing ? '抽取中' : '抽取卡片' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
<template v-if="cards.length">
<article v-for="c in cards" :key="c.id" class="yxg-card">
<h2 class="card-title">{{ c.title }}</h2>
<p class="imagery">{{ c.imagery }}</p>
<p><strong>反思</strong> {{ c.prompt }}</p>
<p class="tip">{{ c.tip }}</p>
</article>
<ReportRich v-if="report" :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="report && !report.has_deep_access && wantDepth" class="yxg-card lock">
<p>三卡组合解读需深度版或成长会员</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁组合模拟支付</button>
</div>
<router-link v-if="report" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</template>
</template>
<p class="yxg-disc">意象卡片用于投射与自我反思不判定好坏也不预测未来</p>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
const year = ref('')
const month = ref('')
const day = ref('')
const wantDepth = ref(false)
const drawing = ref(false)
const paying = ref(false)
const error = ref('')
const bootError = ref('')
const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([])
const report = ref<GrowthReport | null>(null)
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const quotaLabel = computed(() => {
if (!quota.value) return '…'
if (quota.value.unlimited) return '不限(会员)'
return `${quota.value.remaining} / ${quota.value.daily_free}`
})
function selectScene(key: string) {
scene.value = key
track('cards_scene_selected', { scene: key })
}
async function refreshQuota() {
quota.value = await api.getImageCardQuota()
}
async function boot() {
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
if (scenes.value.length && !scenes.value.find((s) => s.key === scene.value)) {
scene.value = scenes.value[0].key
}
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
}
}
async function draw() {
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
drawing.value = true
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
const out = await api.drawImageCard({
profile_id: profile.id,
scene: scene.value,
depth: wantDepth.value,
})
cards.value = out.cards || []
report.value = out.report
quota.value = {
remaining: out.quota_left,
daily_free: quota.value?.daily_free || 3,
unlimited: !!quota.value?.unlimited,
}
track('cards_drawn', { scene: scene.value, depth: wantDepth.value ? 1 : 0 })
} catch (e) {
const m = e instanceof Error ? e.message : '抽取失败'
error.value = m
if (m.includes('次数')) track('cards_quota_exhausted')
} finally {
drawing.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'cards' })
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
}
}
onMounted(boot)
</script>
<style scoped>
.quota{margin:0 0 10px}
.scenes{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.scene{display:inline-flex;align-items:center;gap:6px}
.si{
width:22px;height:22px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;
font-size:12px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.03);
}
.yxg-card{margin:0 0 12px}
.yxg-card .yxg-label:first-child{margin-top:0}
:deep(.bd){margin:8px 0 12px}
.chk{display:flex;align-items:center;gap:8px;font-size:13px;color:#666;margin:4px 0 12px}
.imagery{color:#666;margin-bottom:8px}
.tip{color:#3d6b45;margin-top:6px}
.lock .yxg-btn{margin-top:12px;width:100%}
.report-link{display:block;margin-top:12px}
</style>
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import LifeRhythmPage from './LifeRhythmPage.vue'
const { api } = vi.hoisted(() => ({
api: {
createProfile: vi.fn(),
createRhythm: vi.fn(),
getReport: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' } }),
useRouter: () => ({ back: vi.fn() }),
}))
describe('LifeRhythmPage', () => {
beforeEach(() => vi.clearAllMocks())
it('creates rhythm report from birth query', async () => {
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createRhythm.mockResolvedValue({
id: 'r-rhythm',
type: 'rhythm',
has_deep_access: false,
summary: { headline: '节律标题', one_liner: '节律一句' },
detail: null,
})
const w = mount(LifeRhythmPage)
await flushPromises()
expect(api.createRhythm).toHaveBeenCalledWith('p1')
expect(w.text()).toContain('节律标题')
expect(w.text()).toContain('节气陪伴')
})
})
+140
View File
@@ -0,0 +1,140 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="rhythm" title="身心节律" :sub="needBirth && !report ? '填写生日,生成身心节律生活建议' : undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<template v-if="needBirth && !report">
<div class="yxg-card">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">开始</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在生成</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta head">{{ headline }}</p>
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整节律建议与练习可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
<router-link class="yxg-link-plain report-link" to="/companion">查看今日节气陪伴 </router-link>
<router-link v-if="report.id" class="yxg-link-plain report-link" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</template>
<p class="yxg-disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'rhythm' })
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
}
}
onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.mt{margin-top:14px}
.head{margin:4px 0 8px;font-size:13px}
.lock .yxg-btn{margin-top:12px;width:100%}
.report-link{display:block;margin-top:12px}
</style>
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import MembershipPage from './MembershipPage.vue'
const { api } = vi.hoisted(() => ({
api: {
getMembership: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
}))
describe('MembershipPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows subscribe CTA when inactive', async () => {
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
const w = mount(MembershipPage)
await flushPromises()
expect(w.text()).toContain('尚未开通')
expect(w.text()).toContain('开通月卡')
})
it('activates membership after mock pay', async () => {
api.getMembership
.mockResolvedValueOnce({ active: false, status: 'none' })
.mockResolvedValueOnce({
active: true,
status: 'active',
plan: 'month',
expires_at: '2099-01-01T00:00:00Z',
ask_quota_left: 100,
})
api.createOrder.mockResolvedValue({ order_id: 'om1' })
api.payMock.mockResolvedValue({ paid: true })
const w = mount(MembershipPage)
await flushPromises()
await w.findAll('button').find((b) => b.text().includes('开通月卡'))!.trigger('click')
await flushPromises()
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'membership', plan: 'month' })
expect(w.text()).toContain('会员有效')
expect(w.text()).toContain('月卡')
})
it('shows error with retry', async () => {
api.getMembership.mockRejectedValueOnce(new Error('网络错误'))
const w = mount(MembershipPage)
await flushPromises()
expect(w.text()).toContain('网络错误')
api.getMembership.mockResolvedValue({ active: false, status: 'none' })
await w.find('button.link').trigger('click')
await flushPromises()
expect(w.text()).toContain('尚未开通')
})
})
+87 -11
View File
@@ -1,18 +1,94 @@
<template>
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>成长会员</h1>
<p class="sub">完整分析 · AI成长助手更多次数 · 专属内容</p>
<div class="card">
/ / 年套餐与模拟支付将接入 /api/v1/orders亦可在成长报告内选择深度版单次完整分析
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="membership" title="成长会员" sub="完整分析 · AI成长助手更多次数 · 专属内容" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div v-if="me?.active" class="yxg-card ok">
<p class="ok-title">会员有效 · {{ planLabel }}</p>
<p v-if="me.expires_at" class="yxg-meta">到期{{ formatDate(me.expires_at) }}</p>
<p class="yxg-meta">问答额度剩余{{ me.ask_quota_left ?? 0 }}</p>
</div>
<div v-else class="yxg-card">
<p>尚未开通成长会员开通后可查看画像与关系理解的完整分析</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="subscribe('month')">开通月卡模拟支付</button>
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('quarter')">季卡</button>
<button class="yxg-btn yxg-btn-ghost" type="button" :disabled="paying" @click="subscribe('year')">年卡</button>
</div>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const loading = ref(true)
const paying = ref(false)
const error = ref('')
const me = ref<MembershipMe | null>(null)
const planLabel = computed(() => {
const p = me.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return p || '成长会员'
})
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('zh-CN')
} catch {
return iso
}
}
async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.getMembership()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function subscribe(plan: string) {
paying.value = true
error.value = ''
try {
const { order_id } = await api.createOrder({ kind: 'membership', plan })
await api.payMock(order_id)
me.value = await api.getMembership()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'membership' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
.yxg-card{margin-top:8px}
.ok{border:1px solid #c8e6c9;background:#f4fff4}
.ok-title{font-size:15px;font-weight:650;color:#222;margin-bottom:6px}
.yxg-btn{margin-top:12px;margin-right:8px}
</style>
+92 -14
View File
@@ -1,20 +1,98 @@
<template>
<main class="page">
<h1>我的</h1>
<p class="sub">个人档案 · 成长报告 · 成长会员</p>
<ul class="list">
<li><router-link to="/profile">个人档案</router-link></li>
<li><router-link to="/portrait">个人画像</router-link></li>
<li><router-link to="/relation">关系理解</router-link></li>
<li><router-link to="/membership">成长会员</router-link></li>
</ul>
<main class="yxg-page">
<div class="yxg-hero">
<PageTitle feature="profile" title="我的" sub="个人档案 · 成长报告 · 成长会员" />
</div>
<div class="yxg-sheet">
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div class="yxg-card member">
<p class="yxg-chip-ico">
<span class="yxg-mark icon-purple tiny" aria-hidden="true"></span>
<span class="member-text">
<strong v-if="membership?.active">成长会员有效 · {{ planLabel }}</strong>
<strong v-else>尚未开通成长会员</strong>
<span class="yxg-meta">档案 {{ profileCount }} </span>
</span>
</p>
<router-link class="yxg-btn" to="/membership">
{{ membership?.active ? '查看会员' : '开通成长会员' }}
</router-link>
</div>
<ul class="yxg-list">
<li v-for="item in links" :key="item.to">
<router-link :to="item.to">
<span class="yxg-mark" :class="'icon-' + item.tone" aria-hidden="true">{{ item.icon }}</span>
<span class="yxg-body"><strong>{{ item.label }}</strong></span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import PageTitle from '../components/PageTitle.vue'
import type { IconTone } from '../lib/featureIcons'
const links: { to: string; icon: string; label: string; tone: IconTone }[] = [
{ to: '/profile', icon: '◍', label: '个人档案', tone: 'mute' },
{ to: '/reports', icon: '▣', label: '我的成长报告', tone: 'orange' },
{ to: '/', icon: '△', label: '个人画像', tone: 'gold' },
{ to: '/star', icon: '✦', label: '星象性格', tone: 'night' },
{ to: '/rhythm', icon: '☯', label: '身心节律', tone: 'green' },
{ to: '/cards', icon: '◈', label: '意象卡片', tone: 'teal' },
{ to: '/relation', icon: '♡', label: '关系理解', tone: 'rose' },
{ to: '/ask', icon: '◎', label: 'AI 成长助手', tone: 'gold' },
{ to: '/explore', icon: '◆', label: '探索测试', tone: 'blue' },
{ to: '/membership', icon: '⑨', label: '成长会员', tone: 'purple' },
]
const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '成长会员'
})
async function load() {
loading.value = true
error.value = ''
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:24px 16px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.2;font-size:14px;color:#555}
.list a{color:inherit;text-decoration:none}
.pad{padding:8px 16px}
.member{display:flex;flex-direction:column;gap:12px;align-items:flex-start}
.member-text{display:flex;flex-direction:column;gap:2px}
.member-text strong{font-size:15px;color:#222;font-weight:650}
.tiny{width:36px!important;height:36px!important;font-size:16px!important;border-radius:12px!important}
.yxg-btn{margin-top:2px}
</style>
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import PortraitPage from './PortraitPage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
createProfile: vi.fn(),
createPortrait: vi.fn(),
getReport: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
},
routeQuery: { y: '', m: '', d: '' } as Record<string, string>,
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
RouterLink: {
name: 'RouterLink',
props: ['to'],
template: '<a><slot /></a>',
},
}))
const freeSummary = {
headline: '小愈是9号人 · 博爱大器·成功者',
main_number: 9,
person_title: '博爱大器·成功者',
one_liner: '你身上有一种让人想靠近的魔力',
pyramid: { I: 3, J: 5, K: 1, L: 9, M: 8, N: 1, O: 9 },
wuxing: {
primary: '湿热质',
care_dir: '养心 · 需呵护肺',
strong: '心',
weak: '肺',
emotion: ['热情', '感染力'],
bars: [
{ el: '木', pct: 25, color: '#5CB85C' },
{ el: '火', pct: 50, color: '#E54D42' },
{ el: '土', pct: 13, color: '#E8985A' },
{ el: '金', pct: 13, color: '#C8923A' },
{ el: '水', pct: 0, color: '#4A90E2' },
],
},
today_tip: { solar_term: '立秋', season_el: '火', yi: '午休', ji: '暴晒' },
keywords: ['9号人'],
lock_teaser_number: '天赋优势占位',
lock_teaser_wuxing: '体质占位',
}
describe('PortraitPage', () => {
beforeEach(() => {
vi.clearAllMocks()
routeQuery.y = ''
routeQuery.m = ''
routeQuery.d = ''
})
it('shows birth form when opened without query', async () => {
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(w.text()).toContain('开始解码')
expect(w.text()).not.toContain('请从首页')
expect(w.text()).not.toContain('去首页')
})
it('shows free pyramid and main number; hides deep copy without entitlement', async () => {
routeQuery.y = '1990'
routeQuery.m = '5'
routeQuery.d = '12'
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createPortrait.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary: freeSummary,
detail: null,
})
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(w.text()).toContain('9号人')
expect(w.text()).toContain('博爱大器·成功者')
expect(w.text()).toContain('湿热质')
expect(w.text()).toContain('今日养生锦囊')
expect(w.text()).toContain('解锁完整分析')
expect(w.text()).not.toContain('自信果敢、创造力爆棚')
expect(w.text()).not.toContain('体型偏瘦或适中,面色偏青黄')
})
it('generates from form submit', async () => {
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createPortrait.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary: freeSummary,
detail: null,
})
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
const inputs = w.findAll('input')
await inputs[0].setValue('1990')
await inputs[1].setValue('5')
await inputs[2].setValue('12')
await w.findAll('button').find((b) => b.text().includes('开始解码'))!.trigger('click')
await flushPromises()
expect(api.createPortrait).toHaveBeenCalledWith('p1')
expect(w.text()).toContain('9号人')
expect(w.text()).toContain('解锁完整分析')
})
it('unlocks talent and constitution deep after mock deep_access pay', async () => {
routeQuery.y = '1990'
routeQuery.m = '5'
routeQuery.d = '12'
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createPortrait.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary: freeSummary,
detail: null,
})
api.createOrder.mockResolvedValue({ order_id: 'o1' })
api.payMock.mockResolvedValue({ paid: true })
api.getReport.mockResolvedValue({
id: 'r1',
has_deep_access: true,
summary: freeSummary,
detail: {
number_deep: {
talent: '慈悲大爱、机会磁铁',
topic: '贪多嚼不烂',
direction: '慈善家、艺人',
desc: '你身上有一种让人想靠近的魔力。完整能量长文。',
joints: [],
missing: [],
},
constitution_deep: {
body: '面色偏红,手心脚心热乎乎的',
tips: '清心降火、静养心神',
risk: '失眠、口腔溃疡反复',
season_risk: '夏天最敏感',
food: '苦味清心',
rhythm: '午时小憩',
},
},
})
const w = mount(PortraitPage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
await w.findAll('button').find((b) => b.text().includes('解锁完整分析'))!.trigger('click')
await flushPromises()
expect(api.createOrder).toHaveBeenCalledWith({ kind: 'deep_access', report_id: 'r1' })
expect(w.text()).toContain('慈悲大爱、机会磁铁')
expect(w.text()).toContain('面色偏红,手心脚心热乎乎的')
expect(w.text()).toContain('清心降火、静养心神')
})
})
+137 -59
View File
@@ -1,31 +1,46 @@
<template>
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>个人画像</h1>
<p v-if="loading" class="sub">正在生成</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="report">
<p class="sub">{{ headline }}</p>
<div class="card">
<p class="line">{{ oneLiner }}</p>
<p class="tip">生活建议{{ lifeTip }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k">{{ k }}</span>
<main class="yxg-page decode-page">
<div class="yxg-hero">
<BackButton />
<PageTitle
feature="portrait"
title="愈心解码"
:sub="needBirth && !report ? '填写生日,生成身心解码报告' : '一个生日,读懂身与心'"
/>
</div>
<div class="yxg-sheet sheet-pad">
<template v-if="needBirth && !report">
<div class="yxg-card">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="loading" @click="start">
{{ loading ? '解码中' : '开始解码' }}
</button>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
</div>
</div>
<div v-if="report.has_deep_access && detail" class="card deep">
<h2>完整分析</h2>
<p><strong>行为模式</strong> {{ detail.behavior_pattern }}</p>
<p><strong>关系特点</strong> {{ detail.relation_style }}</p>
<p><strong>成长方向</strong> {{ detail.growth_direction }}</p>
</div>
<div v-else class="card lock">
<p>完整分析行为模式与成长方向可在深度版或成长会员中查看</p>
<button type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
</template>
<p class="disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</template>
<p v-else-if="loading" class="yxg-hint">正在解码</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="retry">重试</button>
</p>
<template v-else-if="report">
<DecodePanel
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
@unlock="buyDeep"
/>
<p v-if="error" class="yxg-err">{{ error }}</p>
<div class="links">
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">自我探索与生活方式参考不是占卜或算命体质内容非医疗建议</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
@@ -33,55 +48,123 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import DecodePanel from '../components/DecodePanel.vue'
import PageTitle from '../components/PageTitle.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
const route = useRoute()
const loading = ref(true)
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const formError = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const lifeTip = computed(() => String(summary.value.life_tip || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords as string[] : []))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'portrait',
title: headline.value || String(summary.value.person_title || '愈心解码'),
line: oneLiner.value,
keywords: keywords.value,
}
})
async function load() {
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
formError.value = ''
needBirth.value = false
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (!y || !m || !d) {
error.value = '请从首页填写生日后进入'
return
}
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createPortrait(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
formError.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
error.value = ''
try {
report.value = await api.getReport(reportId)
track(AnalyticsEvent.PortraitCompleted, { source: 'report_id' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
return
}
needBirth.value = true
loading.value = false
}
function retry() {
if (report.value) {
load()
return
}
needBirth.value = true
error.value = ''
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'portrait' })
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 {
@@ -93,22 +176,17 @@ onMounted(load)
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
h2{font-size:16px;margin-bottom:8px}
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
.err{color:var(--yxg-pri);font-size:13px;margin:8px 0}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
.line{font-size:15px;color:#333}
.tip{margin-top:8px;color:#666}
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.tags span{background:var(--yxg-bg-start,#ffe4e4);color:var(--yxg-pri);padding:4px 10px;border-radius:999px;font-size:12px}
.lock button{
margin-top:12px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
.mt {
margin-top: 14px;
}
.share-btn {
margin-top: 8px;
width: 100%;
}
.links {
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px 0 12px;
}
.lock button:disabled{opacity:.6}
.disc{font-size:11px;color:#bbb;margin-top:16px;line-height:1.5}
.deep p{margin-top:8px}
</style>
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import ProfilePage from './ProfilePage.vue'
const { api } = vi.hoisted(() => ({
api: {
listProfiles: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
createProfile: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
}))
const RouterLinkStub = defineComponent({
props: ['to'],
setup(props, { slots }) {
return () => h('a', { href: String(props.to) }, slots.default?.())
},
})
describe('ProfilePage', () => {
beforeEach(() => vi.clearAllMocks())
it('shows empty state', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('暂无档案')
})
it('lists profiles and opens edit', async () => {
api.listProfiles.mockResolvedValue({
items: [
{
id: 'p1',
relation: 'self',
display_name: '我',
birth_date: '1990-05-12',
user_id: 'u',
created_at: '',
},
],
})
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('1990-05-12')
await w.findAll('button').find((b) => b.text() === '编辑')!.trigger('click')
expect(w.text()).toContain('编辑档案')
})
it('shows error with retry', async () => {
api.listProfiles.mockRejectedValueOnce(new Error('网络错误'))
const w = mount(ProfilePage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('网络错误')
api.listProfiles.mockResolvedValue({ items: [] })
await w.find('button.linkbtn').trigger('click')
await flushPromises()
expect(w.text()).toContain('暂无档案')
})
})
+235 -25
View File
@@ -1,19 +1,87 @@
<template>
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>个人档案</h1>
<p class="sub">管理我的信息与重要的人</p>
<p v-if="error" class="err">{{ error }}</p>
<ul v-if="items.length" class="list">
<li v-for="p in items" :key="p.id">
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
· {{ p.relation === 'self' ? '我' : '关系对象' }}
· {{ formatDate(p.birth_date) }}
</li>
</ul>
<div v-else class="card">暂无档案请先去首页完成性格探索</div>
<router-link class="link" to="/">去性格探索 </router-link>
<router-link class="link" to="/relation">去关系理解 </router-link>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="profile" title="个人档案" sub="管理我的信息与重要的人" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!items.length" class="yxg-card empty">
<p>暂无档案请先去首页完成性格探索或添加重要的人</p>
<router-link class="yxg-btn" to="/">去性格探索</router-link>
</div>
<ul v-else class="yxg-list">
<li v-for="p in items" :key="p.id">
<div class="row">
<div>
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
<span class="meta">
{{ p.relation === 'self' ? '我' : relationLabel(p.relation_type) }}
· {{ formatDate(p.birth_date) }}
</span>
</div>
<div class="ops">
<button type="button" class="yxg-btn" @click="startEdit(p)">编辑</button>
<button type="button" class="yxg-btn yxg-btn-ghost danger" :disabled="busyId === p.id" @click="remove(p)">删除</button>
</div>
</div>
</li>
</ul>
<div v-if="editing" class="yxg-card form">
<h2 class="card-title">编辑档案</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" v-model="formName" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="formY" v-model:month="formM" v-model:day="formD" compact />
<label class="yxg-label">出生地可选 / / 区县</label>
<BirthPlaceSelect v-model="formPlace" />
<label v-if="editing.relation === 'other'" class="yxg-label">关系类型</label>
<select v-if="editing.relation === 'other'" class="yxg-select" v-model="formRelationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="colleague">同事</option>
<option value="other">其他</option>
</select>
<label v-if="editing.relation === 'self'" class="check">
<input v-model="formGeoVisible" type="checkbox" />
位置可见开启后可出现在他人附近的人合盘列表默认关闭
</label>
<p v-if="formError" class="yxg-err">{{ formError }}</p>
<div class="form-actions">
<button class="yxg-btn" type="button" :disabled="saving" @click="saveEdit">保存</button>
<button class="yxg-btn yxg-btn-ghost" type="button" @click="editing = null">取消</button>
</div>
</div>
<div class="yxg-card form">
<h2 class="card-title">添加重要的人</h2>
<label class="yxg-label">称呼</label>
<input class="yxg-input" v-model="newName" placeholder="例如:伴侣" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
<label class="yxg-label">关系类型</label>
<select class="yxg-select" v-model="newRelationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="colleague">同事</option>
<option value="other">其他</option>
</select>
<p v-if="addError" class="yxg-err">{{ addError }}</p>
<button class="yxg-btn" type="button" :disabled="adding" @click="addOther">添加</button>
</div>
<router-link class="yxg-link-plain link" to="/relation">去做关系理解 </router-link>
</template>
</div>
</main>
</template>
@@ -21,31 +89,173 @@
import { onMounted, ref } from 'vue'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import PageTitle from '../components/PageTitle.vue'
const items = ref<Profile[]>([])
const loading = ref(true)
const error = ref('')
const busyId = ref('')
const editing = ref<Profile | null>(null)
const formName = ref('')
const formY = ref('')
const formM = ref('')
const formD = ref('')
const formPlace = ref('')
const formRelationType = ref('partner')
const formGeoVisible = ref(false)
const formError = ref('')
const saving = ref(false)
const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newRelationType = ref('partner')
const addError = ref('')
const adding = ref(false)
function formatDate(v: string) {
return (v || '').slice(0, 10)
}
onMounted(async () => {
function splitBirth(v: string) {
const s = formatDate(v)
const [y, m, d] = s.split('-')
return { y: y || '', m: m ? String(Number(m)) : '', d: d ? String(Number(d)) : '' }
}
function joinBirth(y: string, m: string, d: string) {
const yi = Number(y)
const mi = Number(m)
const di = Number(d)
if (!yi || !mi || !di) return ''
return `${yi}-${String(mi).padStart(2, '0')}-${String(di).padStart(2, '0')}`
}
function relationLabel(t?: string | null) {
const map: Record<string, string> = {
partner: '伴侣',
friend: '朋友',
family: '家人',
colleague: '同事',
other: '关系对象',
}
return map[t || ''] || '关系对象'
}
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listProfiles()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
})
}
function startEdit(p: Profile) {
editing.value = p
formName.value = p.display_name || ''
const b = splitBirth(p.birth_date)
formY.value = b.y
formM.value = b.m
formD.value = b.d
formPlace.value = p.birth_place || ''
formRelationType.value = p.relation_type || 'partner'
formGeoVisible.value = !!p.geo_visible
formError.value = ''
}
async function saveEdit() {
if (!editing.value) return
saving.value = true
formError.value = ''
try {
const birth = joinBirth(formY.value, formM.value, formD.value)
if (!birth) throw new Error('请填写生日')
const body: {
display_name: string
birth_date: string
relation_type?: string
birth_place?: string
geo_visible?: boolean
} = {
display_name: formName.value.trim(),
birth_date: birth,
birth_place: formPlace.value || '',
}
if (editing.value.relation === 'other') body.relation_type = formRelationType.value
if (editing.value.relation === 'self') body.geo_visible = formGeoVisible.value
await api.updateProfile(editing.value.id, body)
editing.value = null
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function remove(p: Profile) {
if (!confirm(`确定删除「${p.display_name || '档案'}」?`)) return
busyId.value = p.id
error.value = ''
try {
await api.deleteProfile(p.id)
if (editing.value?.id === p.id) editing.value = null
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '删除失败'
} finally {
busyId.value = ''
}
}
async function addOther() {
adding.value = true
addError.value = ''
try {
const birth = joinBirth(newY.value, newM.value, newD.value)
if (!birth) throw new Error('请填写生日')
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: newName.value.trim() || 'TA',
relation_type: newRelationType.value,
})
newName.value = ''
newY.value = ''
newM.value = ''
newD.value = ''
await load()
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
.err{color:var(--yxg-pri);font-size:13px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#555}
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.1;font-size:14px;color:#555;margin-bottom:12px}
.link{display:block;margin-top:12px;font-size:14px;color:var(--yxg-pri)}
.yxg-card{margin-bottom:12px}
.empty .yxg-btn{margin-top:10px}
.yxg-list{margin-bottom:12px}
.row{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;width:100%}
.meta{display:block;font-size:12px;color:#999;margin-top:2px}
.ops{display:flex;gap:6px;flex-shrink:0}
.ops .yxg-btn{padding:6px 12px;font-size:12px;box-shadow:none}
.form .yxg-label:first-of-type{margin-top:0}
.form :deep(.bd){margin:4px 0 10px}
.form-actions{display:flex;gap:8px;margin-top:12px}
.form > .yxg-btn{margin-top:12px}
.check{display:flex;gap:8px;align-items:flex-start;margin:12px 0;font-size:13px;color:#555;line-height:1.4}
.check input{margin-top:3px}
.link{display:block;margin-top:8px}
</style>
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import RelationPage from './RelationPage.vue'
const { api } = vi.hoisted(() => ({
api: {
listProfiles: vi.fn(),
createProfile: vi.fn(),
createRelationInsight: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
getReport: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
}))
describe('RelationPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('generates relation insight summary and gates tips', async () => {
api.listProfiles.mockResolvedValue({
items: [{ id: 'self1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.createProfile.mockResolvedValue({ id: 'other1', relation: 'other' })
api.createRelationInsight.mockResolvedValue({
insight: { id: 'i1', report_id: 'rr1' },
report: {
id: 'rr1',
has_deep_access: false,
summary: {
me_style: '我·稳',
other_style: 'TA·活',
diff_one_liner: '节奏不同',
me_keywords: ['稳'],
other_keywords: ['活'],
},
detail: null,
},
})
const w = mount(RelationPage)
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
await flushPromises()
expect(w.text()).toContain('我·稳')
expect(w.text()).toContain('节奏不同')
expect(w.text()).toContain('解锁完整分析')
})
it('shows tips after deep_access pay', async () => {
api.listProfiles.mockResolvedValue({
items: [{ id: 'self1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
})
api.createProfile.mockResolvedValue({ id: 'other1' })
api.createRelationInsight.mockResolvedValue({
insight: { id: 'i1' },
report: {
id: 'rr1',
has_deep_access: false,
summary: { me_style: 'A', other_style: 'B', diff_one_liner: 'D', me_keywords: [], other_keywords: [] },
detail: null,
},
})
api.createOrder.mockResolvedValue({ order_id: 'o1' })
api.payMock.mockResolvedValue({ paid: true })
api.getReport.mockResolvedValue({
id: 'rr1',
has_deep_access: true,
summary: { me_style: 'A', other_style: 'B', diff_one_liner: 'D', me_keywords: [], other_keywords: [] },
detail: {
communication: ['先倾听'],
interaction: ['留白'],
maintenance: ['定期沟通'],
},
})
const w = mount(RelationPage)
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
await flushPromises()
await w.findAll('button').find((b) => b.text().includes('解锁完整分析'))!.trigger('click')
await flushPromises()
expect(w.text()).toContain('可执行建议')
expect(w.text()).toContain('先倾听')
})
})
+120 -76
View File
@@ -1,44 +1,59 @@
<template>
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>关系理解</h1>
<p class="sub">添加重要的人了解双方差异与相处建议</p>
<div class="card">
<label>TA 的称呼</label>
<input v-model="otherName" placeholder="例如:伴侣" />
<label>TA 的生日</label>
<div class="row">
<input v-model.number="oy" type="number" placeholder="年" />
<input v-model.number="om" type="number" placeholder="月" />
<input v-model.number="od" type="number" placeholder="日" />
</div>
<p class="hint">将使用你最近一份的档案进行对比若没有会先引导去首页创建个人画像</p>
<button type="button" :disabled="loading" @click="run">生成关系理解</button>
<p v-if="error" class="err">{{ error }}</p>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="relation" title="人格匹配" sub="合盘指数 · 恋爱/友情/婚姻 · 相处建议" />
</div>
<div class="yxg-sheet sheet-pad">
<div class="yxg-card">
<label class="yxg-label">TA 的称呼</label>
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
<label class="yxg-label">关系类型</label>
<select class="yxg-select" v-model="relationType">
<option value="partner">伴侣</option>
<option value="friend">朋友</option>
<option value="family">家人</option>
<option value="other">其他</option>
</select>
<label class="yxg-label">TA 的生日</label>
<BirthDateInputs v-model:year="oy" v-model:month="om" v-model:day="od" compact />
<p class="yxg-hint">将使用你最近一份的档案进行匹配</p>
<p v-if="loading" class="yxg-hint">生成中</p>
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="run">
{{ loading ? '生成中' : '开始匹配' }}
</button>
<p v-if="error" class="yxg-err">
{{ error }}
<router-link v-if="needSelf" class="yxg-link" to="/">去首页做愈心解码</router-link>
</p>
</div>
<template v-if="report">
<div class="card">
<p><strong></strong>{{ meStyle }}</p>
<p><strong>TA</strong>{{ otherStyle }}</p>
<p class="line">{{ diff }}</p>
<div class="tags">
<span v-for="k in meKeys" :key="'a'+k">·{{ k }}</span>
<span v-for="k in otherKeys" :key="'b'+k">TA·{{ k }}</span>
<template v-if="report">
<p class="yxg-lead">{{ diff }}</p>
<MatchCompare
:me-style="meStyle"
:other-style="otherStyle"
:fit-label="fitLabel"
:harmony="harmony"
:love="loveIndex"
:friend="friendIndex"
:marriage="marriageIndex"
:star-compare="starCompare"
:dimensions="dimCompare"
/>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整相处建议冲突修复对话示例与本周练习可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析模拟支付</button>
</div>
</div>
<div v-if="report.has_deep_access && detail" class="card">
<h2>相处建议</h2>
<ul>
<li v-for="(t, i) in tips" :key="i">{{ t }}</li>
</ul>
</div>
<div v-else class="card lock">
<p>完整相处建议可在深度版或成长会员中查看</p>
<button type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
</template>
<div class="links">
<router-link class="yxg-link-plain" to="/star">看看星座 </router-link>
<router-link class="yxg-link-plain" to="/portrait">回看愈心解码 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
@@ -46,59 +61,103 @@
import { computed, ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import MatchCompare from '../components/MatchCompare.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
const otherName = ref('TA')
const oy = ref<number | null>(1992)
const om = ref<number | null>(6)
const od = ref<number | null>(8)
const relationType = ref('partner')
const oy = ref('1992')
const om = ref('6')
const od = ref('8')
const loading = ref(false)
const paying = ref(false)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const needSelf = ref(false)
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const meStyle = computed(() => String(summary.value.me_style || ''))
const otherStyle = computed(() => String(summary.value.other_style || ''))
const diff = computed(() => String(summary.value.diff_one_liner || ''))
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? summary.value.me_keywords as string[] : []))
const otherKeys = computed(() => (Array.isArray(summary.value.other_keywords) ? summary.value.other_keywords as string[] : []))
const tips = computed(() => {
const d = detail.value
if (!d) return [] as string[]
const a = Array.isArray(d.communication) ? d.communication as string[] : []
const b = Array.isArray(d.interaction) ? d.interaction as string[] : []
const c = Array.isArray(d.maintenance) ? d.maintenance as string[] : []
return [...a, ...b, ...c]
const fitLabel = computed(() => String(summary.value.fit_label || ''))
const harmony = computed(() => {
const n = summary.value.harmony_index
return typeof n === 'number' ? n : null
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const starCompare = computed(() => {
const raw = summary.value.star_compare
return Array.isArray(raw) ? (raw as { key: string; title: string; me: string; other: string; note?: string }[]) : []
})
const dimCompare = computed(() => {
const raw = summary.value.dimension_compare
return Array.isArray(raw)
? (raw as { key: string; title: string; me_score?: number; other_score?: number; note?: string }[])
: []
})
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []))
const otherKeys = computed(() =>
Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : [],
)
const sharePayload = computed<RelationSharePayload | null>(() => {
if (!report.value) return null
return {
type: 'relation',
me: meStyle.value,
other: otherStyle.value,
diff: diff.value,
keywords: [...meKeys.value.slice(0, 3), ...otherKeys.value.slice(0, 3)],
}
})
async function ensureSelf(): Promise<Profile> {
const { items } = await api.listProfiles()
const self = (items || []).find((p) => p.relation === 'self')
if (self) return self
throw new Error('请先在首页完成性格探索,创建「我」的个人档案')
throw new Error('请先在首页完成愈心解码,创建「我」的个人档案')
}
async function run() {
loading.value = true
error.value = ''
needSelf.value = false
report.value = null
try {
if (!oy.value || !om.value || !od.value) {
const y = Number(oy.value)
const m = Number(om.value)
const d = Number(od.value)
if (!y || !m || !d) {
throw new Error('请填写 TA 的完整生日')
}
const self = await ensureSelf()
const birth = `${oy.value}-${String(om.value).padStart(2, '0')}-${String(od.value).padStart(2, '0')}`
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: 'partner',
relation_type: relationType.value,
})
const out = await api.createRelationInsight(self.id, other.id)
report.value = out.report
track(AnalyticsEvent.RelationCompleted)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
const msg = e instanceof Error ? e.message : '生成失败'
error.value = msg
needSelf.value = msg.includes('个人档案') || msg.includes('愈心解码') || msg.includes('性格探索')
} finally {
loading.value = false
}
@@ -107,10 +166,12 @@ async function run() {
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'relation' })
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 {
@@ -120,26 +181,9 @@ async function buyDeep() {
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
h2{font-size:16px;margin-bottom:8px}
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
label{display:block;font-size:12px;color:#999;margin:8px 0 4px}
input{width:100%;box-sizing:border-box;padding:10px;border:1.5px solid #eee;border-radius:12px;margin-bottom:4px}
.row{display:flex;gap:8px}
.row input{flex:1}
.hint{font-size:12px;color:#aaa;margin:8px 0}
button{
margin-top:10px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
}
button:disabled{opacity:.6}
.err{color:var(--yxg-pri);font-size:13px;margin-top:8px}
.line{margin-top:8px;color:#333}
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.tags span{background:#fff3d6;color:#c8923a;padding:4px 10px;border-radius:999px;font-size:12px}
ul{padding-left:18px;margin:0}
li{margin:6px 0}
.yxg-card{margin:8px 0 12px}
.yxg-card .yxg-label:first-child{margin-top:0}
.yxg-btn{margin-top:12px}
.lock .yxg-btn,.share-btn{width:100%}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
</style>
+443
View File
@@ -0,0 +1,443 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="reports" title="成长报告" :sub="typeLabel || undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loading" class="yxg-hint">加载中</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta">{{ typeLabel }} · {{ createdLabel }}</p>
<p class="yxg-lead">{{ headline || oneLiner }}</p>
<!-- 星座报告页也展示星盘 / 运势 / 行星避免打开报告却缺内容 -->
<template v-if="report.type === 'star'">
<div class="panel-tabs">
<button
v-for="p in starPanels"
:key="p.key"
type="button"
class="panel-tab"
:class="{ on: starPanel === p.key }"
@click="starPanel = p.key"
>
{{ p.label }}
</button>
</div>
<template v-if="starPanel === 'chart'">
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectPreview"
:asc-lon="ascLon"
/>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<p v-for="t in transits" :key="t.key" class="yxg-meta">{{ t.title }} · {{ t.aspect }}{{ t.tip }}</p>
</div>
<SignCards v-if="signCards.length" v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="yxg-card soft">
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
<p>{{ activeCard.teaser }}</p>
</div>
</template>
<template v-else-if="starPanel === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="fortuneKey = k"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="yxg-card soft">
<p class="ft-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p>{{ activeFortune.tip }}</p>
<p v-if="activeFortune.dims" class="yxg-meta">
感情 {{ activeFortune.dims.love }} · 事业 {{ activeFortune.dims.career }} · 财运
{{ activeFortune.dims.money }} · 心情 {{ activeFortune.dims.mood }}
</p>
</div>
<p v-else class="yxg-hint">暂无运势数据请重新生成星座报告</p>
</template>
<ul v-else class="planet-list">
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
</li>
<li v-if="!planets.length" class="yxg-hint">暂无行星数据请重新生成星座报告</li>
</ul>
</template>
<!-- 人格匹配 / 合盘指数 -->
<div v-if="(report.type === 'relation' || report.type === 'synastry') && hasMatchIndex" class="indices">
<div class="idx"><em>恋爱</em><strong>{{ loveIndex }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friendIndex }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriageIndex }}</strong></div>
</div>
<template v-if="report.type === 'synastry'">
<div class="panel-tabs">
<button
v-for="t in synTabs"
:key="t.key"
type="button"
class="panel-tab"
:class="{ on: synTab === t.key }"
@click="synTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="synActiveTip" class="yxg-meta">{{ synActiveTip }}</p>
<template v-if="synTab === 'compare'">
<NatalWheel v-if="synPlanetsA.length" :planets="synPlanetsA" :aspects="[]" :asc-lon="synAscA" />
<NatalWheel v-if="synPlanetsB.length" :planets="synPlanetsB" :aspects="[]" :asc-lon="synAscB" />
</template>
<template v-else-if="synTab === 'overlay'">
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
{{ e.planet }} {{ e.house }} · {{ e.house_tip }}
</p>
</template>
<NatalWheel
v-else-if="synActivePlanets.length"
:planets="synActivePlanets"
:aspects="[]"
:asc-lon="synActiveAsc"
/>
</template>
<!-- 愈心解码三角 + 五行 + 付费墙 -->
<template v-if="report.type === 'portrait'">
<DecodePanel
:summary="summary"
:detail="detail"
:deep="!!report.has_deep_access"
:paying="paying"
@unlock="buyDeep"
/>
</template>
<template v-else>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整分析可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
</template>
<div class="links">
<router-link v-if="report.type === 'star'" class="yxg-link-plain" to="/star">回星座页 </router-link>
<router-link v-if="report.type === 'synastry'" class="yxg-link-plain" to="/synastry">回合盘页 </router-link>
<router-link v-if="report.type === 'portrait'" class="yxg-link-plain" to="/portrait">回愈心解码 </router-link>
<router-link v-if="report.type === 'relation'" class="yxg-link-plain" to="/relation">回人格匹配 </router-link>
<router-link class="yxg-link-plain" to="/reports">全部报告 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import DecodePanel from '../components/DecodePanel.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import SignCards, { type SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload, RelationSharePayload, SharePayload } from '../lib/shareLink'
const route = useRoute()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const starPanel = ref<'chart' | 'fortune' | 'planets'>('chart')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
const synTab = ref('compare')
const synTabs = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks_me', label: '马克斯' },
{ key: 'overlay', label: '配对' },
{ key: 'composite_progressed', label: '组合次限' },
]
const starPanels = [
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = { key: string; title: string; sign: string; degree: string; house: number }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || summary.value.diff_one_liner || ''))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.diff_one_liner || ''))
const keywords = computed(() => {
if (Array.isArray(summary.value.keywords)) return summary.value.keywords as string[]
const a = Array.isArray(summary.value.me_keywords) ? (summary.value.me_keywords as string[]) : []
const b = Array.isArray(summary.value.other_keywords) ? (summary.value.other_keywords as string[]) : []
return [...a, ...b]
})
const typeLabel = computed(() => {
const map: Record<string, string> = {
portrait: '愈心解码',
relation: '人格匹配',
synastry: '合盘',
star: '星座',
rhythm: '身心节律',
image_card: '意象卡片',
}
return map[report.value?.type || ''] || '成长报告'
})
const chartObj = computed(() =>
summary.value.chart && typeof summary.value.chart === 'object'
? (summary.value.chart as Record<string, unknown>)
: {},
)
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const wheelPlanets = computed<WheelPlanet[]>(() => {
const raw = summary.value.planets
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),
}
})
})
const aspectPreview = computed<WheelAspect[]>(() => {
const raw = summary.value.aspects_preview
if (!Array.isArray(raw)) return []
return raw.map((a) => {
const o = a as Record<string, unknown>
return { a: String(o.a), b: String(o.b), type: String(o.type), orb: Number(o.orb) }
})
})
const transits = computed(() => {
const raw = summary.value.transits
if (Array.isArray(raw)) return raw as { key: string; title: string; aspect: string; tip: string }[]
const f = summary.value.fortune
if (f && typeof f === 'object' && Array.isArray((f as { transits?: unknown }).transits)) {
return (f as { transits: { key: string; title: string; aspect: string; tip: string }[] }).transits
}
return []
})
const synCharts = computed(() =>
summary.value.charts && typeof summary.value.charts === 'object'
? (summary.value.charts as Record<string, unknown>)
: {},
)
const synActiveBlock = computed(() => {
const c = synCharts.value[synTab.value]
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
})
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
const synActivePlanets = computed<WheelPlanet[]>(() => {
const raw = synActiveBlock.value?.planets
if (!Array.isArray(raw)) return []
return raw.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
})
const synActiveAsc = computed(() =>
typeof synActiveBlock.value?.asc_lon === 'number' ? (synActiveBlock.value.asc_lon as number) : null,
)
const synOverlayEntries = computed(() => {
const o = synCharts.value.overlay
if (!o || typeof o !== 'object') return []
const raw = (o as { entries?: unknown }).entries
return Array.isArray(raw)
? (raw as { planet: string; house: number; house_tip: string }[])
: []
})
function synChart(key: 'chart_a' | 'chart_b'): { planets: WheelPlanet[]; asc: number | null } {
const c = summary.value[key]
if (!c || typeof c !== 'object') return { planets: [], asc: null }
const o = c as { planets?: unknown; asc_lon?: number }
const planets = Array.isArray(o.planets)
? o.planets.map((p) => {
const x = p as Record<string, unknown>
return {
key: String(x.key),
title: String(x.title),
sign: String(x.sign),
degree: String(x.degree),
house: Number(x.house),
lon: Number(x.lon),
}
})
: []
return { planets, asc: typeof o.asc_lon === 'number' ? o.asc_lon : null }
}
const synPlanetsA = computed(() => synChart('chart_a').planets)
const synPlanetsB = computed(() => synChart('chart_b').planets)
const synAscA = computed(() => synChart('chart_a').asc)
const synAscB = computed(() => synChart('chart_b').asc)
const createdLabel = computed(() => {
const t = report.value?.created_at
if (!t) return ''
try {
return new Date(t).toLocaleString('zh-CN')
} catch {
return t
}
})
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const loveIndex = computed(() => (typeof summary.value.love_index === 'number' ? summary.value.love_index : null))
const friendIndex = computed(() =>
typeof summary.value.friend_index === 'number' ? summary.value.friend_index : null,
)
const marriageIndex = computed(() =>
typeof summary.value.marriage_index === 'number' ? summary.value.marriage_index : null,
)
const hasMatchIndex = computed(
() => loveIndex.value != null || friendIndex.value != null || marriageIndex.value != null,
)
const sharePayload = computed<SharePayload | null>(() => {
if (!report.value) return null
if (report.value.type === 'relation') {
const p: RelationSharePayload = {
type: 'relation',
me: String(summary.value.me_style || ''),
other: String(summary.value.other_style || ''),
diff: String(summary.value.diff_one_liner || ''),
keywords: keywords.value,
}
return p
}
const p: PortraitSharePayload = {
type: 'portrait',
title: headline.value,
line: oneLiner.value,
keywords: keywords.value,
}
return p
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[
k
] || k
}
async function load() {
loading.value = true
error.value = ''
report.value = null
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
report.value = await api.getReport(id)
starPanel.value = 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
track(AnalyticsEvent.DeepAccessClicked, { surface: 'report' })
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
}
}
watch(
() => route.params.id,
() => {
load()
},
)
onMounted(load)
</script>
<style scoped>
.yxg-card{margin:12px 0}
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
.panel-tab,.ftab{
flex:1;min-width:64px;border:1px solid #eee;background:#fafafa;border-radius:12px;
padding:10px 8px;font-size:13px;color:#666;cursor:pointer;
}
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
.ft-title{font-weight:700;color:#222;margin-bottom:6px}
.card-title{font-size:16px;margin:0 0 8px}
.planet-list{list-style:none;padding:0;margin:0}
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
.indices{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0}
.idx{
background:#fff5f4;border:1px solid #ffe0dc;border-radius:14px;padding:12px 8px;text-align:center;
}
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
.idx strong{font-size:22px;color:var(--yxg-pri);font-family:var(--font-display)}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="reports" title="我的成长报告" sub="按类型筛选 · 愈心解码 / 星座 / 节律 / 匹配 / 意象" />
</div>
<div class="yxg-sheet">
<div class="yxg-chips">
<button
v-for="f in filters"
:key="f.key"
type="button"
class="yxg-chip yxg-chip-solid"
:class="{ on: filter === f.key }"
@click="filter = f.key"
>{{ f.label }}</button>
</div>
<p v-if="loading" class="yxg-hint pad">加载中</p>
<p v-else-if="error" class="yxg-err pad">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!filtered.length" class="yxg-card empty">
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告</p>
<router-link class="yxg-btn" to="/explore">去探索</router-link>
</div>
<ul v-else class="yxg-list">
<li v-for="r in filtered" :key="r.id">
<router-link :to="`/reports/${r.id}`">
<span class="yxg-body">
<strong>{{ typeLabel(r.type) }}</strong>
<span class="desc">{{ headlineOf(r) }}</span>
<span class="meta">{{ formatDate(r.created_at) }}</span>
</span>
<span class="chev" aria-hidden="true"></span>
</router-link>
</li>
</ul>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
const items = ref<GrowthReport[]>([])
const loading = ref(true)
const error = ref('')
const filter = ref('all')
const filters = [
{ key: 'all', label: '全部' },
{ key: 'portrait', label: '解码' },
{ key: 'star', label: '星座' },
{ key: 'synastry', label: '合盘' },
{ key: 'rhythm', label: '节律' },
{ key: 'relation', label: '匹配' },
{ key: 'image_card', label: '意象' },
]
const filtered = computed(() => {
if (filter.value === 'all') return items.value
return items.value.filter((r) => r.type === filter.value)
})
function typeLabel(t: string) {
const map: Record<string, string> = {
portrait: '△ 愈心解码',
relation: '♡ 人格匹配',
synastry: '✧ 合盘',
star: '✦ 星座',
rhythm: '☯ 身心节律',
image_card: '◈ 意象卡片',
}
return map[t] || '▣ 成长报告'
}
function headlineOf(r: GrowthReport) {
const s = (r.summary || {}) as Record<string, unknown>
return String(s.headline || s.diff_one_liner || s.one_liner || '查看报告')
}
function formatDate(v: string) {
try {
return new Date(v).toLocaleString('zh-CN')
} catch {
return v
}
}
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.listReports()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pad{padding:0 16px}
.empty .yxg-btn{margin-top:12px}
</style>
+154 -49
View File
@@ -1,34 +1,64 @@
<template>
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>{{ title || '探索测试' }}</h1>
<p class="sub">{{ description }}</p>
<p v-if="error" class="err">{{ error }}</p>
<div v-if="!done" class="card">
<div v-for="q in questions" :key="q.id" class="q">
<p class="prompt">{{ q.body.prompt }}</p>
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
{{ opt.text }}
</label>
</div>
<button type="button" :disabled="loading" @click="submit">查看探索结果</button>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="scale" :title="title || '探索测试'" :sub="description" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loadingScale" class="yxg-hint">加载题目</p>
<p v-else-if="loadError" class="yxg-err">
{{ loadError }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else>
<div v-if="!done" class="yxg-card">
<p v-if="draftRestored" class="yxg-hint draft">已恢复上次未提交的作答</p>
<p v-if="questions.length" class="progress">
已答 {{ answeredCount }} / {{ questions.length }}
</p>
<div v-for="(q, idx) in questions" :key="q.id" class="q">
<p class="prompt">{{ idx + 1 }}. {{ q.body.prompt }}</p>
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
{{ opt.text }}
</label>
</div>
<p v-if="submitError" class="yxg-err">{{ submitError }}</p>
<div v-if="needProfile" class="need">
<p>提交结果需要先有的个人档案</p>
<router-link class="yxg-btn" to="/">去首页创建档案</router-link>
</div>
<button v-else class="yxg-btn yxg-btn-block" type="button" :disabled="submitting" @click="submit">
查看探索结果
</button>
</div>
<div v-else class="card">
<h2>{{ resultLabel }}</h2>
<p>{{ resultSummary }}</p>
<p class="share">{{ shareLine }}</p>
<router-link class="link" to="/relation">用结果去做关系理解 </router-link>
<div v-else>
<p class="result-lead">{{ resultLabel }}</p>
<ReportRich :summary="resultSummaryObj" :detail="resultDetailObj" :deep="true" />
<div class="yxg-card">
<p v-if="shareLine" class="share">{{ shareLine }}</p>
<button type="button" class="yxg-btn yxg-btn-ghost" @click="openShare">生成分享卡</button>
<router-link class="yxg-link-plain link" to="/relation">用结果去做关系理解 </router-link>
<router-link class="yxg-link-plain link" to="/ask">去问答聊聊 </router-link>
</div>
</div>
</template>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
const route = useRoute()
const slug = String(route.params.slug || '')
@@ -36,67 +66,142 @@ const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const answers = reactive<Record<string, string>>({})
const loading = ref(false)
const error = ref('')
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const done = ref(false)
const resultLabel = ref('')
const resultSummary = ref('')
const shareLine = ref('')
const shareOpen = ref(false)
const resultRaw = ref<Record<string, unknown> | null>(null)
const draftRestored = ref(false)
const persistReady = ref(false)
onMounted(async () => {
const answeredCount = computed(() => questions.value.filter((q) => answers[q.id]).length)
watch(
answers,
() => {
if (!persistReady.value || done.value) return
saveScaleDraft(slug, { ...answers })
},
{ deep: true },
)
const resultSummaryObj = computed(() => {
const r = resultRaw.value || {}
return {
overview: r.overview || r.summary,
keywords: r.label ? [String(r.label)] : [],
dimensions: r.dimensions,
strengths_preview: r.strengths,
watchouts_preview: r.watchouts,
} as Record<string, unknown>
})
const resultDetailObj = computed(() => {
const r = resultRaw.value || {}
return {
tips: r.tips,
conversation_scripts: r.scripts,
growth_plan: r.growth_plan,
faq: r.faq,
blind_spots: r.watchouts,
strengths: r.strengths,
} as Record<string, unknown>
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!done.value) return null
return {
type: 'portrait',
title: resultLabel.value,
line: shareLine.value || String(resultRaw.value?.summary || ''),
keywords: resultLabel.value ? [resultLabel.value] : [],
}
})
async function load() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
try {
const d = await api.getScale(slug)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body) : q.body,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
for (const [id, key] of Object.entries(draft)) {
if (ids.has(id)) {
answers[id] = key
n++
}
}
draftRestored.value = n > 0
}
persistReady.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
loadError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingScale.value = false
}
})
}
function openShare() {
shareOpen.value = true
}
async function submit() {
loading.value = true
error.value = ''
submitting.value = true
submitError.value = ''
needProfile.value = false
try {
for (const q of questions.value) {
if (!answers[q.id]) throw new Error('请完成全部题目')
}
const { items } = await api.listProfiles()
let self = (items || []).find((p) => p.relation === 'self')
const self = (items || []).find((p) => p.relation === 'self')
if (!self) {
self = await api.createProfile({ relation: 'self', birth_date: '1990-01-01', display_name: '我' })
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
draftRestored.value = false
done.value = true
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
resultSummary.value = String(out.result.summary || '')
shareLine.value = String(out.result.share_line || '')
} catch (e) {
error.value = e instanceof Error ? e.message : '提交失败'
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
loading.value = false
submitting.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
h2{font-size:18px;margin-bottom:8px}
.sub{color:var(--yxg-sub);font-size:13px;margin:8px 0 14px}
.err{color:var(--yxg-pri);font-size:13px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
.yxg-card{margin:8px 0 12px}
.progress{font-size:12px;color:#999;margin-bottom:12px}
.q{margin-bottom:18px}
.prompt{font-weight:600;color:#333;margin-bottom:8px}
.opt{display:block;margin:6px 0;cursor:pointer}
button{
margin-top:8px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
.prompt{font-weight:650;color:#222;margin-bottom:8px;line-height:1.45}
.opt{display:flex;align-items:flex-start;gap:8px;margin:8px 0;cursor:pointer;line-height:1.45}
.result-lead{
font-family:var(--font-display);
font-size:20px;font-weight:700;color:#222;margin:4px 0 12px;letter-spacing:.04em;
}
.share{margin-top:12px;color:var(--yxg-gold,#c8923a)}
.link{display:inline-block;margin-top:14px;color:var(--yxg-pri)}
.share{margin:0 0 10px;color:var(--yxg-gold)}
.link{display:block;margin-top:12px}
.need{margin-top:10px;padding:12px;background:#fff8f7;border-radius:12px}
.need .yxg-btn{margin-top:10px}
.draft{color:var(--yxg-gold);margin-bottom:8px}
.yxg-btn{margin-top:8px}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="!payload" class="yxg-err">
分享内容无效或已过期
<router-link class="yxg-link" to="/">回首页</router-link>
</p>
<template v-else>
<p class="yxg-meta lead">朋友分享了{{ payload.type === 'relation' ? '一段关系理解' : '一份个人画像' }}</p>
<ShareCard v-bind="cardProps" />
<div class="actions">
<router-link class="yxg-btn yxg-btn-block" :to="ctaTo">{{ ctaText }}</router-link>
<router-link class="yxg-link-plain ghost" to="/">先逛逛首页</router-link>
</div>
<p class="yxg-disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import BackButton from '../components/BackButton.vue'
import ShareCard from '../components/ShareCard.vue'
import { parseShareQuery } from '../lib/shareLink'
const route = useRoute()
const payload = computed(() => parseShareQuery(route.query as Record<string, unknown>))
const cardProps = computed(() => {
const p = payload.value
if (!p) return {}
if (p.type === 'portrait') {
return {
type: 'portrait' as const,
title: p.title,
line: p.line,
keywords: p.keywords,
ctaLabel: '查看完整成长报告',
}
}
return {
type: 'relation' as const,
title: '我的方式 vs TA 的方式',
rows: [
p.me ? `我:${p.me}` : '',
p.other ? `TA${p.other}` : '',
p.diff || '',
].filter(Boolean),
keywords: p.keywords,
ctaLabel: '了解彼此 → 查看关系理解',
}
})
const ctaTo = computed(() => (payload.value?.type === 'relation' ? '/relation' : '/'))
const ctaText = computed(() =>
payload.value?.type === 'relation' ? '开始关系理解' : '生成我的个人画像',
)
</script>
<style scoped>
.lead{margin:4px 0 16px}
.actions{display:flex;flex-direction:column;gap:10px;margin-top:18px;align-items:center}
.ghost{text-align:center;font-size:13px}
</style>
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import StarProfilePage from './StarProfilePage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
createProfile: vi.fn(),
createStar: vi.fn(),
getReport: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
},
routeQuery: { y: '1990', m: '5', d: '12' } as Record<string, string>,
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
}))
const summary = {
headline: '小愈的星座更偏「稳健沉淀者」',
one_liner: '一句',
sign_cards: [{ key: 'sun', title: '太阳', label: '金牛', teaser: 't', element: '土', modality: '固定' }],
chart: { note: '热带黄道', asc_lon: 120, houses: [{ num: 1, sign: '狮子' }] },
planets: [
{ key: 'sun', title: '太阳', sign: '金牛', degree: '10.0°', house: 1, lon: 40, element: '土', modality: '固定' },
{ key: 'moon', title: '月亮', sign: '巨蟹', degree: '5.0°', house: 2, lon: 95, element: '水', modality: '开创' },
{ key: 'rise', title: '上升', sign: '狮子', degree: '0.0°', house: 1, lon: 120, element: '火', modality: '固定' },
],
aspects_preview: [{ a: 'sun', b: 'moon', type: 'square', orb: 2, label: '太阳刑相月亮(容许2.0°)', a_title: '太阳', b_title: '月亮' }],
fortune: {
daily: { title: '今日运势', label: '吉', score: 80, tip: 'tip', focus: '行动', lucky: '红', caution: '慢', dims: { love: 1, career: 2, money: 3, mood: 4 } },
lifetime: { title: '一生运势摘要', label: '平稳', score: 70, tip: '人生阶段', focus: '人生阶段', lucky: 'x', caution: 'y', dims: {} },
transits: [{ key: 't1', title: '行运太阳×本命太阳', aspect: '合相', tip: '推进' }],
},
transits: [{ key: 't1', title: '行运太阳×本命太阳', aspect: '合相', tip: '推进' }],
keywords: ['金牛'],
}
describe('StarProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createStar.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary,
detail: null,
})
})
it('shows natal wheel and free chart content', async () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
expect(api.createStar).toHaveBeenCalled()
expect(w.find('svg.wheel').exists() || w.text().includes('本命')).toBe(true)
expect(w.text()).toContain('相位速览')
expect(w.text()).toContain('今日行运')
expect(w.find('svg.wheel').exists()).toBe(true)
})
it('shows lifetime fortune tab', async () => {
const w = mount(StarProfilePage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
await w.findAll('button').find((b) => b.text() === '运势')!.trigger('click')
await w.findAll('button').find((b) => b.text() === '一生')!.trigger('click')
await flushPromises()
expect(w.text()).toContain('一生运势摘要')
})
})
+404
View File
@@ -0,0 +1,404 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="星座" :sub="needBirth && !report ? '填写生日(可选出生时/地),生成星盘与运势' : undefined" />
</div>
<div class="yxg-sheet sheet-pad">
<template v-if="needBirth && !report">
<div class="yxg-card">
<label class="yxg-label">生日 / 出生时可选</label>
<div class="birth-row">
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" compact />
<input class="bd-time" v-model="birthTime" type="time" aria-label="出生时" />
<span class="bd-sep"></span>
</div>
<label class="yxg-label">出生地可选 / / 区县</label>
<BirthPlaceSelect v-model="birthPlace" />
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="start">生成星座</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">正在生成</p>
<p v-else-if="error" class="yxg-err">
{{ error }}
<button type="button" class="yxg-link" @click="load">重试</button>
</p>
<template v-else-if="report">
<p class="yxg-meta head">{{ headline }}</p>
<p v-if="oneLiner" class="yxg-lead">{{ oneLiner }}</p>
<p v-if="chartNote" class="yxg-meta">{{ chartNote }}</p>
<div class="panel-tabs" role="tablist">
<button
v-for="p in panels"
:key="p.key"
type="button"
class="panel-tab"
:class="{ on: panel === p.key }"
@click="panel = p.key"
>
{{ p.label }}
</button>
</div>
<template v-if="panel === 'chart'">
<div class="wheel-settings">
<label><input v-model="showOuter" type="checkbox" /> 显示外行星</label>
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
</div>
<NatalWheel
v-if="wheelPlanets.length"
:planets="wheelPlanets"
:aspects="aspectLines"
:asc-lon="ascLon"
:show-outer="showOuter"
:tight-orb="tightOrb"
@select="onWheelSelect"
/>
<SignCards v-model="signTab" :cards="signCards" />
<div v-if="activeCard" class="yxg-card soft tab-body">
<h2 class="card-title">{{ activeCard.title }} · {{ activeCard.label }}</h2>
<p>{{ activeCard.teaser }}</p>
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }} · {{ activeCard.modality }}</p>
</div>
<div v-if="houses.length" class="yxg-card soft">
<h2 class="card-title">宫位</h2>
<p class="house-row" v-for="h in houses" :key="h.num">{{ h.num }} · {{ h.sign }}</p>
</div>
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}</p>
<p v-if="!report.has_deep_access" class="yxg-meta">完整相位表见深度版</p>
<ul v-else class="aspect-full">
<li v-for="(a, i) in fullAspects" :key="'f' + i">{{ a.label }}</li>
</ul>
</div>
<div v-if="transits.length" class="yxg-card soft">
<h2 class="card-title">今日行运</h2>
<div v-for="t in transits" :key="t.key" class="transit">
<strong>{{ t.title }}</strong>
<span class="yxg-meta">{{ t.aspect }}</span>
<p>{{ t.tip }}</p>
</div>
</div>
</template>
<template v-else-if="panel === 'fortune'">
<div class="fortune-tabs">
<button
v-for="k in fortuneKeys"
:key="k"
type="button"
class="ftab"
:class="{ on: fortuneKey === k }"
@click="fortuneKey = k"
>
{{ fortuneLabel(k) }}
</button>
</div>
<div v-if="activeFortune" class="yxg-card daily">
<p class="daily-title">
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}
</p>
<p class="focus">焦点{{ activeFortune.focus }}</p>
<p>{{ activeFortune.tip }}</p>
<div v-if="activeFortune.dims" class="dims">
<span>感情 {{ activeFortune.dims.love }}</span>
<span>事业 {{ activeFortune.dims.career }}</span>
<span>财运 {{ activeFortune.dims.money }}</span>
<span>心情 {{ activeFortune.dims.mood }}</span>
</div>
<p class="yxg-meta">幸运{{ activeFortune.lucky }}</p>
<p class="yxg-meta">注意{{ activeFortune.caution }}</p>
</div>
</template>
<template v-else>
<ul class="planet-list">
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
<strong>{{ p.title }}</strong>
<span>{{ p.sign }} {{ p.degree }} · {{ p.house }}</span>
<span class="yxg-meta">{{ p.element }} · {{ p.modality }}</span>
</li>
</ul>
</template>
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
<div v-if="!report.has_deep_access" class="yxg-card lock">
<p>完整相位与年运详解可在深度版或成长会员中查看</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析模拟支付</button>
</div>
<div class="links">
<router-link class="yxg-link-plain" to="/synastry">去做合盘比较盘</router-link>
<router-link class="yxg-link-plain" to="/relation">去做人格匹配 </router-link>
<router-link class="yxg-link-plain" to="/ask">去问答聊聊星座 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 </router-link>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">运势与星盘为自我探索参考请结合现实判断</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
import PageTitle from '../components/PageTitle.vue'
import ReportRich from '../components/ReportRich.vue'
import ShareSheet from '../components/ShareSheet.vue'
import SignCards, { type SignCard } from '../components/SignCards.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { PortraitSharePayload } from '../lib/shareLink'
const SETTINGS_KEY = 'yuxingu_star_wheel_settings'
const route = useRoute()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const shareOpen = ref(false)
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
const birthTime = ref('')
const birthPlace = ref('')
const panel = ref<'chart' | 'fortune' | 'planets'>('chart')
const signTab = ref('sun')
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
const showOuter = ref(true)
const tightOrb = ref(false)
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) {
const o = JSON.parse(raw) as { showOuter?: boolean; tightOrb?: boolean }
if (typeof o.showOuter === 'boolean') showOuter.value = o.showOuter
if (typeof o.tightOrb === 'boolean') tightOrb.value = o.tightOrb
}
} catch {
/* ignore */
}
watch([showOuter, tightOrb], () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ showOuter: showOuter.value, tightOrb: tightOrb.value }))
})
const panels = [
{ key: 'chart' as const, label: '星盘' },
{ key: 'fortune' as const, label: '运势' },
{ key: 'planets' as const, label: '行星' },
]
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
type FortunePeriod = {
title?: string
label?: string
score?: number
tip?: string
focus?: string
lucky?: string
caution?: string
dims?: { love?: number; career?: number; money?: number; mood?: number }
}
type PlanetRow = WheelPlanet & { element?: string; modality?: string }
type AspectRow = WheelAspect & { a_title?: string; b_title?: string; label?: string }
type TransitRow = { key: string; title: string; aspect: string; tip: string }
type HouseRow = { num: number; sign: string }
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : []))
const chartObj = computed(() => (summary.value.chart && typeof summary.value.chart === 'object' ? (summary.value.chart as Record<string, unknown>) : {}))
const chartNote = computed(() => String(chartObj.value.note || ''))
const ascLon = computed(() => (typeof chartObj.value.asc_lon === 'number' ? chartObj.value.asc_lon : null))
const houses = computed(() => (Array.isArray(chartObj.value.houses) ? (chartObj.value.houses as HouseRow[]) : []))
const signCards = computed(() => {
const raw = summary.value.sign_cards
return Array.isArray(raw) ? (raw as SignCard[]) : []
})
const activeCard = computed(() => signCards.value.find((c) => c.key === signTab.value) || signCards.value[0])
const fortuneBundle = computed(() => {
const f = summary.value.fortune
return f && typeof f === 'object' ? (f as Record<string, FortunePeriod>) : {}
})
const activeFortune = computed(() => fortuneBundle.value[fortuneKey.value] || null)
const planets = computed(() => {
const raw = summary.value.planets
return Array.isArray(raw) ? (raw as PlanetRow[]) : []
})
const wheelPlanets = computed<WheelPlanet[]>(() =>
planets.value.map((p) => ({
key: p.key,
title: p.title,
sign: p.sign,
degree: p.degree,
house: p.house,
lon: Number(p.lon),
element: p.element,
modality: p.modality,
})),
)
const aspectPreview = computed(() => {
const raw = summary.value.aspects_preview
return Array.isArray(raw) ? (raw as AspectRow[]) : []
})
const fullAspects = computed(() => {
const raw = detail.value?.aspects
return Array.isArray(raw) ? (raw as AspectRow[]) : aspectPreview.value
})
const aspectLines = computed<WheelAspect[]>(() => {
const src = report.value?.has_deep_access ? fullAspects.value : aspectPreview.value
return src.map((a) => ({ a: a.a, b: a.b, type: a.type, orb: a.orb, label: a.label }))
})
const transits = computed(() => {
const fromSummary = summary.value.transits
if (Array.isArray(fromSummary)) return fromSummary as TransitRow[]
const f = fortuneBundle.value as Record<string, unknown>
if (Array.isArray(f.transits)) return f.transits as TransitRow[]
return []
})
const sharePayload = computed<PortraitSharePayload | null>(() => {
if (!report.value) return null
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function fortuneLabel(k: string) {
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
}
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const bt = birthTime.value ? birthTime.value.slice(0, 5) : undefined
const profile = await api.createProfile({
relation: 'self',
birth_date: birth,
display_name: '我',
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
async function start() {
const y = Number(year.value)
const m = Number(month.value)
const d = Number(day.value)
const msg = validateBirth(y, m, d)
if (msg) {
error.value = msg
return
}
await generate(y, m, d)
}
async function load() {
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
needBirth.value = false
try {
report.value = await api.getReport(reportId)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
await generate(y, m, d)
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
track(AnalyticsEvent.DeepAccessClicked, { surface: 'star' })
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
}
}
onMounted(load)
</script>
<style scoped>
.yxg-card{margin:8px 0 12px}
.yxg-label{margin-top:12px}
.yxg-label:first-child{margin-top:0}
.birth-row{
display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:4px;
}
.birth-row :deep(.bd){margin:0;flex-wrap:nowrap}
.bd-time{
width:108px;padding:10px 6px;border:1.5px solid #eee;border-radius:12px;
font-size:14px;text-align:center;outline:none;background:#fdfaf8;color:#333;
}
.bd-time:focus{border-color:#f0b8b0;background:#fff}
.bd-sep{font-size:12px;color:#bbb;flex-shrink:0}
:deep(.bp){margin-bottom:10px}
.head{margin:4px 0 8px;font-size:13px}
.tab-body p{margin:0 0 6px;line-height:1.65}
.panel-tabs,.fortune-tabs{display:flex;gap:8px;margin:10px 0 12px;flex-wrap:wrap}
.panel-tab,.ftab{
flex:1;min-width:56px;border:1px solid #eee;background:#fafafa;border-radius:12px;
padding:10px 6px;font-size:13px;color:#666;cursor:pointer;
}
.panel-tab.on,.ftab.on{border-color:var(--yxg-pri);background:#fff5f4;color:#222;font-weight:600}
.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}
.planet-list{list-style:none;padding:0;margin:0}
.planet{display:flex;flex-direction:column;gap:4px;padding:12px}
.lock .yxg-btn,.share-btn{margin-top:12px;width:100%}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.wheel-settings{
display:flex;gap:16px;font-size:12px;color:#666;margin:4px 0 8px;justify-content:center;
}
.wheel-settings label{display:flex;align-items:center;gap:4px;cursor:pointer}
.house-row{font-size:13px;color:#555;margin:4px 0}
.aspect-line{font-size:13px;color:#555;margin:4px 0;line-height:1.5}
.aspect-full{margin:8px 0 0;padding-left:18px;font-size:12px;color:#666}
.transit{margin:8px 0;font-size:13px}
.transit p{margin:2px 0 0;color:#555}
</style>
@@ -0,0 +1,98 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="合盘邀请" sub="填写生日,与好友生成合盘" />
</div>
<div class="yxg-sheet sheet-pad">
<p v-if="loading" class="yxg-hint">加载邀请</p>
<template v-else-if="meta">
<p class="yxg-lead">{{ meta.host_name || '好友' }} 邀请你合盘</p>
<p v-if="meta.already_accepted" class="yxg-meta">该邀请已使用可直接去合盘页再测</p>
<div v-else class="yxg-card">
<label class="yxg-label">你的称呼</label>
<input v-model="name" class="name-in" placeholder="我" />
<label class="yxg-label">生日</label>
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
<button class="yxg-btn yxg-btn-block mt" type="button" :disabled="submitting" @click="accept">
{{ submitting ? '生成中' : '接受并合盘' }}
</button>
</div>
<p v-if="error" class="yxg-err">{{ error }}</p>
<router-link class="yxg-link-plain" to="/synastry">回合盘页 </router-link>
</template>
<p v-else class="yxg-err">{{ error || '邀请无效或已过期' }}</p>
<p class="yxg-disc">合盘为自我探索参考不是占卜或算命</p>
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import PageTitle from '../components/PageTitle.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const name = ref('我')
const ty = ref('')
const tm = ref('')
const td = ref('')
const meta = ref<{
host_name: string
already_accepted: boolean
} | null>(null)
onMounted(async () => {
const token = String(route.params.token || '')
try {
meta.value = await api.getSynastryInvite(token)
} catch (e) {
error.value = e instanceof Error ? e.message : '邀请无效'
} finally {
loading.value = false
}
})
async function accept() {
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
}
submitting.value = true
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const rep = await api.acceptSynastryInvite(String(route.params.token), {
display_name: name.value || '我',
birth_date: birth,
})
track(AnalyticsEvent.SynastryInviteAccepted, { report_id: rep.id })
await router.push(`/reports/${rep.id}`)
} catch (e) {
error.value = e instanceof Error ? e.message : '接受失败'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.yxg-label{display:block;margin:12px 0 6px;font-size:13px;color:#666}
.name-in{
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;font-size:14px;
}
.mt{margin-top:14px}
</style>
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import SynastryPage from './SynastryPage.vue'
const { api } = vi.hoisted(() => ({
api: {
listProfiles: vi.fn(),
createProfile: vi.fn(),
createSynastry: vi.fn(),
createSynastryInvite: vi.fn(),
listSynastryNearby: vi.fn(),
updateProfile: vi.fn(),
getReport: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
}))
describe('SynastryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.listProfiles.mockResolvedValue({
items: [
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },
{ id: 'b1', relation: 'other', display_name: 'TA', birth_date: '1992-08-20' },
],
})
})
it('generates synastry and shows paywall without deep access', async () => {
api.createSynastry.mockResolvedValue({
id: 'r1',
has_deep_access: false,
summary: {
headline: '我 × TA:恋爱 80 · 友情 70 · 婚姻 75',
one_liner: '五盘合观',
as_of: '2026-08-02',
love_index: 80,
friend_index: 70,
marriage_index: 75,
love_note: '恋爱说明',
me_name: '我',
other_name: 'TA',
chart_a: {
asc_lon: 10,
planets: [{ key: 'sun', title: '太阳', sign: '金牛', degree: '1°', house: 1, lon: 40 }],
},
chart_b: {
asc_lon: 20,
planets: [{ key: 'sun', title: '太阳', sign: '狮子', degree: '2°', house: 1, lon: 122 }],
},
aspects_preview: [{ label: '我方太阳合相对方月亮(容许1.0°)' }],
charts: {
compare: { tip: '比较盘提示' },
composite: {
tip: '组合盘:关系整体气质。',
asc_lon: 30,
planets: [{ key: 'sun', title: '太阳', sign: '双子', degree: '3°', house: 1, lon: 63 }],
aspects_preview: [{ label: '太阳六合月亮(容许2.0°)' }],
},
composite_progressed: {
tip: '组合次限',
planets: [{ key: 'sun', title: '太阳', sign: '巨蟹', degree: '4°', house: 1, lon: 94 }],
aspects_preview: [],
},
davison: { tip: '时空', planets: [], aspects_preview: [] },
marks_me: { tip: '马克斯我', planets: [], aspects_preview: [] },
marks_other: { tip: '马克斯TA', planets: [], aspects_preview: [] },
overlay: {
tip: '配对盘提示',
entries: [{ planet: '太阳', sign: '狮子', house: 5, house_tip: '恋爱表达与创造' }],
},
davison_progressed: { tip: '时空次限', planets: [], aspects_preview: [] },
marks_progressed: { tip: '马盘推运', planets: [], aspects_preview: [] },
},
},
detail: null,
})
const w = mount(SynastryPage, { global: { stubs: { RouterLink: true } } })
await flushPromises()
await w.find('button.yxg-btn-block').trigger('click')
await flushPromises()
expect(api.createSynastry).toHaveBeenCalled()
const args = api.createSynastry.mock.calls[0]
expect(args[0]).toBe('a1')
expect(args[1]).toBe('b1')
expect(w.text()).toContain('80')
expect(w.text()).toContain('解锁完整合盘')
expect(w.text()).not.toContain('相处深析')
// switch to composite tab
const tabs = w.findAll('button.tab')
const comp = tabs.find((t) => t.text() === '组合')
expect(comp).toBeTruthy()
await comp!.trigger('click')
await flushPromises()
expect(w.text()).toContain('组合盘:关系整体气质')
const prog = w.findAll('button.stab').find((t) => t.text() === '次限')
await prog!.trigger('click')
await flushPromises()
expect(w.text()).toContain('组合次限')
})
})
+498
View File
@@ -0,0 +1,498 @@
<template>
<main class="yxg-page">
<div class="yxg-hero">
<BackButton />
<PageTitle feature="star" title="合盘" sub="比较 · 组合 · 时空 · 马克斯 · 配对" />
</div>
<div class="yxg-sheet sheet-pad">
<template v-if="!report">
<div class="yxg-card">
<p class="yxg-lead">选择双方档案一次生成五主盘与次限推运</p>
<label class="yxg-label">档案 A</label>
<select v-model="profileA" class="sel">
<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 v-model="profileB" class="sel">
<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 v-model="asOf" type="date" class="sel" />
<div class="social-row">
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="!profileA || inviting" @click="createInvite">
{{ inviting ? '生成中' : '邀请好友合盘' }}
</button>
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="nearbyLoading" @click="loadNearby">
{{ nearbyLoading ? '定位中' : '附近的人' }}
</button>
</div>
<p v-if="invitePath" class="yxg-meta">邀请链接{{ 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>
<div class="yxg-card soft mini">
<p class="card-title">快速添加 TA</p>
<BirthDateInputs v-model:year="ty" v-model:month="tm" v-model:day="td" />
<input v-model="tName" class="name-in" placeholder="称呼(如:TA" />
<button type="button" class="yxg-btn yxg-btn-ghost" :disabled="adding" @click="addTemp">
{{ adding ? '添加中' : '添加档案' }}
</button>
</div>
<button
class="yxg-btn yxg-btn-block mt"
type="button"
:disabled="loading || !profileA || !profileB"
@click="generate"
>
{{ loading ? '合盘中…' : '开始合盘' }}
</button>
<p v-if="error" class="yxg-err">{{ error }}</p>
</div>
</template>
<p v-else-if="loading" class="yxg-hint">合盘计算中</p>
<template v-else-if="report">
<p class="yxg-lead">{{ headline }}</p>
<p class="yxg-meta">{{ oneLiner }}</p>
<div class="indices">
<div class="idx"><em>恋爱</em><strong>{{ love }}</strong></div>
<div class="idx"><em>友情</em><strong>{{ friend }}</strong></div>
<div class="idx"><em>婚姻</em><strong>{{ marriage }}</strong></div>
</div>
<p class="yxg-meta">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
<div class="tabs" role="tablist">
<button
v-for="t in mainTabs"
:key="t.key"
type="button"
class="tab"
:class="{ on: mainTab === t.key }"
@click="mainTab = t.key"
>
{{ t.label }}
</button>
</div>
<div v-if="needsSubTab" class="subtabs">
<button type="button" class="stab" :class="{ on: subTab === 'natal' }" @click="subTab = 'natal'">本盘</button>
<button type="button" class="stab" :class="{ on: subTab === 'prog' }" @click="subTab = 'prog'">次限</button>
<template v-if="mainTab === 'marks'">
<button type="button" class="stab" :class="{ on: marksWho === 'me' }" @click="marksWho = 'me'"></button>
<button type="button" class="stab" :class="{ on: marksWho === 'other' }" @click="marksWho = 'other'">TA</button>
</template>
</div>
<!-- 比较盘 -->
<template v-if="mainTab === 'compare'">
<p class="tip">{{ chartTip('compare') }}</p>
<h3 class="sec">比较盘 · </h3>
<NatalWheel v-if="planetsA.length" :planets="planetsA" :aspects="[]" :asc-lon="ascA" />
<h3 class="sec">比较盘 · TA</h3>
<NatalWheel v-if="planetsB.length" :planets="planetsB" :aspects="[]" :asc-lon="ascB" />
<div v-if="aspectPreview.length" class="yxg-card soft">
<h2 class="card-title">跨盘相位速览</h2>
<p v-for="(a, i) in aspectPreview" :key="i" class="aline">{{ a.label }}</p>
</div>
</template>
<!-- 配对盘 -->
<template v-else-if="mainTab === 'overlay'">
<p class="tip">{{ overlayTip }}</p>
<div class="yxg-card soft">
<p v-for="(e, i) in overlayEntries" :key="i" class="aline">
{{ e.planet }}{{ e.sign }} 我方 {{ e.house }} · {{ e.house_tip }}
</p>
</div>
</template>
<!-- 单盘型 -->
<template v-else>
<p class="tip">{{ activeChartTip }}</p>
<NatalWheel
v-if="activePlanets.length"
:planets="activePlanets"
:aspects="[]"
:asc-lon="activeAsc"
/>
<div v-if="activeAspectPreview.length" class="yxg-card soft">
<h2 class="card-title">相位速览</h2>
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
</div>
</template>
<div v-if="report.has_deep_access && detail" class="yxg-card">
<h2 class="card-title">相处深析</h2>
<div v-for="(s, i) in sections" :key="i" class="sec-block">
<strong>{{ s.title }}</strong>
<p>{{ s.body }}</p>
</div>
<ul>
<li v-for="(a, i) in fullAspects" :key="'fa' + i">{{ a.label }}</li>
</ul>
</div>
<div v-else class="yxg-card lock">
<p>完整相位推运深文案与落宫解读可在深度版解锁</p>
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整合盘模拟支付</button>
</div>
<div class="links">
<router-link class="yxg-link-plain" to="/star">回星座排盘 </router-link>
<router-link class="yxg-link-plain" to="/relation">人格匹配 </router-link>
<router-link v-if="report.id" class="yxg-link-plain" :to="`/reports/${report.id}`">在报告页打开 </router-link>
<button type="button" class="yxg-link-plain reset" @click="reset">再测一对</button>
</div>
<button type="button" class="yxg-btn yxg-btn-ghost share-btn" @click="shareOpen = true">生成分享卡</button>
</template>
<p class="yxg-disc">合盘指数为自我探索参考不是占卜或算命</p>
</div>
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BirthDateInputs from '../components/BirthDateInputs.vue'
import NatalWheel, { type WheelPlanet } from '../components/NatalWheel.vue'
import PageTitle from '../components/PageTitle.vue'
import ShareSheet from '../components/ShareSheet.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import type { RelationSharePayload } from '../lib/shareLink'
type MainTab = 'compare' | 'composite' | 'davison' | 'marks' | 'overlay'
const loading = ref(false)
const adding = ref(false)
const paying = ref(false)
const inviting = ref(false)
const nearbyLoading = ref(false)
const error = ref('')
const nearbyHint = ref('')
const invitePath = ref('')
const profiles = ref<Profile[]>([])
const nearby = ref<{ profile: Profile; distance_km: number }[]>([])
const profileA = ref('')
const profileB = ref('')
const asOf = ref(new Date().toISOString().slice(0, 10))
const report = ref<GrowthReport | null>(null)
const shareOpen = ref(false)
const ty = ref('')
const tm = ref('')
const td = ref('')
const tName = ref('TA')
const mainTab = ref<MainTab>('compare')
const subTab = ref<'natal' | 'prog'>('natal')
const marksWho = ref<'me' | 'other'>('me')
const mainTabs: { key: MainTab; label: string }[] = [
{ key: 'compare', label: '比较' },
{ key: 'composite', label: '组合' },
{ key: 'davison', label: '时空' },
{ key: 'marks', label: '马克斯' },
{ key: 'overlay', label: '配对' },
]
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const charts = computed(() => (summary.value.charts || {}) as Record<string, unknown>)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const love = computed(() => Number(summary.value.love_index || 0))
const friend = computed(() => Number(summary.value.friend_index || 0))
const marriage = computed(() => Number(summary.value.marriage_index || 0))
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>
}
function chartTip(key: string): string {
const c = chartBlock(key)
return String(c?.tip || '')
}
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 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 activeChartTip = computed(() => chartTip(activeChartKey.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
} 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
// Only save coordinates; visibility stays off unless user explicitly enables it.
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(loadProfiles)
</script>
<style scoped>
.yxg-card{margin:8px 0 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;
}
.mini{margin-top:12px}
.name-in{
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;margin:8px 0;font-size:14px;
}
.mt{margin-top:14px}
.social-row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.social-row .yxg-btn{flex:1;min-width:120px}
.indices{display:flex;gap:10px;margin:14px 0}
.idx{
flex:1;text-align:center;background:#fff;border-radius:14px;padding:12px 8px;
box-shadow:0 4px 16px rgba(0,0,0,.05);
}
.idx em{display:block;font-style:normal;font-size:11px;color:#999;margin-bottom:4px}
.idx strong{font-size:22px;color:var(--yxg-pri)}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0 8px}
.tab{
border:1px solid #eee;background:#fff;border-radius:999px;padding:6px 12px;font-size:13px;cursor:pointer;color:#666;
}
.tab.on{background:var(--yxg-pri);color:#fff;border-color:transparent}
.subtabs{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap}
.stab{
border:none;background:#f3eeea;border-radius:8px;padding:5px 10px;font-size:12px;cursor:pointer;color:#555;
}
.stab.on{background:#2c2c2c;color:#fff}
.tip{font-size:13px;color:#666;margin:4px 0 10px}
.sec{font-size:14px;margin:16px 0 6px;color:#444}
.aline{font-size:13px;color:#555;margin:4px 0}
.sec-block{margin:10px 0}
.sec-block p{margin:4px 0;color:#555;font-size:13px}
.lock .yxg-btn,.share-btn{width:100%;margin-top:10px}
.links{display:flex;flex-direction:column;gap:8px;margin:12px 0}
.reset{background:none;border:none;text-align:left;cursor:pointer;padding:0;font:inherit;color:inherit}
</style>
+35 -10
View File
@@ -3,18 +3,43 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: () => import('../pages/HomePage.vue'), meta: { tab: true } },
{ path: '/explore', name: 'explore', component: () => import('../pages/ExplorePage.vue'), meta: { tab: true } },
{ path: '/ask', name: 'ask', component: () => import('../pages/AskPage.vue'), meta: { tab: true } },
{ path: '/companion', name: 'companion', component: () => import('../pages/CompanionPage.vue'), meta: { tab: true } },
{ path: '/mine', name: 'mine', component: () => import('../pages/MinePage.vue'), meta: { tab: true } },
{ path: '/profile', name: 'profile', component: () => import('../pages/ProfilePage.vue'), meta: { tab: false } },
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue'), meta: { tab: false } },
{ path: '/relation', name: 'relation', component: () => import('../pages/RelationPage.vue'), meta: { tab: false } },
{ path: '/membership', name: 'membership', component: () => import('../pages/MembershipPage.vue'), meta: { tab: false } },
{ path: '/scales/:slug', name: 'scale', component: () => import('../pages/ScalePage.vue'), meta: { tab: false } },
{ path: '/', name: 'home', component: () => import('../pages/HomePage.vue') },
{ path: '/explore', name: 'explore', component: () => import('../pages/ExplorePage.vue') },
{
path: '/explore/:category',
name: 'explore-category',
component: () => import('../pages/ExploreCategoryPage.vue'),
},
{
path: '/growth-plan',
name: 'growth-plan',
component: () => import('../pages/GrowthPlanPage.vue'),
},
{ path: '/ask', name: 'ask', component: () => import('../pages/AskPage.vue') },
{ path: '/companion', name: 'companion', component: () => import('../pages/CompanionPage.vue') },
{ path: '/mine', name: 'mine', component: () => import('../pages/MinePage.vue') },
{ path: '/profile', name: 'profile', component: () => import('../pages/ProfilePage.vue') },
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue') },
{ path: '/star', name: 'star', component: () => import('../pages/StarProfilePage.vue') },
{ path: '/synastry', name: 'synastry', component: () => import('../pages/SynastryPage.vue') },
{
path: '/synastry/invite/:token',
name: 'synastry-invite',
component: () => import('../pages/SynastryInvitePage.vue'),
},
{ path: '/rhythm', name: 'rhythm', component: () => import('../pages/LifeRhythmPage.vue') },
{ path: '/cards', name: 'cards', component: () => import('../pages/ImageCardPage.vue') },
{ path: '/relation', name: 'relation', component: () => import('../pages/RelationPage.vue') },
{ path: '/membership', name: 'membership', component: () => import('../pages/MembershipPage.vue') },
{ path: '/scales/:slug', name: 'scale', component: () => import('../pages/ScalePage.vue') },
{ path: '/share', name: 'share', component: () => import('../pages/SharePage.vue') },
{ path: '/reports', name: 'reports', component: () => import('../pages/ReportsPage.vue') },
{ path: '/reports/:id', name: 'report', component: () => import('../pages/ReportPage.vue') },
{ path: '/decode', redirect: (to) => ({ path: '/portrait', query: to.query }) },
],
scrollBehavior() {
return { top: 0 }
},
})
export default router
+255 -3
View File
@@ -1,12 +1,264 @@
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body,#app{min-height:100%}
body{
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:linear-gradient(180deg,var(--yxg-bg-start) 0%,var(--yxg-bg-end) 28%,#fff9f7 100%);
color:var(--yxg-text);
font-family:var(--font-sans);
font-size:14px;
line-height:1.65;
color:var(--color-text-primary);
-webkit-font-smoothing:antialiased;
background:
radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.45) 0%, transparent 55%),
radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.55) 0%, transparent 50%),
linear-gradient(180deg, var(--yxg-bg-start) 0%, var(--yxg-bg-end) 28%, var(--color-bg-sheet) 100%);
background-attachment:fixed;
}
.app-shell{
max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh;
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px) + 12px);
position:relative;
}
a{color:inherit;text-decoration:none}
/* ── Page shell (测测式:暖色洗底 + 白 sheet) ── */
.yxg-page{
padding:0 0 8px;
position:relative;
}
.yxg-page-pad{
padding:0 var(--spacing-md) 8px;
}
.yxg-hero{
padding:4px var(--spacing-md) 12px;
}
.yxg-sheet{
margin-top:8px;
background:var(--color-surface);
border-radius:var(--radius-sheet) var(--radius-sheet) 0 0;
padding:8px 0 28px;
box-shadow:var(--shadow-sheet);
min-height:52vh;
animation:yxgSheetUp .45s cubic-bezier(.22,.8,.28,1) both;
}
.yxg-sheet-inset{
margin:0 var(--spacing-md);
border-radius:var(--radius-xl);
min-height:0;
padding:4px 0 20px;
box-shadow:var(--shadow-card);
}
@keyframes yxgSheetUp{
from{opacity:0;transform:translateY(16px)}
to{opacity:1;transform:translateY(0)}
}
.yxg-sec{
padding:0 var(--spacing-md);
margin-top:20px;
}
.yxg-sec:first-child{margin-top:12px}
.yxg-sec-head{
display:flex;justify-content:space-between;align-items:baseline;
margin-bottom:12px;
}
.yxg-sec-head .title{
font-size:17px;font-weight:700;color:#222;letter-spacing:.02em;
}
.yxg-sec-head .more{
font-size:12px;color:var(--color-text-tertiary);
}
.yxg-sec-head .more::after{content:" "}
/* ── Cards ── */
.yxg-card{
background:var(--color-surface);
border-radius:var(--radius-lg);
padding:16px;
margin:0 var(--spacing-md) 12px;
box-shadow:var(--shadow-card);
font-size:14px;color:#555;line-height:1.7;
}
/* sheet 内已有左右 padding 时,取消卡片/列表外边距 */
.yxg-sheet.sheet-pad .yxg-card,
.yxg-sheet.sheet-pad .yxg-list{
margin-left:0;margin-right:0;
}
.yxg-sheet.sheet-pad{
padding-left:var(--spacing-md);
padding-right:var(--spacing-md);
}
.yxg-card h2,.yxg-card .card-title{
font-size:16px;font-weight:700;color:#222;margin:0 0 8px;line-height:1.35;
}
.yxg-card.soft{background:#fafafa;box-shadow:none}
.yxg-hero-card{
margin:0 var(--spacing-md);
padding:18px 16px 16px;
background:var(--color-surface);
border-radius:var(--radius-xl);
box-shadow:var(--shadow-hero);
text-align:center;
animation:yxgFadeUp .5s ease both;
}
@keyframes yxgFadeUp{
from{opacity:0;transform:translateY(8px)}
to{opacity:1;transform:translateY(0)}
}
/* ── List (探索 / 我的) ── */
.yxg-list{
list-style:none;
margin:0 var(--spacing-md);
padding:4px 4px 4px 8px;
background:var(--color-surface);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-card);
}
.yxg-list > li{
padding:14px 8px 14px 4px;
border-bottom:1px solid #f5f5f5;
}
.yxg-list > li:last-child{border-bottom:none}
.yxg-list a{
display:flex;align-items:flex-start;gap:12px;
color:inherit;text-decoration:none;
}
.yxg-list .yxg-body strong{
font-size:15px;font-weight:650;color:#222;line-height:1.35;
}
.yxg-list .yxg-body .desc,
.yxg-list .yxg-body .meta{
display:block;font-size:12px;color:var(--color-text-tertiary);
margin-top:4px;line-height:1.45;
}
.yxg-list .chev{
margin-left:auto;align-self:center;color:#d0d0d0;font-size:16px;flex-shrink:0;
}
/* ── Chips / filters ── */
.yxg-chips{
display:flex;gap:8px;padding:4px var(--spacing-md) 12px;
overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;
}
.yxg-chips::-webkit-scrollbar{display:none}
.yxg-chip{
flex-shrink:0;padding:7px 14px;border-radius:var(--radius-pill);
background:rgba(255,255,255,.72);border:1px solid rgba(255,255,255,.9);
font-size:12px;color:#666;cursor:pointer;
transition:background .15s,color .15s,box-shadow .15s;
}
.yxg-chip.on,.yxg-chip:active{
background:#fff;color:var(--yxg-pri);font-weight:600;
box-shadow:0 2px 8px rgba(0,0,0,.04);
}
.yxg-chip-solid{
background:#fff;border:1px solid var(--color-border);
}
.yxg-chip-solid.on{
border-color:var(--yxg-pri);color:var(--yxg-pri);
}
/* ── Buttons / forms ── */
.yxg-btn{
display:inline-flex;align-items:center;justify-content:center;
padding:11px 18px;border:none;border-radius:var(--radius-pill);
color:#fff;font-size:14px;font-weight:600;cursor:pointer;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
box-shadow:0 6px 16px rgba(229,77,66,.28);
text-decoration:none;
}
.yxg-btn:disabled{opacity:.5;cursor:not-allowed;box-shadow:none}
.yxg-btn:active:not(:disabled){opacity:.92}
.yxg-btn-block{width:100%}
.yxg-btn-ghost{
background:#fff;color:var(--yxg-pri);
border:1px solid #f0c0bc;box-shadow:none;
}
.yxg-btn-soft{
background:var(--color-accent-green);
box-shadow:0 6px 16px rgba(92,184,92,.25);
}
.yxg-input,.yxg-select,.yxg-textarea{
width:100%;padding:11px 12px;
border:1.5px solid var(--color-border-strong);
border-radius:var(--radius-md);
font:inherit;font-size:15px;color:var(--color-text-primary);
background:var(--color-input-bg);outline:none;
transition:border-color .15s,background .15s;
}
.yxg-input:focus,.yxg-select:focus,.yxg-textarea:focus{
border-color:#f0b8b0;background:#fff;
}
.yxg-textarea{resize:vertical;line-height:1.55;font-size:14px}
.yxg-label{
display:block;font-size:12px;color:var(--color-text-secondary);
margin:8px 0 4px;
}
.yxg-hint{font-size:13px;color:var(--color-text-secondary);line-height:1.5}
.yxg-err{color:var(--yxg-pri);font-size:13px;margin:8px 0}
.yxg-meta{font-size:12px;color:var(--color-text-tertiary)}
.yxg-lead{font-size:15px;color:#333;line-height:1.65;margin:0 0 12px}
.yxg-disc{font-size:11px;color:var(--color-text-tertiary);margin-top:20px;line-height:1.5}
.yxg-link{
background:none;border:none;color:var(--yxg-pri);
padding:0 4px;font-size:13px;text-decoration:underline;cursor:pointer;
}
.yxg-link-plain{
color:var(--yxg-pri);text-decoration:none;font-size:14px;
}
/* ── Icon tones (legacy home.css) ── */
.icon-pink{background:#FFE4E4;color:var(--yxg-pri)}
.icon-blue{background:#E3EEFF;color:#4A90E2}
.icon-purple{background:#EDE4FF;color:#8B5CF6}
.icon-orange{background:#FFE8D6;color:#E8985A}
.icon-green{background:#E0F6E4;color:#5CB85C}
.icon-rose{background:#FFE0EC;color:#E85A8C}
.icon-teal{background:#DDF6EF;color:#2EA88A}
.icon-gold{background:#FFF3D6;color:#C8923A}
.icon-night{background:#E8E2F4;color:#6B5B95}
.icon-mute{background:#F3F3F3;color:#aaa}
.yxg-row{
display:flex;align-items:flex-start;gap:12px;
}
.yxg-row .yxg-mark{
width:44px;height:44px;border-radius:14px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:18px;font-weight:600;
box-shadow:inset 0 -2px 0 rgba(0,0,0,.03);
}
.yxg-row .yxg-body{flex:1;min-width:0}
.yxg-chip-ico{
display:inline-flex;align-items:center;gap:8px;
}
.yxg-chip-ico .m{opacity:.9;font-size:13px}
/* ── Feed cards ── */
.yxg-feed{display:flex;flex-direction:column;gap:12px}
.yxg-feed-card{
display:flex;gap:14px;align-items:stretch;
padding:12px;background:#fafafa;border-radius:var(--radius-lg);
color:inherit;transition:transform .12s,background .12s;
position:relative;overflow:hidden;
}
.yxg-feed-card:active{transform:scale(.985);background:#f5f5f5}
.yxg-feed-cover{
width:72px;height:72px;border-radius:14px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:28px;position:relative;overflow:hidden;
}
.yxg-feed-cover::after{
content:"";position:absolute;inset:0;
background:linear-gradient(160deg,rgba(255,255,255,.35),transparent 50%);
}
.yxg-feed-body{
flex:1;min-width:0;display:flex;flex-direction:column;justify-content:center;padding:2px 0;
}
.yxg-feed-body .name{font-size:15px;font-weight:650;color:#222;line-height:1.35}
.yxg-feed-body .meta{font-size:12px;color:var(--color-text-secondary);margin-top:4px;line-height:1.4}
.fc-a .yxg-feed-cover{background:linear-gradient(145deg,#FFD0C8,#FFB0A4);color:#C23B32}
.fc-b .yxg-feed-cover{background:linear-gradient(145deg,#E4D8F8,#C9B6F0);color:#5B4A8A}
.fc-c .yxg-feed-cover{background:linear-gradient(145deg,#D6E8FF,#B0D0F5);color:#3A6FB0}
.fc-d .yxg-feed-cover{background:linear-gradient(145deg,#D8F0DC,#B5E0BE);color:#3A8A4A}
.fc-e .yxg-feed-cover{background:linear-gradient(145deg,#FFE6C8,#F5C98A);color:#B07A28}
)
+12
View File
@@ -0,0 +1,12 @@
declare module 'element-china-area-data' {
export type AreaNode = {
label: string
value: string
children?: AreaNode[]
}
export const pcaTextArr: AreaNode[]
export const pcTextArr: AreaNode[]
export const regionData: AreaNode[]
export const provinceAndCityData: AreaNode[]
export const codeToText: Record<string, string>
}
+9
View File
@@ -1,5 +1,14 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GA_MEASUREMENT_ID?: string
readonly VITE_ANALYTICS_DEBUG?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
+3 -2
View File
@@ -18,7 +18,8 @@
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
"baseUrl": ".",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "e2e/**/*.ts", "vitest.config.ts", "playwright.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
globals: true,
},
})