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:
@@ -0,0 +1,6 @@
|
||||
# GA4 Measurement ID(留空则埋点 no-op,应用仍可用)
|
||||
# 静态站历史示例:G-LVVXH3TL04
|
||||
VITE_GA_MEASUREMENT_ID=
|
||||
|
||||
# 开发时在控制台打印 [analytics] 日志:设为 1
|
||||
VITE_ANALYTICS_DEBUG=0
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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('查看关系理解')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: '个人画像',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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('还没有个人档案')
|
||||
})
|
||||
})
|
||||
@@ -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('已保存')
|
||||
})
|
||||
})
|
||||
@@ -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} 分` : '点选此刻感受(1–5)' }}</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
/* chips:sheet 外、暖色底上 */
|
||||
.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('走一小步')
|
||||
})
|
||||
})
|
||||
@@ -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('节气陪伴')
|
||||
})
|
||||
})
|
||||
@@ -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('尚未开通')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('清心降火、静养心神')
|
||||
})
|
||||
})
|
||||
@@ -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('暂无档案')
|
||||
})
|
||||
})
|
||||
@@ -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('先倾听')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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('一生运势摘要')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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('组合次限')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Vendored
+9
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user