feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单
对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,101 +1,183 @@
|
||||
<template>
|
||||
<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>
|
||||
<PageShell :sheet="false">
|
||||
<template #hero>
|
||||
<!-- 双轨顶栏 · 对标测测「测测AI|真人1v1」 -->
|
||||
<div class="rails">
|
||||
<button
|
||||
type="button"
|
||||
class="rail"
|
||||
:class="{ on: rail === 'ai' }"
|
||||
@click="rail = 'ai'"
|
||||
>
|
||||
愈心 AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rail"
|
||||
:class="{ on: rail === 'advisor' }"
|
||||
@click="rail = 'advisor'"
|
||||
>
|
||||
顾问
|
||||
<span class="rail-tag">推荐</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="yxg-sheet ask-sheet">
|
||||
<p v-if="bootLoading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="bootError" class="yxg-err pad">
|
||||
<!-- AI 轨 -->
|
||||
<div v-show="rail === 'ai'" class="ai-pane">
|
||||
<p v-if="bootLoading" class="hint">加载中…</p>
|
||||
<p v-else-if="bootError" class="err">
|
||||
{{ bootError }}
|
||||
<button type="button" class="yxg-link" @click="boot">重试</button>
|
||||
<button type="button" class="retry" @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 class="card empty">
|
||||
<HomeToolIcon name="ask" :size="56" />
|
||||
<p>还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。</p>
|
||||
<router-link class="cta" to="/portrait">去愈心解码</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">
|
||||
<!-- 冷启动引导 -->
|
||||
<div v-if="!messages.length" class="card greet reveal">
|
||||
<div class="greet-row">
|
||||
<HomeToolIcon name="ask" :size="48" />
|
||||
<div>
|
||||
<p class="hi">Hi,我是愈心 AI</p>
|
||||
<p class="hi-sub">最近有什么事,都可以和我聊聊</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompts">
|
||||
<button
|
||||
v-for="s in guidePrompts"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="prompt"
|
||||
@click="pickScene(s)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="advisor-link" @click="rail = 'advisor'">
|
||||
也可以看看成长顾问 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card controls">
|
||||
<label class="lbl">档案</label>
|
||||
<select class="sel" 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">
|
||||
<div class="quick">
|
||||
<button
|
||||
v-for="s in scenes"
|
||||
:key="s.key"
|
||||
v-for="q in quickTools"
|
||||
:key="q.to"
|
||||
type="button"
|
||||
class="yxg-chip yxg-chip-solid"
|
||||
:class="{ on: scene === s.key }"
|
||||
@click="pickScene(s)"
|
||||
class="q-btn"
|
||||
@click="router.push(q.to)"
|
||||
>
|
||||
<span aria-hidden="true">{{ s.icon }} </span>{{ s.label }}
|
||||
{{ q.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>
|
||||
<p v-if="sending" class="hint pad">助手正在回复…</p>
|
||||
</div>
|
||||
|
||||
<p v-if="sendError" class="yxg-err pad">
|
||||
<p v-if="sendError" class="err pad">
|
||||
{{ sendError }}
|
||||
<router-link v-if="quotaExhausted" class="yxg-link" to="/membership">开通成长会员</router-link>
|
||||
<router-link v-if="quotaExhausted" class="retry" 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>
|
||||
<div class="composer-wrap">
|
||||
<p v-if="quota" class="quota">
|
||||
剩余 {{ quota.remaining }} 次
|
||||
<span v-if="!quota.active_membership">(免费 {{ quota.free_limit }})</span>
|
||||
</p>
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<textarea
|
||||
class="ta"
|
||||
v-model="draft"
|
||||
rows="2"
|
||||
placeholder="让我来解答你的问题吧"
|
||||
:disabled="sending || (quota !== null && quota.remaining <= 0)"
|
||||
/>
|
||||
<button
|
||||
class="send"
|
||||
type="submit"
|
||||
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
<p class="ai-note">部分内容由 AI 生成,仅供参考</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 顾问轨(对标真人1v1,无连麦则引导提问/会员) -->
|
||||
<div v-show="rail === 'advisor'" class="adv-pane">
|
||||
<div class="card">
|
||||
<p class="adv-title">成长顾问</p>
|
||||
<p class="adv-sub">结合档案聊聊卡住的事 · 可先用 AI,或开通会员解锁更多</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="a in advisors"
|
||||
:key="a.name"
|
||||
class="adv-card"
|
||||
>
|
||||
<div class="av" :style="{ background: a.bg }">{{ a.mark }}</div>
|
||||
<div class="adv-body">
|
||||
<strong>{{ a.name }}</strong>
|
||||
<span class="meta">{{ a.meta }}</span>
|
||||
<span class="tags">{{ a.tags }}</span>
|
||||
</div>
|
||||
<button type="button" class="ask-btn" @click="askAdvisor(a)">去提问</button>
|
||||
</div>
|
||||
<router-link class="cta soft" to="/membership">查看成长会员权益</router-link>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const rail = ref<'ai' | 'advisor'>('ai')
|
||||
|
||||
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: '我想弄清现阶段什么对我真正重要。' },
|
||||
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
|
||||
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
|
||||
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
|
||||
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
|
||||
] as const
|
||||
|
||||
const guidePrompts = scenes
|
||||
|
||||
const quickTools = [
|
||||
{ to: '/portrait', label: '工具' },
|
||||
{ to: '/synastry', label: '合盘' },
|
||||
{ to: '/cards', label: '意象卡' },
|
||||
{ to: '/relation', label: '匹配' },
|
||||
]
|
||||
|
||||
const advisors = [
|
||||
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
|
||||
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
|
||||
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
|
||||
]
|
||||
|
||||
const bootLoading = ref(true)
|
||||
const bootError = ref('')
|
||||
const profiles = ref<Profile[]>([])
|
||||
@@ -134,13 +216,19 @@ function onProfileChange() {
|
||||
sendError.value = ''
|
||||
}
|
||||
|
||||
function pickScene(s: (typeof scenes)[number]) {
|
||||
function pickScene(s: { key: string; prompt: string }) {
|
||||
scene.value = s.key
|
||||
draft.value = s.prompt
|
||||
threadId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function askAdvisor(a: (typeof advisors)[number]) {
|
||||
rail.value = 'ai'
|
||||
const s = scenes.find((x) => x.key === a.scene) || scenes[0]
|
||||
pickScene(s)
|
||||
}
|
||||
|
||||
async function ensureThread(): Promise<string> {
|
||||
if (threadId.value) return threadId.value
|
||||
if (!profileId.value) throw new Error('请先选择档案')
|
||||
@@ -181,33 +269,320 @@ onMounted(boot)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
.rails {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 16px rgba(180, 90, 70, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.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);
|
||||
.rail {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
position: relative;
|
||||
}
|
||||
.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%);
|
||||
.rail.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
|
||||
}
|
||||
.rail-tag {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ai-pane,
|
||||
.adv-pane {
|
||||
padding: 12px 16px 100px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-hero);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card.empty {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.greet-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.hi {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.hi-sub {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.prompt {
|
||||
text-align: left;
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fffaf8;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.prompt:active {
|
||||
background: #fff0ec;
|
||||
border-color: #f5c4bc;
|
||||
}
|
||||
.advisor-link {
|
||||
margin-top: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.controls .lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sel {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: var(--color-input-bg);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
.quick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.q-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.thread {
|
||||
overflow-y: auto;
|
||||
max-height: 36vh;
|
||||
padding: 4px 0 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);
|
||||
background: #fff;
|
||||
}
|
||||
.bubble.user {
|
||||
margin-left: 28px;
|
||||
color: #333;
|
||||
}
|
||||
.bubble.assistant {
|
||||
margin-right: 28px;
|
||||
background: #fff5f4;
|
||||
color: #444;
|
||||
box-shadow: none;
|
||||
}
|
||||
.composer-wrap {
|
||||
position: sticky;
|
||||
bottom: 72px;
|
||||
padding-top: 8px;
|
||||
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
|
||||
}
|
||||
.quota {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.ta {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.ta:focus {
|
||||
border-color: #f0b8b0;
|
||||
}
|
||||
.send {
|
||||
flex-shrink: 0;
|
||||
padding: 11px 16px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.send:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #ccc;
|
||||
text-align: center;
|
||||
}
|
||||
.adv-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
.adv-sub {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.adv-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.av {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: #c23b32;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.adv-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.adv-body strong {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
.tags {
|
||||
font-size: 11px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.ask-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta.soft {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid #f5c4bc;
|
||||
box-shadow: none;
|
||||
}
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.err {
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.pad { padding: 0 4px; }
|
||||
.retry {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: underline;
|
||||
font-size: 13px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.reveal {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.composer .yxg-textarea{flex:1;resize:none;min-height:44px}
|
||||
.composer .yxg-btn{flex-shrink:0;padding:11px 16px}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="companion" title="陪伴" sub="节气生活 · 心情记录" />
|
||||
</div>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="head">
|
||||
<h1 class="title">陪伴</h1>
|
||||
<p class="sub">节气生活 · 心情记录</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- 角色问候卡 · 对标陪伴小星 -->
|
||||
<section class="sec">
|
||||
<div class="hero-card">
|
||||
<HomeToolIcon name="companion" :size="64" />
|
||||
<div class="hero-copy">
|
||||
<p class="hi">Hi,我是节气小伴</p>
|
||||
<p class="hi-sub">今天也可以慢慢来,先感受一下身体与心情。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="term-card">
|
||||
<p class="term">
|
||||
<HomeToolIcon name="rhythm" :size="28" />
|
||||
<span>今日 · {{ term.name }}</span>
|
||||
</p>
|
||||
<p class="date">{{ todayLabel }}</p>
|
||||
<p class="tip">{{ term.tip }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="yxg-card">
|
||||
<p class="sec-title"><span aria-hidden="true">○ </span>今日心情</p>
|
||||
<section class="sec">
|
||||
<div class="block">
|
||||
<p class="sec-title">今日心情</p>
|
||||
<div class="scores">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
@@ -21,24 +40,28 @@
|
||||
class="score"
|
||||
:class="{ on: score === n }"
|
||||
@click="score = n"
|
||||
>{{ moodMarks[n - 1] }}</button>
|
||||
>
|
||||
{{ moodMarks[n - 1] }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="yxg-meta score-hint">{{ score ? `已选 ${score} 分` : '点选此刻感受(1–5)' }}</p>
|
||||
<p class="score-hint">{{ score ? `已选 ${score} 分` : '点选此刻感受(1–5)' }}</p>
|
||||
<textarea
|
||||
class="yxg-textarea"
|
||||
class="ta"
|
||||
v-model="note"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
placeholder="可选:写一两句此刻的感受"
|
||||
/>
|
||||
<button type="button" class="yxg-btn yxg-btn-soft save" :disabled="saving || !score" @click="save">
|
||||
<button type="button" class="save" :disabled="saving || !score" @click="save">
|
||||
{{ saving ? '保存中…' : saved ? '已保存' : '保存心情' }}
|
||||
</button>
|
||||
<p v-if="moodErr" class="yxg-err">{{ moodErr }}</p>
|
||||
<p v-if="moodErr" class="err">{{ moodErr }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="recent.length" class="yxg-card">
|
||||
<p class="sec-title"><span aria-hidden="true">▣ </span>近七日心情</p>
|
||||
<section v-if="recent.length" class="sec">
|
||||
<div class="block">
|
||||
<p class="sec-title">近七日心情</p>
|
||||
<ul class="trail">
|
||||
<li v-for="m in recent" :key="m.id">
|
||||
<span class="d">{{ m.day }}</span>
|
||||
@@ -47,21 +70,30 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="sec links">
|
||||
<router-link class="link" to="/ask">
|
||||
<HomeToolIcon name="ask" :size="36" />
|
||||
<span>去 AI 成长助手聊聊 →</span>
|
||||
</router-link>
|
||||
<router-link class="link" to="/rhythm">
|
||||
<HomeToolIcon name="rhythm" :size="36" />
|
||||
<span>身心节律探索 →</span>
|
||||
</router-link>
|
||||
<router-link class="link" to="/growth-plan">
|
||||
<HomeToolIcon name="growth" :size="36" />
|
||||
<span>成长计划 →</span>
|
||||
</router-link>
|
||||
</section>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { track } from '../lib/analytics'
|
||||
|
||||
const moodMarks = ['◦', '○', '◎', '●', '◉']
|
||||
@@ -123,38 +155,115 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
.head { padding: 4px 0 8px; }
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.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);
|
||||
.sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.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;
|
||||
.sec { padding: 0 16px; margin-top: 14px; }
|
||||
.hero-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.score.on{
|
||||
border-color:var(--color-accent-green);
|
||||
background:var(--color-accent-green-soft);
|
||||
color:#3d6b45;
|
||||
.hi { font-size: 16px; font-weight: 700; color: #222; }
|
||||
.hi-sub { font-size: 12px; color: #888; margin-top: 4px; line-height: 1.45; }
|
||||
.term-card,
|
||||
.block {
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
}
|
||||
.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;
|
||||
.term {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #3d6b45;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.date { margin: 6px 0 10px; font-size: 12px; color: #999; }
|
||||
.tip { color: #444; line-height: 1.7; font-size: 14px; }
|
||||
.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: #fff;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
.score.on {
|
||||
border-color: var(--color-accent-green);
|
||||
background: var(--color-accent-green-soft);
|
||||
color: #3d6b45;
|
||||
}
|
||||
.score-hint { margin-bottom: 10px; font-size: 12px; color: #999; }
|
||||
.ta {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
.save {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.save:disabled { opacity: 0.45; }
|
||||
.err { color: var(--color-primary); font-size: 12px; margin-top: 8px; }
|
||||
.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 #f0ebe8;
|
||||
color: #666;
|
||||
}
|
||||
.trail li:last-child { border-bottom: none; }
|
||||
.trail .d { color: #999; }
|
||||
.trail .s { font-weight: 700; color: #3d6b45; }
|
||||
.links { display: flex; flex-direction: column; gap: 8px; padding-bottom: 8px; }
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
.trail li:last-child{border-bottom:none}
|
||||
.trail .d{color:#999}
|
||||
.trail .s{font-weight:700;color:#3d6b45}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
<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>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="head">
|
||||
<BackButton />
|
||||
<h1 class="title">{{ cat?.title || '分类' }}</h1>
|
||||
<p v-if="cat" class="sub">{{ cat.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<p v-if="loading" class="hint">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else-if="cat" class="grid-wrap">
|
||||
<router-link
|
||||
v-for="it in cat.items"
|
||||
:key="it.key"
|
||||
:to="it.path"
|
||||
class="tile"
|
||||
>
|
||||
<HomeToolIcon :name="toolIconFor(it.path || it.key)" :size="48" />
|
||||
<div class="tile-body">
|
||||
<strong>
|
||||
{{ it.title }}
|
||||
<span v-if="it.badge" class="badge">{{ it.badge }}</span>
|
||||
</strong>
|
||||
<span class="desc">{{ it.description }}</span>
|
||||
</div>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -38,7 +36,9 @@ 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'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
@@ -81,11 +81,65 @@ 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;
|
||||
.head { padding: 0 0 8px; }
|
||||
.title {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
.grid-wrap {
|
||||
padding: 8px 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-lg);
|
||||
color: inherit;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.tile:active { transform: scale(0.985); }
|
||||
.tile-body { flex: 1; min-width: 0; }
|
||||
.tile-body strong {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.desc {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
margin-left: 4px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.chev { color: #d0d0d0; font-size: 16px; }
|
||||
.hint, .err {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.err { color: var(--color-primary); }
|
||||
</style>
|
||||
|
||||
@@ -1,35 +1,62 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageTitle feature="explore" title="探索" sub="六大分类 · 找到适合你的自我理解方式" />
|
||||
</div>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="head">
|
||||
<h1 class="title">探索</h1>
|
||||
<p class="sub">测评 · 工具 · 自我理解</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="yxg-sheet">
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">
|
||||
<!-- 推荐宫格(对齐首页 12 宫 + 截图「更多」墙) -->
|
||||
<section class="sec">
|
||||
<div class="sec-head">
|
||||
<span class="sec-title">推荐工具</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<router-link
|
||||
v-for="t in featured"
|
||||
:key="t.to + t.label"
|
||||
:to="t.to"
|
||||
class="g-item"
|
||||
>
|
||||
<HomeToolIcon :name="t.icon" :size="52" />
|
||||
<span class="g-label">{{ t.label }}</span>
|
||||
<span v-if="t.badge" class="g-badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-head">
|
||||
<span class="sec-title">全部分类</span>
|
||||
</div>
|
||||
<p v-if="loading" class="hint">加载中…</p>
|
||||
<p v-else-if="error" class="err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
<button type="button" class="retry" @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>
|
||||
<div v-else class="cat-list">
|
||||
<ListRow
|
||||
v-for="c in categories"
|
||||
:key="c.key"
|
||||
:to="`/explore/${c.key}`"
|
||||
:title="c.title"
|
||||
:desc="`${c.description} · ${c.items?.length || 0} 项`"
|
||||
:icon="toolIconFor(c.key)"
|
||||
:badge="hotBadge(c)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
import ListRow from '../components/ListRow.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
type Cat = {
|
||||
key: string
|
||||
@@ -37,13 +64,33 @@ type Cat = {
|
||||
description: string
|
||||
icon: string
|
||||
tone: string
|
||||
items?: unknown[]
|
||||
items?: { badge?: string }[]
|
||||
}
|
||||
|
||||
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
|
||||
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
|
||||
{ to: '/star', label: '星座', icon: 'star' },
|
||||
{ to: '/portrait', label: '愈心解码', icon: 'portrait', badge: '热', badgeTone: 'hot' },
|
||||
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
|
||||
{ to: '/synastry', label: '合盘', icon: 'synastry', badge: '新', badgeTone: 'new' },
|
||||
{ to: '/ask', label: 'AI问答', icon: 'ask' },
|
||||
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
|
||||
{ to: '/cards', label: '意象卡片', icon: 'cards' },
|
||||
{ to: '/reports', label: '成长报告', icon: 'reports', badge: '新', badgeTone: 'new' },
|
||||
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
|
||||
{ to: '/relation', label: '人格匹配', icon: 'relation' },
|
||||
{ to: '/membership', label: '成长会员', icon: 'growth' },
|
||||
]
|
||||
|
||||
const categories = ref<Cat[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
function hotBadge(c: Cat) {
|
||||
const b = c.items?.find((i) => i.badge)?.badge
|
||||
return b || undefined
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -61,6 +108,90 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pad{padding:8px 16px}
|
||||
.yxg-list{margin-top:8px}
|
||||
.head { padding: 4px 0 8px; }
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sec { padding: 0 16px; margin-top: 18px; }
|
||||
.sec:first-child { margin-top: 12px; }
|
||||
.sec-head { margin-bottom: 12px; }
|
||||
.sec-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px 6px;
|
||||
}
|
||||
.g-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.g-item:active { transform: scale(0.92); }
|
||||
.g-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.g-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
padding: 2px 5px;
|
||||
border-radius: 7px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.g-badge.b-hot {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
}
|
||||
.g-badge.b-new {
|
||||
background: linear-gradient(135deg, #6eb6ff, #4a90e2);
|
||||
}
|
||||
.cat-list {
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.cat-list :deep(.lr) {
|
||||
border-bottom: 1px solid #f0ebe8;
|
||||
}
|
||||
.cat-list :deep(.lr:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.hint, .err {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.err { color: var(--color-primary); }
|
||||
.retry {
|
||||
margin-left: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-accent-blue);
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<BackButton />
|
||||
<PageTitle feature="growth" title="成长计划" sub="小目标 · 每日打卡(生活成长,非运势)" />
|
||||
</div>
|
||||
<div class="head">
|
||||
<HomeToolIcon name="growth" :size="48" />
|
||||
<div>
|
||||
<h1 class="title">成长计划</h1>
|
||||
<p class="sub">小目标 · 每日打卡</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 class="body">
|
||||
<div class="create">
|
||||
<label class="lbl">新计划标题</label>
|
||||
<input class="inp" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
|
||||
<label class="lbl">焦点(可选)</label>
|
||||
<input class="inp" v-model="focus" maxlength="80" placeholder="例如:保护睡眠与情绪" />
|
||||
<button class="cta" type="button" :disabled="saving || !title.trim()" @click="create">创建计划</button>
|
||||
<p v-if="err" class="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="loading" class="hint">加载中…</p>
|
||||
<div v-else class="plans">
|
||||
<div 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)">
|
||||
<button type="button" class="check" :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>
|
||||
<p v-if="!plans.length" class="hint">还没有计划,先创建一个小目标吧。</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</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'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
|
||||
const plans = ref<{ id: string; title: string; focus: string }[]>([])
|
||||
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
|
||||
@@ -83,7 +90,7 @@ async function checkin(id: string) {
|
||||
checking.value = id
|
||||
err.value = ''
|
||||
try {
|
||||
await api.growthCheckin(id, {})
|
||||
await api.createGrowthCheckin(id, {})
|
||||
const ck = await api.listGrowthCheckins(id)
|
||||
checkins.value[id] = ck.items || []
|
||||
} catch (e) {
|
||||
@@ -97,14 +104,89 @@ 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}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.body { padding: 8px 16px 0; }
|
||||
.create {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.lbl:first-child { margin-top: 0; }
|
||||
.inp {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.cta {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta:disabled { opacity: 0.45; }
|
||||
.plans { display: flex; flex-direction: column; gap: 10px; }
|
||||
.plan {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid #f5f0ed;
|
||||
}
|
||||
.plan strong { font-size: 15px; color: #222; }
|
||||
.focus { font-size: 12px; color: #888; margin: 6px 0 10px; }
|
||||
.check {
|
||||
padding: 8px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1.5px solid #f5c4bc;
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check:disabled { opacity: 0.5; }
|
||||
.cks {
|
||||
list-style: none;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.cks li { padding: 4px 0; }
|
||||
.hint { font-size: 13px; color: #999; text-align: center; padding: 16px; }
|
||||
.err { color: var(--color-primary); font-size: 12px; margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
+966
-274
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,108 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #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>
|
||||
</template>
|
||||
|
||||
<div class="yxg-card">
|
||||
<label class="yxg-label">生日(用于关联档案)</label>
|
||||
<div class="body" :class="{ 'has-results': cards.length }">
|
||||
<!-- Greeting -->
|
||||
<div class="greet">
|
||||
<div class="greet-icons" aria-hidden="true">
|
||||
<HomeToolIcon name="cards" :size="44" />
|
||||
<HomeToolIcon name="ask" :size="36" class="icon-offset" />
|
||||
</div>
|
||||
<h1 class="greet-title">Hi,我是愈心 AI</h1>
|
||||
<p class="greet-sub">来用意念卡片整理问题吧</p>
|
||||
<p v-if="bootError" class="yxg-err">{{ bootError }}(仍可填写后重试抽取)</p>
|
||||
<p class="quota">今日剩余 {{ quotaLabel }} · 场景 {{ scene }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt rows (landing) -->
|
||||
<div v-if="!cards.length" class="prompt-list">
|
||||
<button
|
||||
v-for="(row, i) in promptRows"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="prompt-row"
|
||||
@click="onPromptClick(row)"
|
||||
>
|
||||
<span class="prompt-ico" aria-hidden="true">{{ row.icon }}</span>
|
||||
<span class="prompt-text">{{ row.label }}</span>
|
||||
<span class="prompt-go" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode chips -->
|
||||
<div v-if="!cards.length" class="mode-row" role="group" aria-label="抽卡模式">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-chip"
|
||||
:class="{ on: !wantDepth }"
|
||||
@click="wantDepth = false"
|
||||
>
|
||||
单卡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-chip"
|
||||
:class="{ on: wantDepth }"
|
||||
@click="wantDepth = true"
|
||||
>
|
||||
三卡组合
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible birth -->
|
||||
<div v-if="!cards.length" class="birth-fold">
|
||||
<button type="button" class="birth-toggle" @click="showBirth = !showBirth">
|
||||
<span>关联生日</span>
|
||||
<span class="caret" :class="{ open: showBirth }">▾</span>
|
||||
</button>
|
||||
<div v-show="showBirth" class="birth-panel yxg-card soft">
|
||||
<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>
|
||||
</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>
|
||||
<!-- Results -->
|
||||
<template v-if="cards.length">
|
||||
<article v-for="c in cards" :key="c.id" class="yxg-card card-result">
|
||||
<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>
|
||||
<p class="yxg-disc">意象卡片用于投射与自我反思,不判定好坏,也不预测未来。</p>
|
||||
|
||||
<p class="yxg-disc ai-note">意象卡片用于投射与自我反思,不判定好坏,也不预测未来。部分内容由 AI 生成,仅供参考。</p>
|
||||
<p v-if="error" class="yxg-err err-float">{{ error }}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Bottom input bar -->
|
||||
<div v-if="!cards.length" class="input-bar">
|
||||
<input
|
||||
v-model="questionInput"
|
||||
type="text"
|
||||
class="question-in"
|
||||
placeholder="让我来解答你的问题吧"
|
||||
@keydown.enter="sendDraw"
|
||||
/>
|
||||
<button type="button" class="send-btn" :disabled="drawing" aria-label="发送并抽取" @click="sendDraw">
|
||||
<span v-if="drawing" class="send-spin">…</span>
|
||||
<span v-else class="send-ico" aria-hidden="true">↑</span>
|
||||
</button>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -56,7 +112,8 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
@@ -73,6 +130,15 @@ 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 questionInput = ref('')
|
||||
const showBirth = ref(false)
|
||||
|
||||
const promptRows = [
|
||||
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
|
||||
{ icon: '💬', label: '和 TA 相处时,我总在重复什么模式?', scene: '关系反思' },
|
||||
{ icon: '🪞', label: '我想更了解自己此刻的状态', scene: '自我觉察' },
|
||||
{ icon: '🍃', label: '生活节奏太快,我需要一点方向感', scene: '生活节奏' },
|
||||
]
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
@@ -82,11 +148,35 @@ const quotaLabel = computed(() => {
|
||||
return `${quota.value.remaining} / ${quota.value.daily_free}`
|
||||
})
|
||||
|
||||
function birthFilled() {
|
||||
const y = Number(year.value)
|
||||
const m = Number(month.value)
|
||||
const d = Number(day.value)
|
||||
return !validateBirth(y, m, d)
|
||||
}
|
||||
|
||||
function selectScene(key: string) {
|
||||
scene.value = key
|
||||
track('cards_scene_selected', { scene: key })
|
||||
}
|
||||
|
||||
function onPromptClick(row: { label: string; scene: string }) {
|
||||
selectScene(row.scene)
|
||||
questionInput.value = row.label
|
||||
if (birthFilled()) {
|
||||
draw()
|
||||
} else {
|
||||
showBirth.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function sendDraw() {
|
||||
if (questionInput.value.trim()) {
|
||||
scene.value = questionInput.value.trim()
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
async function refreshQuota() {
|
||||
quota.value = await api.getImageCardQuota()
|
||||
}
|
||||
@@ -111,6 +201,7 @@ async function draw() {
|
||||
const msg = validateBirth(y, m, d)
|
||||
if (msg) {
|
||||
error.value = msg
|
||||
showBirth.value = true
|
||||
return
|
||||
}
|
||||
drawing.value = true
|
||||
@@ -160,19 +251,216 @@ 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);
|
||||
.body {
|
||||
padding: 0 16px 88px;
|
||||
}
|
||||
.body.has-results {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.greet {
|
||||
text-align: center;
|
||||
padding: 8px 0 20px;
|
||||
}
|
||||
.greet-icons {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.icon-offset {
|
||||
margin-bottom: -4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.greet-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0;
|
||||
}
|
||||
.greet-sub {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
.quota {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.prompt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.prompt-row:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.prompt-ico {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
.prompt-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.prompt-go {
|
||||
font-size: 14px;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mode-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mode-chip {
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 8px 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mode-chip.on {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
|
||||
}
|
||||
|
||||
.birth-fold {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.birth-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.caret {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.caret.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.birth-panel {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.birth-panel :deep(.bd) {
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.card-result {
|
||||
margin-bottom: 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;
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.err-float {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.question-in {
|
||||
flex: 1;
|
||||
border: 1.5px solid #eee;
|
||||
border-radius: 22px;
|
||||
padding: 11px 16px;
|
||||
font-size: 14px;
|
||||
background: #fdfaf8;
|
||||
outline: none;
|
||||
}
|
||||
.question-in:focus {
|
||||
border-color: rgba(229, 77, 66, 0.35);
|
||||
}
|
||||
.send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.28);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.send-ico {
|
||||
line-height: 1;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,36 +1,112 @@
|
||||
<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">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">身心节律</span>
|
||||
<span class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<template v-if="needBirth && !report">
|
||||
<div class="yxg-card">
|
||||
<div class="intro-card">
|
||||
<HomeToolIcon name="rhythm" :size="64" />
|
||||
<h1 class="intro-title">身心节律</h1>
|
||||
<p class="intro-sub">填写生日,看见作息 · 饮食 · 活动的节奏建议(生活方式参考,非算命)</p>
|
||||
</div>
|
||||
<div class="yxg-card form-card">
|
||||
<label class="yxg-label">生日</label>
|
||||
<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>
|
||||
<button class="cta" type="button" :disabled="loading" @click="start">
|
||||
{{ loading ? '生成中…' : '生成我的节律' }}
|
||||
</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">
|
||||
|
||||
<p v-else-if="loading" class="yxg-hint">正在生成节律…</p>
|
||||
<p v-else-if="error && !report" 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 class="mode-tabs" role="tablist">
|
||||
<button
|
||||
v-for="t in resultTabs"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ on: tab === t.key }"
|
||||
@click="tab = t.key"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'overview'">
|
||||
<h2 class="hl">{{ headline || '我的身心节律' }}</h2>
|
||||
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
|
||||
<div v-if="keywords.length" class="tags">
|
||||
<span v-for="k in keywords" :key="k" class="tag">{{ k }}</span>
|
||||
</div>
|
||||
<div v-if="styleLabel" class="spotlight">
|
||||
<strong>节律倾向</strong>
|
||||
<p>{{ styleLabel }}</p>
|
||||
</div>
|
||||
<p v-if="overviewText" class="overview">{{ overviewText }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'wuxing'">
|
||||
<WuxingBars v-if="wuxingBars.length" :bars="wuxingBars" />
|
||||
<div v-else class="empty-soft">
|
||||
<HomeToolIcon name="rhythm" :size="48" />
|
||||
<p>暂无五行分布数据,可从概览与建议了解节律倾向。</p>
|
||||
</div>
|
||||
<ReportRich :summary="summary" :detail="null" :deep="false" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="tab === 'tips'">
|
||||
<ReportRich :summary="summary" :detail="detail" :deep="!!report.has_deep_access" />
|
||||
<div v-if="!report.has_deep_access" class="lock-card">
|
||||
<p>完整习惯方案与本周练习可在深度版或成长会员中查看。</p>
|
||||
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<router-link class="term-card" to="/companion">
|
||||
<div class="term-ico" aria-hidden="true">
|
||||
<HomeToolIcon name="companion" :size="40" />
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<strong>节气生活陪伴</strong>
|
||||
<p>{{ solarTip || '结合当季节气,微调作息与饮食节奏。' }}</p>
|
||||
</div>
|
||||
<span class="term-go" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
<p v-if="weekFocus" class="focus-tip">本周关注:{{ weekFocus }}</p>
|
||||
</template>
|
||||
|
||||
<div class="sticky-bar">
|
||||
<router-link class="sb-btn primary" to="/companion">去节气陪伴</router-link>
|
||||
<button
|
||||
v-if="!report.has_deep_access"
|
||||
class="sb-btn"
|
||||
type="button"
|
||||
:disabled="paying"
|
||||
@click="buyDeep"
|
||||
>
|
||||
解锁深度
|
||||
</button>
|
||||
<router-link class="sb-btn" to="/ask">去问答</router-link>
|
||||
</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>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -41,8 +117,10 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import WuxingBars, { type WuxingBar } from '../components/WuxingBars.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -51,14 +129,53 @@ const needBirth = ref(true)
|
||||
const error = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const paying = ref(false)
|
||||
const tab = ref<'overview' | 'wuxing' | 'tips' | 'term'>('overview')
|
||||
const year = ref(String(route.query.y || ''))
|
||||
const month = ref(String(route.query.m || ''))
|
||||
const day = ref(String(route.query.d || ''))
|
||||
|
||||
const resultTabs = [
|
||||
{ key: 'overview' as const, label: '概览' },
|
||||
{ key: 'wuxing' as const, label: '五行' },
|
||||
{ key: 'tips' as const, label: '建议' },
|
||||
{ key: 'term' as const, label: '节气' },
|
||||
]
|
||||
|
||||
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 styleLabel = computed(() => String(summary.value.style_label || ''))
|
||||
const overviewText = computed(() => String(summary.value.overview || ''))
|
||||
const weekFocus = computed(() => String(summary.value.week_focus || ''))
|
||||
const keywords = computed(() =>
|
||||
Array.isArray(summary.value.keywords) ? (summary.value.keywords as string[]) : [],
|
||||
)
|
||||
const solarTip = computed(() => {
|
||||
const st = summary.value.solar_term
|
||||
if (st && typeof st === 'object') {
|
||||
const tip = (st as Record<string, unknown>).tip
|
||||
if (tip) return String(tip)
|
||||
}
|
||||
return String(summary.value.today_tip || summary.value.life_tip || '')
|
||||
})
|
||||
|
||||
const wuxingBars = computed((): WuxingBar[] => {
|
||||
const wx = summary.value.wuxing
|
||||
if (!wx || typeof wx !== 'object') return []
|
||||
const bars = (wx as Record<string, unknown>).bars
|
||||
if (!Array.isArray(bars)) return []
|
||||
return bars
|
||||
.map((b) => {
|
||||
const o = b as Record<string, unknown>
|
||||
return {
|
||||
el: String(o.el || o.element || ''),
|
||||
pct: Number(o.pct ?? o.score ?? 0),
|
||||
color: String(o.color || '#E54D42'),
|
||||
}
|
||||
})
|
||||
.filter((b) => b.el)
|
||||
})
|
||||
|
||||
async function generate(y: number, m: number, d: number) {
|
||||
loading.value = true
|
||||
@@ -68,6 +185,7 @@ async function generate(y: number, m: number, d: number) {
|
||||
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)
|
||||
tab.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
@@ -96,8 +214,10 @@ async function load() {
|
||||
needBirth.value = false
|
||||
try {
|
||||
report.value = await api.getReport(reportId)
|
||||
tab.value = 'overview'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -107,6 +227,9 @@ async function load() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -132,9 +255,165 @@ 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}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.who-static {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.spacer { width: 36px; }
|
||||
.body { padding: 8px 16px 88px; }
|
||||
.intro-card { text-align: center; padding: 20px 12px 8px; }
|
||||
.intro-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
|
||||
.form-card { margin-top: 12px; }
|
||||
.cta {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta.soft {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid #f5c4bc;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cta:disabled { opacity: 0.45; }
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
}
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 9px 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-tab.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.hl {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #222;
|
||||
}
|
||||
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #8a4a3a;
|
||||
font-weight: 600;
|
||||
border: 1px solid #f5e8e4;
|
||||
}
|
||||
.spotlight {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spotlight strong { font-size: 14px; color: #222; }
|
||||
.spotlight p { margin-top: 6px; font-size: 13px; color: #666; line-height: 1.5; }
|
||||
.overview { margin-top: 12px; font-size: 13px; color: #555; line-height: 1.65; }
|
||||
.empty-soft {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fafafa;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-soft p { margin-top: 10px; }
|
||||
.lock-card {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.term-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: inherit;
|
||||
}
|
||||
.term-body { flex: 1; min-width: 0; }
|
||||
.term-body strong { font-size: 15px; color: #222; }
|
||||
.term-body p { margin-top: 4px; font-size: 12px; color: #666; line-height: 1.45; }
|
||||
.term-go { font-size: 20px; color: var(--color-primary); }
|
||||
.focus-tip { margin-top: 12px; font-size: 13px; color: #888; }
|
||||
.sticky-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid #f0ebe8;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.sb-btn {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
border-radius: 22px;
|
||||
border: 1.5px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sb-btn.primary {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.25);
|
||||
}
|
||||
.sb-btn:disabled { opacity: 0.45; }
|
||||
.yxg-label { display: block; margin-bottom: 8px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #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">
|
||||
<div class="head">
|
||||
<HomeToolIcon name="growth" :size="48" />
|
||||
<div>
|
||||
<h1 class="title">成长会员</h1>
|
||||
<p class="sub">完整分析 · AI 更多次数 · 专属内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<p v-if="loading" class="hint">加载中…</p>
|
||||
<p v-else-if="error" class="err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
<button type="button" class="retry" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="me?.active" class="yxg-card ok">
|
||||
<div v-if="me?.active" class="status-card on">
|
||||
<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>
|
||||
<p v-if="me.expires_at" class="meta">到期:{{ formatDate(me.expires_at) }}</p>
|
||||
<p class="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 v-else class="status-card">
|
||||
<p class="lead">开通后可查看画像与关系理解的完整分析,并获得更多 AI 问答次数。</p>
|
||||
</div>
|
||||
|
||||
<div class="plans">
|
||||
<button
|
||||
v-for="p in plans"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="plan"
|
||||
:class="{ featured: p.featured }"
|
||||
:disabled="paying"
|
||||
@click="subscribe(p.key)"
|
||||
>
|
||||
<span v-if="p.tag" class="tag">{{ p.tag }}</span>
|
||||
<strong>{{ p.label }}</strong>
|
||||
<span class="price">{{ p.price }}</span>
|
||||
<span class="desc">{{ p.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="note">当前为模拟支付,便于本地验收。</p>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -32,9 +54,16 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
const plans = [
|
||||
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
|
||||
{ key: 'quarter', label: '季卡', price: '模拟开通', desc: '三个月持续成长', featured: true, tag: '推荐' },
|
||||
{ key: 'year', label: '年卡', price: '模拟开通', desc: '全年陪伴与报告', featured: false, tag: '' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const paying = ref(false)
|
||||
const error = ref('')
|
||||
@@ -87,8 +116,78 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.body { padding: 8px 16px 0; }
|
||||
.status-card {
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fafafa;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.status-card.on {
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.ok-title { font-size: 16px; font-weight: 700; color: #222; }
|
||||
.meta { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.lead { font-size: 14px; color: #555; line-height: 1.6; }
|
||||
.plans { display: flex; flex-direction: column; gap: 10px; }
|
||||
.plan {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1.5px solid #f0ebe8;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.plan.featured {
|
||||
border-color: #f5c4bc;
|
||||
background: linear-gradient(145deg, #fff7f4, #ffe9e2);
|
||||
box-shadow: var(--shadow-hero);
|
||||
}
|
||||
.plan strong { font-size: 16px; color: #222; }
|
||||
.price { font-size: 13px; font-weight: 650; color: var(--color-primary); }
|
||||
.desc { font-size: 12px; color: #999; }
|
||||
.tag {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.plan:disabled { opacity: 0.55; }
|
||||
.note { margin-top: 12px; font-size: 11px; color: #ccc; text-align: center; }
|
||||
.hint, .err { font-size: 13px; color: #999; padding: 8px 0; }
|
||||
.err { color: var(--color-primary); }
|
||||
.retry {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: underline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,97 @@
|
||||
<template>
|
||||
<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>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="profile-head">
|
||||
<span class="av" aria-hidden="true">
|
||||
<BrandLogo size="card" class="av-logo" />
|
||||
</span>
|
||||
<div class="who">
|
||||
<p class="name">愈心谷用户</p>
|
||||
<p class="meta">档案 {{ profileCount }} 份</p>
|
||||
</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>
|
||||
<router-link class="edit" to="/profile">编辑 ›</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="loading" class="hint">加载中…</p>
|
||||
<p v-else-if="error" class="err">
|
||||
{{ error }}
|
||||
<button type="button" class="retry" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<!-- 资产条 -->
|
||||
<section class="assets">
|
||||
<router-link to="/membership" class="asset">
|
||||
<strong>{{ membership?.active ? planLabel : '未开通' }}</strong>
|
||||
<span>成长会员</span>
|
||||
</router-link>
|
||||
<router-link to="/ask" class="asset">
|
||||
<strong>{{ profileCount }}</strong>
|
||||
<span>档案</span>
|
||||
</router-link>
|
||||
<router-link to="/reports" class="asset">
|
||||
<strong>报告</strong>
|
||||
<span>成长记录</span>
|
||||
</router-link>
|
||||
<router-link to="/membership" class="asset accent">
|
||||
<strong>{{ membership?.active ? '续费' : '开通' }}</strong>
|
||||
<span>会员中心</span>
|
||||
</router-link>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<p class="sec-title">档案与内容</p>
|
||||
<div class="group">
|
||||
<ListRow
|
||||
v-for="item in groupArchive"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:title="item.label"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<p class="sec-title">成长与会员</p>
|
||||
<div class="group">
|
||||
<ListRow
|
||||
v-for="item in groupGrowth"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:title="item.label"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</PageShell>
|
||||
</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'
|
||||
import BrandLogo from '../components/BrandLogo.vue'
|
||||
import ListRow from '../components/ListRow.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
|
||||
|
||||
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 groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
|
||||
{ to: '/profile', label: '个人档案', icon: 'portrait' },
|
||||
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
|
||||
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
|
||||
{ to: '/star', label: '星象性格', icon: 'star' },
|
||||
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
|
||||
{ to: '/cards', label: '意象卡片', icon: 'cards' },
|
||||
]
|
||||
|
||||
const groupGrowth: { to: string; label: string; icon: HomeToolIconName }[] = [
|
||||
{ to: '/relation', label: '人格匹配', icon: 'relation' },
|
||||
{ to: '/ask', label: 'AI 成长助手', icon: 'ask' },
|
||||
{ to: '/explore', label: '探索测试', icon: 'mbti' },
|
||||
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
|
||||
{ to: '/membership', label: '成长会员', icon: 'growth' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -68,7 +104,7 @@ const planLabel = computed(() => {
|
||||
if (p === 'quarter') return '季卡'
|
||||
if (p === 'year') return '年卡'
|
||||
if (p === 'month') return '月卡'
|
||||
return '成长会员'
|
||||
return '会员中'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
@@ -89,10 +125,107 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.profile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
.av {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.av-logo {
|
||||
width: 34px !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.who { flex: 1; min-width: 0; }
|
||||
.name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.edit {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.asset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 4px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
.asset strong {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
.asset span {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
.asset.accent strong {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.sec {
|
||||
padding: 0 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.sec-title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.group {
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2px 10px;
|
||||
}
|
||||
.group :deep(.lr) {
|
||||
border-bottom: 1px solid #f0ebe8;
|
||||
}
|
||||
.group :deep(.lr:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.hint, .err {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.err { color: var(--color-primary); }
|
||||
.retry {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: underline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
<template>
|
||||
<main class="yxg-page decode-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<BackButton />
|
||||
<PageTitle
|
||||
feature="portrait"
|
||||
title="愈心解码"
|
||||
:sub="needBirth && !report ? '填写生日,生成身心解码报告' : '一个生日,读懂身与心'"
|
||||
/>
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<div v-if="!report" class="head">
|
||||
<HomeToolIcon name="portrait" :size="48" />
|
||||
<div>
|
||||
<h1 class="title">愈心解码</h1>
|
||||
<p class="sub">一个生日,读懂身与心</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="head compact">
|
||||
<HomeToolIcon name="portrait" :size="40" />
|
||||
<h1 class="title sm">愈心解码</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body" :class="{ 'has-sticky': !!report }">
|
||||
<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 class="decode-cta">
|
||||
<div class="decode-inner">
|
||||
<HomeToolIcon name="portrait" :size="56" />
|
||||
<h2 class="decode-title">愈心解码</h2>
|
||||
<p class="decode-sub">一个生日,读懂性格与身心节奏</p>
|
||||
<div class="decode-inputs">
|
||||
<BirthDateInputs v-model:year="year" v-model:month="month" v-model:day="day" />
|
||||
<button class="decode-btn" type="button" :disabled="loading" @click="start">
|
||||
{{ loading ? '解码中…' : '开始解码' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formError" class="yxg-err">{{ formError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else-if="loading" class="yxg-hint">正在解码…</p>
|
||||
<p v-else-if="error" class="yxg-err">
|
||||
<p v-else-if="error && !report" class="yxg-err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="retry">重试</button>
|
||||
</p>
|
||||
|
||||
<template v-else-if="report">
|
||||
<DecodePanel
|
||||
:summary="summary"
|
||||
@@ -32,16 +48,23 @@
|
||||
@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>
|
||||
<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>
|
||||
|
||||
<!-- Sticky bottom CTAs -->
|
||||
<div v-if="report" class="sticky-bar">
|
||||
<router-link class="sticky-btn" to="/ask">去问答</router-link>
|
||||
<router-link class="sticky-btn primary" to="/relation">去做匹配</router-link>
|
||||
<button type="button" class="sticky-btn ghost" @click="shareOpen = true">分享</button>
|
||||
</div>
|
||||
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -53,7 +76,8 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
@@ -176,17 +200,130 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mt {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.share-btn {
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.links {
|
||||
.head.compact {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.title.sm {
|
||||
font-size: 18px;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.body {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
.body.has-sticky {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.decode-cta {
|
||||
margin: 8px 0 16px;
|
||||
padding: 28px 20px 24px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-hero);
|
||||
text-align: center;
|
||||
}
|
||||
.decode-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.decode-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-gold);
|
||||
letter-spacing: 0.08em;
|
||||
margin: 14px 0 4px;
|
||||
}
|
||||
.decode-sub {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.decode-inputs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.decode-inputs :deep(.bd) {
|
||||
justify-content: center;
|
||||
}
|
||||
.decode-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
|
||||
cursor: pointer;
|
||||
}
|
||||
.decode-btn:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.report-link {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.sticky-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0));
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.sticky-btn {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sticky-btn.primary {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
|
||||
}
|
||||
.sticky-btn.ghost {
|
||||
flex: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const RouterLinkStub = defineComponent({
|
||||
|
||||
@@ -1,46 +1,75 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="profile" title="个人档案" sub="管理我的信息与重要的人" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">个人档案</span>
|
||||
<span class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<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>
|
||||
<button type="button" class="yxg-link linkbtn" @click="load">重试</button>
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!items.length" class="yxg-card empty">
|
||||
<div v-if="!items.length" class="empty-card">
|
||||
<HomeToolIcon name="portrait" :size="56" />
|
||||
<p>暂无档案。请先去首页完成性格探索,或添加重要的人。</p>
|
||||
<router-link class="yxg-btn" to="/">去性格探索</router-link>
|
||||
<router-link class="cta" 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>
|
||||
<template v-else>
|
||||
<div v-if="selfProfile" class="self-card">
|
||||
<div class="av-ring">
|
||||
<span class="av-inner">{{ avatarInitial(selfProfile) }}</span>
|
||||
</div>
|
||||
<div class="self-body">
|
||||
<strong>{{ selfProfile.display_name || '我' }}</strong>
|
||||
<span class="meta">我 · {{ formatDate(selfProfile.birth_date) }}</span>
|
||||
<span v-if="selfProfile.birth_place" class="meta sub">{{ selfProfile.birth_place }}</span>
|
||||
</div>
|
||||
<div class="self-ops">
|
||||
<button type="button" class="op-btn" @click="startEdit(selfProfile)">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="others.length" class="section">
|
||||
<h2 class="sec-title">重要的人</h2>
|
||||
<div class="others-list">
|
||||
<div v-for="p in others" :key="p.id" class="other-row">
|
||||
<ListRow
|
||||
:title="p.display_name || 'TA'"
|
||||
:desc="`${relationLabel(p.relation_type)} · ${formatDate(p.birth_date)}`"
|
||||
:icon="'relation'"
|
||||
:chevron="false"
|
||||
/>
|
||||
<div class="row-ops">
|
||||
<button type="button" class="op-btn" @click="startEdit(p)">编辑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="op-btn danger"
|
||||
:disabled="busyId === p.id"
|
||||
@click="remove(p)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-if="editing" class="yxg-card form">
|
||||
<h2 class="card-title">编辑档案</h2>
|
||||
<div v-if="editing" class="yxg-card form-panel">
|
||||
<h2 class="panel-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>
|
||||
<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">
|
||||
@@ -56,49 +85,98 @@
|
||||
</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>
|
||||
<button class="cta" type="button" :disabled="saving" @click="saveEdit">保存</button>
|
||||
<button class="cta soft" 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 v-if="showInviteFill" class="yxg-card form-panel invite-panel">
|
||||
<h2 class="panel-title">邀请好友填档案</h2>
|
||||
<p class="invite-desc">
|
||||
生成链接发给家人或朋友。对方填写生日后,可出现在你的合盘与关系理解中。
|
||||
</p>
|
||||
<p v-if="inviteHint" class="yxg-meta">{{ inviteHint }}</p>
|
||||
<p v-if="invitePath" class="invite-link">{{ invitePath }}</p>
|
||||
<p v-if="inviteError" class="yxg-err">{{ inviteError }}</p>
|
||||
<div class="form-actions">
|
||||
<button class="cta" type="button" :disabled="inviting || !selfProfile" @click="createFillInvite">
|
||||
{{ inviting ? '生成中…' : invitePath ? '重新生成' : '生成填写链接' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="invitePath"
|
||||
class="cta soft"
|
||||
type="button"
|
||||
:disabled="copyBusy"
|
||||
@click="copyInvite"
|
||||
>
|
||||
{{ copyBusy ? '复制中…' : '复制链接' }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="yxg-link linkbtn" @click="closeInviteFill">关闭</button>
|
||||
</div>
|
||||
|
||||
<router-link class="yxg-link-plain link" to="/relation">去做关系理解 →</router-link>
|
||||
<div v-if="showAddForm" class="yxg-card form-panel">
|
||||
<div class="panel-head">
|
||||
<h2 class="panel-title">添加档案</h2>
|
||||
<button type="button" class="yxg-link linkbtn" @click="openInviteFill">邀请好友</button>
|
||||
</div>
|
||||
<label class="yxg-label">昵称</label>
|
||||
<input class="yxg-input" v-model="newName" placeholder="请输入昵称(1–6 个字)" maxlength="6" />
|
||||
<label class="yxg-label">关系</label>
|
||||
<div class="rel-chips" role="group" aria-label="关系类型">
|
||||
<button
|
||||
v-for="r in relationOptions"
|
||||
:key="r.value"
|
||||
type="button"
|
||||
class="rel-chip"
|
||||
:class="{ on: newRelationType === r.value }"
|
||||
@click="newRelationType = r.value"
|
||||
>
|
||||
{{ r.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="yxg-label">生日</label>
|
||||
<BirthDateInputs v-model:year="newY" v-model:month="newM" v-model:day="newD" compact />
|
||||
<label class="yxg-label">出生地(可选)</label>
|
||||
<BirthPlaceSelect v-model="newPlace" />
|
||||
<p v-if="addError" class="yxg-err">{{ addError }}</p>
|
||||
<button class="cta" type="button" :disabled="adding" @click="addOther">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="bottom-cta">
|
||||
<button type="button" class="fab" @click="toggleAdd">
|
||||
{{ showAddForm ? '收起' : '添加档案' }}
|
||||
</button>
|
||||
<router-link class="link-row" to="/synastry">去做合盘 ›</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import ListRow from '../components/ListRow.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { copyText } from '../lib/shareLink'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref<Profile[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const busyId = ref('')
|
||||
const editing = ref<Profile | null>(null)
|
||||
const showAddForm = ref(false)
|
||||
const showInviteFill = ref(false)
|
||||
const formName = ref('')
|
||||
const formY = ref('')
|
||||
const formM = ref('')
|
||||
@@ -112,14 +190,36 @@ const newName = ref('')
|
||||
const newY = ref('')
|
||||
const newM = ref('')
|
||||
const newD = ref('')
|
||||
const newRelationType = ref('partner')
|
||||
const newPlace = ref('')
|
||||
const newRelationType = ref('family')
|
||||
const addError = ref('')
|
||||
const adding = ref(false)
|
||||
const inviting = ref(false)
|
||||
const invitePath = ref('')
|
||||
const inviteError = ref('')
|
||||
const inviteHint = ref('')
|
||||
const copyBusy = ref(false)
|
||||
|
||||
const relationOptions = [
|
||||
{ value: 'family', label: '家人' },
|
||||
{ value: 'friend', label: '朋友' },
|
||||
{ value: 'partner', label: '伴侣' },
|
||||
{ value: 'colleague', label: '同事' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
|
||||
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
|
||||
|
||||
function formatDate(v: string) {
|
||||
return (v || '').slice(0, 10)
|
||||
}
|
||||
|
||||
function avatarInitial(p: Profile) {
|
||||
const n = p.display_name || (p.relation === 'self' ? '我' : 'T')
|
||||
return n.slice(0, 1)
|
||||
}
|
||||
|
||||
function splitBirth(v: string) {
|
||||
const s = formatDate(v)
|
||||
const [y, m, d] = s.split('-')
|
||||
@@ -145,6 +245,69 @@ function relationLabel(t?: string | null) {
|
||||
return map[t || ''] || '关系对象'
|
||||
}
|
||||
|
||||
function toggleAdd() {
|
||||
showAddForm.value = !showAddForm.value
|
||||
if (showAddForm.value) {
|
||||
editing.value = null
|
||||
showInviteFill.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
showAddForm.value = true
|
||||
showInviteFill.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
function openInviteFill() {
|
||||
showInviteFill.value = true
|
||||
showAddForm.value = false
|
||||
editing.value = null
|
||||
inviteError.value = ''
|
||||
if (!selfProfile.value) {
|
||||
inviteHint.value = '请先完成自己的档案,再邀请好友填写。'
|
||||
} else {
|
||||
inviteHint.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeInviteFill() {
|
||||
showInviteFill.value = false
|
||||
void router.replace({ path: '/profile', query: {} })
|
||||
}
|
||||
|
||||
function applyQueryFlags() {
|
||||
if (route.query.add === '1') openAddForm()
|
||||
if (route.query.inviteFill === '1') openInviteFill()
|
||||
}
|
||||
|
||||
async function createFillInvite() {
|
||||
if (!selfProfile.value) {
|
||||
inviteError.value = '请先完成自己的档案'
|
||||
return
|
||||
}
|
||||
inviting.value = true
|
||||
inviteError.value = ''
|
||||
inviteHint.value = ''
|
||||
try {
|
||||
const res = await api.createSynastryInvite(selfProfile.value.id)
|
||||
invitePath.value = `${location.origin}${res.path}`
|
||||
inviteHint.value = '好友打开链接后填写生日即可;也可用于合盘。'
|
||||
} catch (e) {
|
||||
inviteError.value = e instanceof Error ? e.message : '生成失败'
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!invitePath.value) return
|
||||
copyBusy.value = true
|
||||
const ok = await copyText(invitePath.value)
|
||||
inviteHint.value = ok ? '链接已复制,发给家人或朋友即可。' : '复制失败,请长按链接手动复制。'
|
||||
copyBusy.value = false
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -160,6 +323,7 @@ async function load() {
|
||||
|
||||
function startEdit(p: Profile) {
|
||||
editing.value = p
|
||||
showAddForm.value = false
|
||||
formName.value = p.display_name || ''
|
||||
const b = splitBirth(p.birth_date)
|
||||
formY.value = b.y
|
||||
@@ -227,12 +391,16 @@ async function addOther() {
|
||||
birth_date: birth,
|
||||
display_name: newName.value.trim() || 'TA',
|
||||
relation_type: newRelationType.value,
|
||||
birth_place: newPlace.value || undefined,
|
||||
})
|
||||
newName.value = ''
|
||||
newY.value = ''
|
||||
newM.value = ''
|
||||
newD.value = ''
|
||||
newPlace.value = ''
|
||||
showAddForm.value = false
|
||||
await load()
|
||||
if (route.query.add === '1') void router.replace({ path: '/profile', query: {} })
|
||||
} catch (e) {
|
||||
addError.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
@@ -240,22 +408,205 @@ async function addOther() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(
|
||||
() => [route.query.add, route.query.inviteFill],
|
||||
() => applyQueryFlags(),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
applyQueryFlags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||
.spacer { width: 36px; }
|
||||
.body { padding: 8px 16px 100px; }
|
||||
.empty-card {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fafafa;
|
||||
color: #888;
|
||||
}
|
||||
.empty-card p { margin: 12px 0; font-size: 14px; line-height: 1.5; }
|
||||
.self-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
box-shadow: var(--shadow-hero);
|
||||
}
|
||||
.av-ring {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
|
||||
}
|
||||
.av-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.self-body { flex: 1; min-width: 0; }
|
||||
.self-body strong { display: block; font-size: 17px; color: #222; }
|
||||
.meta { display: block; font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.meta.sub { color: #aaa; }
|
||||
.self-ops { flex-shrink: 0; }
|
||||
.sec-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.other-row {
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.other-row :deep(.lr) { padding: 12px 14px 8px; }
|
||||
.row-ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
.op-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
.op-btn.danger { color: var(--color-primary); border-color: #ffe0dc; background: #fff5f4; }
|
||||
.form-panel { margin: 14px 0; }
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.panel-title { font-size: 16px; font-weight: 700; margin: 0 0 12px; color: #222; }
|
||||
.panel-head .panel-title { margin-bottom: 0; }
|
||||
.invite-panel .invite-desc {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #666;
|
||||
}
|
||||
.invite-link {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #faf7f5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #444;
|
||||
word-break: break-all;
|
||||
}
|
||||
.rel-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rel-chip {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
.rel-chip.on {
|
||||
border-color: rgba(229, 77, 66, 0.45);
|
||||
background: #fff5f4;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.cta {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta.soft {
|
||||
background: #fff;
|
||||
color: #666;
|
||||
border: 1.5px solid #eee;
|
||||
box-shadow: none;
|
||||
}
|
||||
.form-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.form-actions .cta { flex: 1; margin-top: 0; }
|
||||
.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; }
|
||||
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
|
||||
.yxg-label:first-of-type { margin-top: 0; }
|
||||
.bottom-cta {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid #f0ebe8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.fab {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.link-row { font-size: 13px; color: var(--color-primary); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('RelationPage', () => {
|
||||
})
|
||||
|
||||
const w = mount(RelationPage)
|
||||
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
|
||||
await w.findAll('button').find((b) => b.text().includes('去匹配'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('我·稳')
|
||||
expect(w.text()).toContain('节奏不同')
|
||||
@@ -82,7 +82,7 @@ describe('RelationPage', () => {
|
||||
})
|
||||
|
||||
const w = mount(RelationPage)
|
||||
await w.findAll('button').find((b) => b.text().includes('开始匹配'))!.trigger('click')
|
||||
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()
|
||||
|
||||
@@ -1,60 +1,142 @@
|
||||
<template>
|
||||
<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>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">人格匹配</span>
|
||||
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true">↗</button>
|
||||
<span v-else class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<template v-if="!report">
|
||||
<div class="hero-block">
|
||||
<h1 class="hero-title">看见彼此的相处模样</h1>
|
||||
<p class="hero-sub">基于档案与生日,理解相处模式 · 性格对照 · 默契指数</p>
|
||||
</div>
|
||||
|
||||
<div class="focus-chips" role="tablist">
|
||||
<button
|
||||
v-for="f in focusTabs"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="fchip"
|
||||
:class="{ on: focus === f.key }"
|
||||
@click="focus = f.key"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="deco-card" aria-hidden="true">
|
||||
<div class="deco-blur">
|
||||
<span class="deco-av me" />
|
||||
<span class="deco-heart">♡</span>
|
||||
<span class="deco-av ta" />
|
||||
</div>
|
||||
<p class="deco-hint">{{ focusHint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="yxg-card form-card">
|
||||
<label class="yxg-label">关系类型</label>
|
||||
<div class="rel-chips">
|
||||
<button
|
||||
v-for="r in relationOptions"
|
||||
:key="r.value"
|
||||
type="button"
|
||||
class="rchip"
|
||||
:class="{ on: relationType === r.value }"
|
||||
@click="relationType = r.value"
|
||||
>
|
||||
{{ r.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="yxg-label">TA 的称呼</label>
|
||||
<input class="yxg-input" v-model="otherName" placeholder="例如:伴侣" />
|
||||
<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>
|
||||
<button class="cta" 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>
|
||||
|
||||
<template v-else>
|
||||
<p class="diff-lead">{{ diff }}</p>
|
||||
|
||||
<div class="mode-tabs" role="tablist">
|
||||
<button
|
||||
v-for="t in resultTabs"
|
||||
:key="t.key"
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ on: tab === t.key }"
|
||||
@click="tab = t.key"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'index'">
|
||||
<div class="pair-spot">
|
||||
<div class="who"><span class="tag">我</span><strong>{{ meStyle || '—' }}</strong></div>
|
||||
<span class="vs">×</span>
|
||||
<div class="who"><span class="tag">TA</span><strong>{{ otherStyle || '—' }}</strong></div>
|
||||
</div>
|
||||
<div class="spotlight-row">
|
||||
<div v-if="harmony != null" class="spot big">
|
||||
<em>默契指数</em>
|
||||
<strong>{{ harmony }}</strong>
|
||||
</div>
|
||||
<div v-if="fitLabel" class="spot badge-spot">
|
||||
<span class="fit-badge">{{ fitLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
|
||||
<template v-else-if="tab === 'compare'">
|
||||
<MatchCompare
|
||||
:me-style="meStyle"
|
||||
:other-style="otherStyle"
|
||||
:fit-label="fitLabel"
|
||||
:harmony="harmony"
|
||||
:love="loveIndex"
|
||||
:friend="friendIndex"
|
||||
:marriage="marriageIndex"
|
||||
:star-compare="starCompare"
|
||||
:dimensions="dimCompare"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p v-else class="tips-intro">基于你们的档案,以下是相处与沟通建议。</p>
|
||||
|
||||
<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 v-if="!report.has_deep_access" class="lock-card">
|
||||
<p>完整相处建议、冲突修复与本周练习可在深度版或成长会员中查看。</p>
|
||||
<button class="cta soft" 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="/portrait">回看愈心解码 →</router-link>
|
||||
|
||||
<div class="sticky-bar">
|
||||
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
|
||||
<router-link class="sb-btn primary" to="/ask">去问答</router-link>
|
||||
<router-link class="sb-btn" to="/synastry">合盘</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>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -64,7 +146,7 @@ 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 PageShell from '../components/PageShell.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
@@ -81,6 +163,34 @@ const error = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const shareOpen = ref(false)
|
||||
const needSelf = ref(false)
|
||||
const focus = ref('mode')
|
||||
const tab = ref<'index' | 'compare' | 'tips'>('index')
|
||||
|
||||
const focusTabs = [
|
||||
{ key: 'mode', label: '相处模式' },
|
||||
{ key: 'personality', label: '性格对照' },
|
||||
{ key: 'sync', label: '默契指数' },
|
||||
]
|
||||
const relationOptions = [
|
||||
{ value: 'partner', label: '伴侣' },
|
||||
{ value: 'friend', label: '朋友' },
|
||||
{ value: 'family', label: '家人' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
const resultTabs = [
|
||||
{ key: 'index' as const, label: '指数' },
|
||||
{ key: 'compare' as const, label: '对照' },
|
||||
{ key: 'tips' as const, label: '建议' },
|
||||
]
|
||||
|
||||
const focusHint = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
mode: '看见日常互动里,谁更主动、谁更需要空间',
|
||||
personality: '对照彼此的性格关键词与表达习惯',
|
||||
sync: '恋爱 · 友情 · 婚姻维度的默契参考',
|
||||
}
|
||||
return map[focus.value] || map.mode
|
||||
})
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
@@ -140,9 +250,7 @@ async function run() {
|
||||
const y = Number(oy.value)
|
||||
const m = Number(om.value)
|
||||
const d = Number(od.value)
|
||||
if (!y || !m || !d) {
|
||||
throw new Error('请填写 TA 的完整生日')
|
||||
}
|
||||
if (!y || !m || !d) throw new Error('请填写 TA 的完整生日')
|
||||
const self = await ensureSelf()
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const other = await api.createProfile({
|
||||
@@ -153,6 +261,7 @@ async function run() {
|
||||
})
|
||||
const out = await api.createRelationInsight(self.id, other.id)
|
||||
report.value = out.report
|
||||
tab.value = 'index'
|
||||
track(AnalyticsEvent.RelationCompleted)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '生成失败'
|
||||
@@ -181,9 +290,237 @@ async function buyDeep() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.who-static {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.spacer { width: 36px; }
|
||||
.share-ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
}
|
||||
.body { padding: 8px 16px 88px; }
|
||||
.hero-block { text-align: center; padding: 12px 8px 4px; }
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #222;
|
||||
}
|
||||
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
|
||||
.focus-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.fchip {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fchip.on {
|
||||
background: #fff5f4;
|
||||
border-color: #f5c4bc;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.deco-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 20px 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.deco-blur {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
.deco-av {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #ffd0c4, #ffb8a8);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.deco-av.ta { background: linear-gradient(145deg, #d8eaff, #b4d2f5); }
|
||||
.deco-heart { font-size: 20px; color: var(--color-primary); opacity: 0.7; }
|
||||
.deco-hint { margin-top: 12px; font-size: 12px; color: #8a6a5a; line-height: 1.45; }
|
||||
.form-card { margin-top: 4px; }
|
||||
.rel-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
|
||||
.rchip {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.rchip.on {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.cta {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta.soft {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid #f5c4bc;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cta:disabled { opacity: 0.45; }
|
||||
.diff-lead {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pair-spot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fafafa;
|
||||
}
|
||||
.pair-spot .who { text-align: center; flex: 1; }
|
||||
.pair-spot .tag {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.pair-spot strong { font-size: 14px; color: #222; }
|
||||
.pair-spot .vs { font-size: 18px; color: var(--color-primary); opacity: 0.6; }
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
}
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 9px 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-tab.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.spotlight-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.spot {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spot em { display: block; font-style: normal; font-size: 12px; color: #999; margin-bottom: 4px; }
|
||||
.spot strong { font-size: 32px; font-family: var(--font-display); color: var(--color-primary); }
|
||||
.badge-spot { display: flex; align-items: center; justify-content: center; }
|
||||
.fit-badge {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.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(--color-primary); font-family: var(--font-display); }
|
||||
.lock-card {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.tips-intro { font-size: 13px; color: #888; margin-bottom: 12px; line-height: 1.5; }
|
||||
.sticky-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid #f0ebe8;
|
||||
}
|
||||
.sb-btn {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
border-radius: 22px;
|
||||
border: 1.5px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sb-btn.primary {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
|
||||
.yxg-label:first-child { margin-top: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="reports" title="成长报告" :sub="typeLabel || undefined" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">成长报告</span>
|
||||
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true">↗</button>
|
||||
<span v-else class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<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-else-if="report">
|
||||
<div class="report-head">
|
||||
<span class="type-badge">{{ typeLabel }}</span>
|
||||
<span class="date-badge">{{ createdLabel }}</span>
|
||||
</div>
|
||||
<h2 class="hl">{{ headline || oneLiner || '成长报告' }}</h2>
|
||||
<p v-if="oneLiner && headline" class="lead">{{ oneLiner }}</p>
|
||||
|
||||
<router-link class="vip-strip" to="/membership">
|
||||
<span class="vip-ico" aria-hidden="true">★</span>
|
||||
<span class="vip-copy">
|
||||
<strong>成长会员</strong>
|
||||
<em>深度报告 · 全年节气陪伴 · 解锁完整分析</em>
|
||||
</span>
|
||||
<span class="vip-go">开通 ›</span>
|
||||
</router-link>
|
||||
|
||||
<div v-if="showModeTabs" class="mode-tabs" role="tablist">
|
||||
<button
|
||||
v-for="p in activePanels"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ on: panelKey === p.key }"
|
||||
@click="panelKey = p.key"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 星座 -->
|
||||
<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'">
|
||||
<template v-if="panelKey === 'chart'">
|
||||
<NatalWheel
|
||||
v-if="wheelPlanets.length"
|
||||
:planets="wheelPlanets"
|
||||
@@ -40,12 +60,12 @@
|
||||
<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>
|
||||
<div v-if="activeCard" class="spotlight">
|
||||
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
|
||||
<p>{{ activeCard.teaser }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="starPanel === 'fortune'">
|
||||
<template v-else-if="panelKey === 'fortune'">
|
||||
<div class="fortune-tabs">
|
||||
<button
|
||||
v-for="k in fortuneKeys"
|
||||
@@ -58,52 +78,36 @@
|
||||
{{ fortuneLabel(k) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeFortune" class="yxg-card soft">
|
||||
<div v-if="activeFortune" class="spotlight">
|
||||
<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>
|
||||
<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">
|
||||
<li v-for="p in planets" :key="p.key" class="planet-row">
|
||||
<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 v-if="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'">
|
||||
<template v-if="panelKey === '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'">
|
||||
<template v-else-if="panelKey === 'overlay'">
|
||||
<p v-for="(e, i) in synOverlayEntries" :key="i" class="yxg-meta">
|
||||
{{ e.planet }} → {{ e.house }}宫 · {{ e.house_tip }}
|
||||
</p>
|
||||
@@ -116,7 +120,6 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 愈心解码:三角 + 五行 + 付费墙 -->
|
||||
<template v-if="report.type === 'portrait'">
|
||||
<DecodePanel
|
||||
:summary="summary"
|
||||
@@ -128,23 +131,32 @@
|
||||
</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 v-if="!report.has_deep_access && report.type !== 'portrait'" class="lock-card">
|
||||
<p>完整分析可在深度版或成长会员中查看。</p>
|
||||
<button class="cta soft" type="button" :disabled="paying" @click="buyDeep">解锁深度版</button>
|
||||
</div>
|
||||
|
||||
<div class="sticky-bar">
|
||||
<button type="button" class="sb-btn" @click="shareOpen = true">分享</button>
|
||||
<button
|
||||
v-if="!report.has_deep_access"
|
||||
class="sb-btn primary"
|
||||
type="button"
|
||||
:disabled="paying"
|
||||
@click="buyDeep"
|
||||
>
|
||||
解锁
|
||||
</button>
|
||||
<router-link v-if="relatedLink" class="sb-btn" :class="{ primary: report.has_deep_access }" :to="relatedLink.to">
|
||||
{{ relatedLink.label }}
|
||||
</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>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -153,7 +165,7 @@ 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 PageShell from '../components/PageShell.vue'
|
||||
import DecodePanel from '../components/DecodePanel.vue'
|
||||
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
@@ -168,10 +180,15 @@ 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 panelKey = ref('chart')
|
||||
const signTab = ref('sun')
|
||||
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly'>('daily')
|
||||
const synTab = ref('compare')
|
||||
|
||||
const starPanels = [
|
||||
{ key: 'chart', label: '星盘' },
|
||||
{ key: 'fortune', label: '运势' },
|
||||
{ key: 'planets', label: '行星' },
|
||||
]
|
||||
const synTabs = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
{ key: 'composite', label: '组合' },
|
||||
@@ -180,11 +197,6 @@ const synTabs = [
|
||||
{ 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 = {
|
||||
@@ -217,6 +229,19 @@ const typeLabel = computed(() => {
|
||||
}
|
||||
return map[report.value?.type || ''] || '成长报告'
|
||||
})
|
||||
const showModeTabs = computed(() => report.value?.type === 'star' || report.value?.type === 'synastry')
|
||||
const activePanels = computed(() => (report.value?.type === 'synastry' ? synTabs : starPanels))
|
||||
|
||||
const relatedLink = computed(() => {
|
||||
const t = report.value?.type
|
||||
if (t === 'star') return { to: '/star', label: '星座' }
|
||||
if (t === 'synastry') return { to: '/synastry', label: '合盘' }
|
||||
if (t === 'portrait') return { to: '/portrait', label: '解码' }
|
||||
if (t === 'relation') return { to: '/relation', label: '匹配' }
|
||||
if (t === 'rhythm') return { to: '/rhythm', label: '节律' }
|
||||
return { to: '/reports', label: '全部' }
|
||||
})
|
||||
|
||||
const chartObj = computed(() =>
|
||||
summary.value.chart && typeof summary.value.chart === 'object'
|
||||
? (summary.value.chart as Record<string, unknown>)
|
||||
@@ -261,7 +286,7 @@ const synCharts = computed(() =>
|
||||
: {},
|
||||
)
|
||||
const synActiveBlock = computed(() => {
|
||||
const c = synCharts.value[synTab.value]
|
||||
const c = synCharts.value[panelKey.value]
|
||||
return c && typeof c === 'object' ? (c as Record<string, unknown>) : null
|
||||
})
|
||||
const synActiveTip = computed(() => String(synActiveBlock.value?.tip || ''))
|
||||
@@ -287,9 +312,7 @@ 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 }[])
|
||||
: []
|
||||
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 } {
|
||||
@@ -319,7 +342,7 @@ const createdLabel = computed(() => {
|
||||
const t = report.value?.created_at
|
||||
if (!t) return ''
|
||||
try {
|
||||
return new Date(t).toLocaleString('zh-CN')
|
||||
return new Date(t).toLocaleDateString('zh-CN')
|
||||
} catch {
|
||||
return t
|
||||
}
|
||||
@@ -346,34 +369,32 @@ 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,
|
||||
() =>
|
||||
(report.value?.type === 'relation' || report.value?.type === 'synastry') &&
|
||||
(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 = {
|
||||
return {
|
||||
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
|
||||
} as RelationSharePayload
|
||||
}
|
||||
const p: PortraitSharePayload = {
|
||||
return {
|
||||
type: 'portrait',
|
||||
title: headline.value,
|
||||
line: oneLiner.value,
|
||||
keywords: keywords.value,
|
||||
}
|
||||
return p
|
||||
} as PortraitSharePayload
|
||||
})
|
||||
|
||||
function fortuneLabel(k: string) {
|
||||
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[
|
||||
k
|
||||
] || k
|
||||
return ({ daily: '今日', weekly: '本周', monthly: '本月', yearly: '今年', lifetime: '一生' } as Record<string, string>)[k] || k
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -384,7 +405,7 @@ async function load() {
|
||||
const id = String(route.params.id || '')
|
||||
if (!id) throw new Error('缺少报告 id')
|
||||
report.value = await api.getReport(id)
|
||||
starPanel.value = 'chart'
|
||||
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
|
||||
signTab.value = 'sun'
|
||||
fortuneKey.value = 'daily'
|
||||
} catch (e) {
|
||||
@@ -410,34 +431,204 @@ async function buyDeep() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
load()
|
||||
},
|
||||
)
|
||||
|
||||
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;
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.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;
|
||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||
.spacer { width: 36px; }
|
||||
.share-ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
}
|
||||
.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}
|
||||
.body { padding: 8px 16px 88px; }
|
||||
.report-head { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.date-badge {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0ebe8;
|
||||
}
|
||||
.hl {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #222;
|
||||
}
|
||||
.lead { margin-top: 6px; font-size: 13px; color: #777; line-height: 1.55; }
|
||||
.vip-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
|
||||
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
|
||||
color: inherit;
|
||||
}
|
||||
.vip-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0c86a, #e8a830);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vip-copy { flex: 1; min-width: 0; }
|
||||
.vip-copy strong { display: block; font-size: 14px; color: #222; }
|
||||
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
|
||||
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mode-tabs::-webkit-scrollbar { display: none; }
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
min-width: 52px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 9px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode-tab.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.spotlight {
|
||||
margin: 12px 0;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spotlight h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
|
||||
.spotlight p { font-size: 13px; color: #555; line-height: 1.6; }
|
||||
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ftab {
|
||||
flex: 1;
|
||||
min-width: 52px;
|
||||
border: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
padding: 10px 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.ftab.on { border-color: var(--color-primary); background: #fff5f4; color: #222; font-weight: 600; }
|
||||
.ft-title { font-weight: 700; color: #222; margin-bottom: 6px; }
|
||||
.planet-list { list-style: none; padding: 0; margin: 0; }
|
||||
.planet-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.planet-row strong { font-size: 14px; color: #222; }
|
||||
.planet-row span { font-size: 12px; color: #888; }
|
||||
.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(--color-primary); font-family: var(--font-display); }
|
||||
.lock-card {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.cta.soft {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1.5px solid #f5c4bc;
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta.soft:disabled { opacity: 0.45; }
|
||||
.sticky-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid #f0ebe8;
|
||||
}
|
||||
.sb-btn {
|
||||
flex: 1;
|
||||
padding: 11px 8px;
|
||||
border-radius: 22px;
|
||||
border: 1.5px solid #f0ebe8;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sb-btn.primary {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
.card-title { font-size: 16px; margin: 0 0 8px; }
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,66 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="reports" title="我的成长报告" sub="按类型筛选 · 愈心解码 / 星座 / 节律 / 匹配 / 意象" />
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">我的成长报告</span>
|
||||
<span class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: filter === f.key }"
|
||||
@click="filter = f.key"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</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>
|
||||
<div class="body">
|
||||
<router-link class="vip-strip" to="/membership">
|
||||
<span class="vip-ico" aria-hidden="true">★</span>
|
||||
<span class="vip-copy">
|
||||
<strong>成长会员</strong>
|
||||
<em>解锁全部深度报告与节气陪伴</em>
|
||||
</span>
|
||||
<span class="vip-go">开通 ›</span>
|
||||
</router-link>
|
||||
|
||||
<p v-if="loading" class="yxg-hint pad">加载中…</p>
|
||||
<p v-else-if="error" class="yxg-err pad">
|
||||
<p v-if="loading" class="hint">加载中…</p>
|
||||
<p v-else-if="error" class="err">
|
||||
{{ error }}
|
||||
<button type="button" class="yxg-link" @click="load">重试</button>
|
||||
<button type="button" class="retry" @click="load">重试</button>
|
||||
</p>
|
||||
<template v-else>
|
||||
<div v-if="!filtered.length" class="yxg-card empty">
|
||||
<div v-if="!filtered.length" class="empty">
|
||||
<HomeToolIcon name="reports" :size="56" />
|
||||
<p>还没有{{ filter === 'all' ? '' : '该类' }}成长报告。</p>
|
||||
<router-link class="yxg-btn" to="/explore">去探索</router-link>
|
||||
<router-link class="cta" 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>
|
||||
<div v-else class="list">
|
||||
<router-link v-for="r in filtered" :key="r.id" :to="`/reports/${r.id}`" class="report-card">
|
||||
<span class="thumb" aria-hidden="true">
|
||||
<HomeToolIcon :name="toolIconFor(r.type)" :size="44" />
|
||||
</span>
|
||||
<span class="card-body">
|
||||
<span class="card-title">
|
||||
{{ typeLabel(r.type) }}
|
||||
<span v-if="badgeFor(r)" class="badge" :class="'b-' + badgeFor(r)">{{ badgeLabel(badgeFor(r)!) }}</span>
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="card-desc">{{ headlineOf(r) }}</span>
|
||||
<span class="card-date">{{ formatDate(r.created_at) }}</span>
|
||||
</span>
|
||||
<span class="card-cta">查看报告</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -49,7 +68,9 @@ 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'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { toolIconFor } from '../lib/toolIconMap'
|
||||
|
||||
const items = ref<GrowthReport[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -73,14 +94,14 @@ const filtered = computed(() => {
|
||||
|
||||
function typeLabel(t: string) {
|
||||
const map: Record<string, string> = {
|
||||
portrait: '△ 愈心解码',
|
||||
relation: '♡ 人格匹配',
|
||||
synastry: '✧ 合盘',
|
||||
star: '✦ 星座',
|
||||
rhythm: '☯ 身心节律',
|
||||
image_card: '◈ 意象卡片',
|
||||
portrait: '愈心解码',
|
||||
relation: '人格匹配',
|
||||
synastry: '合盘',
|
||||
star: '星座',
|
||||
rhythm: '身心节律',
|
||||
image_card: '意象卡片',
|
||||
}
|
||||
return map[t] || '▣ 成长报告'
|
||||
return map[t] || '成长报告'
|
||||
}
|
||||
|
||||
function headlineOf(r: GrowthReport) {
|
||||
@@ -90,12 +111,24 @@ function headlineOf(r: GrowthReport) {
|
||||
|
||||
function formatDate(v: string) {
|
||||
try {
|
||||
return new Date(v).toLocaleString('zh-CN')
|
||||
return new Date(v).toLocaleDateString('zh-CN')
|
||||
} catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
function badgeFor(r: GrowthReport): 'new' | 'hot' | null {
|
||||
const created = new Date(r.created_at).getTime()
|
||||
const weekAgo = Date.now() - 7 * 86400000
|
||||
if (created > weekAgo) return 'new'
|
||||
if (r.type === 'portrait' || r.type === 'star') return 'hot'
|
||||
return null
|
||||
}
|
||||
|
||||
function badgeLabel(b: 'new' | 'hot') {
|
||||
return b === 'new' ? 'NEW' : 'HOT'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -113,6 +146,154 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pad{padding:0 16px}
|
||||
.empty .yxg-btn{margin-top:12px}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||
.spacer { width: 36px; }
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
flex-shrink: 0;
|
||||
padding: 7px 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.chip.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
border-color: #f5c4bc;
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.body { padding: 12px 16px 24px; }
|
||||
.vip-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(145deg, #fff4e0, #ffe8b8);
|
||||
box-shadow: 0 4px 14px rgba(200, 146, 58, 0.12);
|
||||
color: inherit;
|
||||
}
|
||||
.vip-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #f0c86a, #e8a830);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vip-copy { flex: 1; min-width: 0; }
|
||||
.vip-copy strong { display: block; font-size: 14px; color: #222; }
|
||||
.vip-copy em { display: block; font-style: normal; font-size: 11px; color: #8a7040; margin-top: 2px; }
|
||||
.vip-go { font-size: 13px; font-weight: 600; color: #b07a28; flex-shrink: 0; }
|
||||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.report-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
color: inherit;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
.report-card:active { transform: scale(0.985); }
|
||||
.thumb {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(155deg, #ffe9e2, #ffd4c8);
|
||||
}
|
||||
.card-body { flex: 1; min-width: 0; }
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.badge.b-hot { background: linear-gradient(135deg, #ff7a6e, var(--color-primary)); }
|
||||
.badge.b-new { background: linear-gradient(135deg, #6eb6ff, #4a90e2); }
|
||||
.card-desc {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-date { display: block; font-size: 11px; color: #c4c4c4; margin-top: 4px; }
|
||||
.card-cta {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: #fff5f4;
|
||||
border: 1px solid #ffe0dc;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
padding: 11px 20px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hint, .err { font-size: 13px; color: #999; }
|
||||
.err { color: var(--color-primary); }
|
||||
.retry {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-accent-blue);
|
||||
text-decoration: underline;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,52 +1,90 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<BackButton />
|
||||
<PageTitle feature="scale" :title="title || '探索测试'" :sub="description" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<div class="head">
|
||||
<HomeToolIcon name="mbti" :size="48" />
|
||||
<div>
|
||||
<h1 class="title">{{ title || '探索测试' }}</h1>
|
||||
<p v-if="description && phase !== 'intro'" class="sub">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body" :class="{ 'has-bar': phase === 'answering' }">
|
||||
<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">
|
||||
查看探索结果
|
||||
|
||||
<!-- Intro -->
|
||||
<div v-else-if="phase === 'intro'" class="intro-screen">
|
||||
<div class="intro-card">
|
||||
<HomeToolIcon name="mbti" :size="64" />
|
||||
<h2 class="intro-title">{{ title || '探索测试' }}</h2>
|
||||
<p v-if="description" class="intro-desc">{{ description }}</p>
|
||||
<p v-if="questions.length" class="intro-count">共 {{ questions.length }} 题 · 约 {{ estMinutes }} 分钟</p>
|
||||
<p v-if="draftRestored" class="draft-hint">检测到上次未完成的作答,开始后将继续</p>
|
||||
<button class="yxg-btn yxg-btn-block intro-start" type="button" :disabled="!questions.length" @click="startTest">
|
||||
开始
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- Answering -->
|
||||
<div v-else-if="phase === 'answering'" class="answer-screen">
|
||||
<p v-if="draftRestored && currentIdx === 0" class="yxg-hint draft">已恢复上次未提交的作答</p>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar">
|
||||
<i class="progress-fill" :style="{ width: progressPct + '%' }" />
|
||||
</div>
|
||||
<span class="progress-label">{{ currentIdx + 1 }} / {{ questions.length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="currentQuestion" class="q-card yxg-card">
|
||||
<p class="prompt">{{ currentQuestion.body.prompt }}</p>
|
||||
<label v-for="opt in currentQuestion.body.options" :key="opt.key" class="opt" :class="{ picked: answers[currentQuestion.id] === opt.key }">
|
||||
<input v-model="answers[currentQuestion.id]" type="radio" :value="opt.key" />
|
||||
<span class="opt-text">{{ opt.text }}</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-else-if="phase === 'result'">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div v-if="phase === 'answering'" class="nav-bar">
|
||||
<button type="button" class="nav-btn prev" :disabled="currentIdx === 0" @click="prevQ">上一题</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-btn next"
|
||||
:disabled="!answers[currentQuestion?.id || ''] || submitting"
|
||||
@click="onNext"
|
||||
>
|
||||
{{ isLast ? (submitting ? '提交中…' : '查看探索结果') : '下一题' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ShareSheet :open="shareOpen" :payload="sharePayload" @close="shareOpen = false" />
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -54,7 +92,8 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
|
||||
@@ -71,24 +110,32 @@ const submitting = ref(false)
|
||||
const loadError = ref('')
|
||||
const submitError = ref('')
|
||||
const needProfile = ref(false)
|
||||
const done = ref(false)
|
||||
const phase = ref<'intro' | 'answering' | 'result'>('intro')
|
||||
const resultLabel = ref('')
|
||||
const shareLine = ref('')
|
||||
const shareOpen = ref(false)
|
||||
const resultRaw = ref<Record<string, unknown> | null>(null)
|
||||
const draftRestored = ref(false)
|
||||
const persistReady = ref(false)
|
||||
const currentIdx = ref(0)
|
||||
|
||||
const answeredCount = computed(() => questions.value.filter((q) => answers[q.id]).length)
|
||||
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
|
||||
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
|
||||
const progressPct = computed(() => {
|
||||
if (!questions.value.length) return 0
|
||||
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
|
||||
})
|
||||
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
|
||||
|
||||
watch(
|
||||
answers,
|
||||
() => {
|
||||
if (!persistReady.value || done.value) return
|
||||
if (!persistReady.value || phase.value === 'result') return
|
||||
saveScaleDraft(slug, { ...answers })
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const resultSummaryObj = computed(() => {
|
||||
const r = resultRaw.value || {}
|
||||
return {
|
||||
@@ -112,7 +159,7 @@ const resultDetailObj = computed(() => {
|
||||
})
|
||||
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
if (!done.value) return null
|
||||
if (phase.value !== 'result') return null
|
||||
return {
|
||||
type: 'portrait',
|
||||
title: resultLabel.value,
|
||||
@@ -121,6 +168,28 @@ const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function firstUnansweredIdx(): number {
|
||||
const idx = questions.value.findIndex((q) => !answers[q.id])
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
function startTest() {
|
||||
phase.value = 'answering'
|
||||
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
|
||||
}
|
||||
|
||||
function prevQ() {
|
||||
if (currentIdx.value > 0) currentIdx.value--
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
if (isLast.value) {
|
||||
submit()
|
||||
} else {
|
||||
currentIdx.value++
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loadingScale.value = true
|
||||
loadError.value = ''
|
||||
@@ -174,7 +243,7 @@ async function submit() {
|
||||
const out = await api.submitScale(slug, self.id, { ...answers })
|
||||
clearScaleDraft(slug)
|
||||
draftRestored.value = false
|
||||
done.value = true
|
||||
phase.value = 'result'
|
||||
resultRaw.value = out.result as Record<string, unknown>
|
||||
resultLabel.value = String(out.result.label || '探索结果')
|
||||
shareLine.value = String(out.result.share_line || '')
|
||||
@@ -189,19 +258,212 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yxg-card{margin:8px 0 12px}
|
||||
.progress{font-size:12px;color:#999;margin-bottom:12px}
|
||||
.q{margin-bottom:18px}
|
||||
.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;
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.body {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
.body.has-bar {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.intro-screen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0 32px;
|
||||
}
|
||||
.intro-card {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-hero);
|
||||
}
|
||||
.intro-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 16px 0 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.intro-desc {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.intro-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.draft-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-accent-gold);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.intro-start {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
|
||||
.answer-screen {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #f0ebe7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #ff7a6e, var(--color-primary));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.q-card {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.prompt {
|
||||
font-weight: 650;
|
||||
font-size: 17px;
|
||||
color: #222;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
padding: 14px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid #eee;
|
||||
background: #fdfaf8;
|
||||
cursor: pointer;
|
||||
line-height: 1.45;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.opt.picked {
|
||||
border-color: var(--color-primary);
|
||||
background: #fff5f3;
|
||||
}
|
||||
.opt input {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.opt-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0));
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
padding: 13px;
|
||||
border-radius: 22px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.nav-btn.prev {
|
||||
background: #f3eeea;
|
||||
color: #555;
|
||||
}
|
||||
.nav-btn.prev:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.nav-btn.next {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
|
||||
}
|
||||
.nav-btn.next:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.result-lead {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin: 4px 0 12px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
<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>
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">分享</span>
|
||||
<span class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<template v-if="!payload">
|
||||
<div class="empty-hero">
|
||||
<HomeToolIcon name="ask" :size="64" />
|
||||
<h1 class="hero-title">分享内容无效</h1>
|
||||
<p class="hero-sub">链接可能已过期,你可以从首页重新开始。</p>
|
||||
<router-link class="cta" to="/">回首页</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="yxg-meta lead">朋友分享了{{ payload.type === 'relation' ? '一段关系理解' : '一份个人画像' }}</p>
|
||||
<div class="hero-block">
|
||||
<HomeToolIcon :name="payload.type === 'relation' ? 'relation' : 'portrait'" :size="64" />
|
||||
<h1 class="hero-title">{{ shareTitle }}</h1>
|
||||
<p class="hero-sub">{{ shareSub }}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<router-link class="cta" :to="ctaTo">{{ ctaText }}</router-link>
|
||||
<router-link class="ghost-link" to="/">先逛逛首页</router-link>
|
||||
</div>
|
||||
<p class="yxg-disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.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 shareTitle = computed(() => {
|
||||
const p = payload.value
|
||||
if (!p) return '分享'
|
||||
return p.type === 'relation' ? '关系理解分享' : '个人画像分享'
|
||||
})
|
||||
const shareSub = computed(() => {
|
||||
const p = payload.value
|
||||
if (!p) return ''
|
||||
return p.type === 'relation' ? '朋友分享了一段关系理解' : '朋友分享了一份个人画像'
|
||||
})
|
||||
|
||||
const cardProps = computed(() => {
|
||||
const p = payload.value
|
||||
if (!p) return {}
|
||||
@@ -46,11 +75,7 @@ const cardProps = computed(() => {
|
||||
return {
|
||||
type: 'relation' as const,
|
||||
title: '我的方式 vs TA 的方式',
|
||||
rows: [
|
||||
p.me ? `我:${p.me}` : '',
|
||||
p.other ? `TA:${p.other}` : '',
|
||||
p.diff || '',
|
||||
].filter(Boolean),
|
||||
rows: [p.me ? `我:${p.me}` : '', p.other ? `TA:${p.other}` : '', p.diff || ''].filter(Boolean),
|
||||
keywords: p.keywords,
|
||||
ctaLabel: '了解彼此 → 查看关系理解',
|
||||
}
|
||||
@@ -63,7 +88,53 @@ const ctaText = computed(() =>
|
||||
</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}
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||
.spacer { width: 36px; }
|
||||
.body { padding: 8px 16px 24px; }
|
||||
.hero-block, .empty-hero {
|
||||
text-align: center;
|
||||
padding: 20px 12px 16px;
|
||||
}
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-top: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: #222;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
padding: 13px;
|
||||
border-radius: 22px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.actions { margin-top: 8px; }
|
||||
.ghost-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.empty-hero .cta { max-width: 280px; margin: 18px auto 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,107 @@
|
||||
<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">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<button v-if="report" type="button" class="who" @click="resetForm">
|
||||
自己 <span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<span v-else class="who-static">星座</span>
|
||||
<button v-if="report" type="button" class="share-ico" aria-label="分享" @click="shareOpen = true">↗</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<template v-if="needBirth && !report">
|
||||
<div class="yxg-card">
|
||||
<div class="intro-card">
|
||||
<HomeToolIcon name="star" :size="64" />
|
||||
<h1 class="intro-title">星座性格</h1>
|
||||
<p class="intro-sub">填写生日,看见太阳 · 月亮 · 上升与行星画像</p>
|
||||
</div>
|
||||
<div class="yxg-card form-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>
|
||||
<label class="yxg-label">出生地(可选)</label>
|
||||
<BirthPlaceSelect v-model="birthPlace" />
|
||||
<button class="yxg-btn yxg-btn-block" type="button" :disabled="loading" @click="start">生成星座</button>
|
||||
<button class="cta" type="button" :disabled="loading" @click="start">
|
||||
{{ loading ? '生成中…' : '生成我的星座' }}
|
||||
</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">
|
||||
|
||||
<p v-else-if="loading" class="yxg-hint">正在生成星盘…</p>
|
||||
<p v-else-if="error && !report" 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">
|
||||
<template v-else-if="report">
|
||||
<div class="mode-tabs" role="tablist">
|
||||
<button
|
||||
v-for="p in panels"
|
||||
v-for="p in viewTabs"
|
||||
:key="p.key"
|
||||
type="button"
|
||||
class="panel-tab"
|
||||
:class="{ on: panel === p.key }"
|
||||
@click="panel = p.key"
|
||||
class="mode-tab"
|
||||
:class="{ on: view === p.key }"
|
||||
@click="view = p.key"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="panel === 'chart'">
|
||||
<template v-if="view === 'overview'">
|
||||
<h2 class="hl">{{ headline || '我的星座画像' }}</h2>
|
||||
<p v-if="oneLiner" class="lead">{{ oneLiner }}</p>
|
||||
|
||||
<div class="spotlight">
|
||||
<div class="sp-head">
|
||||
<strong>星象速览</strong>
|
||||
<router-link class="sp-ai" to="/ask">AI 解读 ›</router-link>
|
||||
</div>
|
||||
<p class="sp-text">{{ chartNote || '行星落在不同星座,构成独特的你。' }}</p>
|
||||
<div v-if="keywords.length" class="tags">
|
||||
<span v-for="k in keywords.slice(0, 4)" :key="k" class="tag">{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="planet-grid">
|
||||
<button
|
||||
v-for="(cell, i) in gridCells"
|
||||
:key="cell.key + i"
|
||||
type="button"
|
||||
class="cell"
|
||||
:class="{ center: cell.center, on: signTab === cell.key }"
|
||||
@click="onGridTap(cell)"
|
||||
>
|
||||
<template v-if="cell.center">
|
||||
<HomeToolIcon name="star" :size="40" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong class="nick">{{ cell.nick }}</strong>
|
||||
<span class="role">{{ cell.role }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeCard" class="detail-card">
|
||||
<h3>{{ activeCard.title }} · {{ activeCard.label }}</h3>
|
||||
<p>{{ activeCard.teaser }}</p>
|
||||
<p v-if="activeCard.element" class="yxg-meta">{{ activeCard.element }}象 · {{ activeCard.modality }}</p>
|
||||
</div>
|
||||
|
||||
<router-link class="ask-bar" to="/ask">
|
||||
<span>AI,本周我可以关注什么?</span>
|
||||
<span aria-hidden="true">›</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template v-else-if="view === 'chart'">
|
||||
<div class="wheel-settings">
|
||||
<label><input v-model="showOuter" type="checkbox" /> 显示外行星</label>
|
||||
<label><input v-model="showOuter" type="checkbox" /> 外行星</label>
|
||||
<label><input v-model="tightOrb" type="checkbox" /> 紧容许度</label>
|
||||
</div>
|
||||
<NatalWheel
|
||||
@@ -56,35 +113,19 @@
|
||||
: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>
|
||||
<p v-for="(a, i) in aspectPreview" :key="i" class="aspect-line">
|
||||
{{ a.label || `${a.a_title}${a.type}${a.b_title}` }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="panel === 'fortune'">
|
||||
<template v-else-if="view === 'fortune'">
|
||||
<div class="fortune-tabs">
|
||||
<button
|
||||
v-for="k in fortuneKeys"
|
||||
@@ -97,7 +138,7 @@
|
||||
{{ fortuneLabel(k) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeFortune" class="yxg-card daily">
|
||||
<div v-if="activeFortune" class="fortune-card">
|
||||
<p class="daily-title">
|
||||
{{ activeFortune.title }} · {{ activeFortune.label }} · {{ activeFortune.score }}分
|
||||
</p>
|
||||
@@ -116,31 +157,30 @@
|
||||
|
||||
<template v-else>
|
||||
<ul class="planet-list">
|
||||
<li v-for="p in planets" :key="p.key" class="yxg-card soft planet">
|
||||
<li v-for="p in planets" :key="p.key" class="planet-row" @click="signTab = p.key; view = 'overview'">
|
||||
<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">
|
||||
<div v-if="!report.has_deep_access" class="lock-card">
|
||||
<p>完整相位与年运详解可在深度版或成长会员中查看。</p>
|
||||
<button class="yxg-btn" type="button" :disabled="paying" @click="buyDeep">解锁完整分析(模拟支付)</button>
|
||||
<button class="cta soft" 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 class="bottom-links">
|
||||
<router-link to="/synastry">合盘 ›</router-link>
|
||||
<router-link to="/relation">人格匹配 ›</router-link>
|
||||
<router-link v-if="report.id" :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>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -152,11 +192,12 @@ import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import BirthPlaceSelect from '../components/BirthPlaceSelect.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import NatalWheel, { type WheelAspect, type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import ReportRich from '../components/ReportRich.vue'
|
||||
import ShareSheet from '../components/ShareSheet.vue'
|
||||
import SignCards, { type SignCard } from '../components/SignCards.vue'
|
||||
import type { SignCard } from '../components/SignCards.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
import type { PortraitSharePayload } from '../lib/shareLink'
|
||||
|
||||
@@ -174,12 +215,20 @@ 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 view = ref<'overview' | 'chart' | 'fortune' | 'planets'>('overview')
|
||||
const signTab = ref('sun')
|
||||
const fortuneKey = ref<'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'>('daily')
|
||||
const showOuter = ref(true)
|
||||
const tightOrb = ref(false)
|
||||
|
||||
const viewTabs = [
|
||||
{ key: 'overview' as const, label: '概览' },
|
||||
{ key: 'chart' as const, label: '星盘' },
|
||||
{ key: 'fortune' as const, label: '运势' },
|
||||
{ key: 'planets' as const, label: '行星' },
|
||||
]
|
||||
const fortuneKeys = ['daily', 'weekly', 'monthly', 'yearly', 'lifetime'] as const
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||
if (raw) {
|
||||
@@ -194,13 +243,6 @@ 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
|
||||
@@ -213,7 +255,6 @@ type FortunePeriod = {
|
||||
}
|
||||
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>)
|
||||
@@ -221,7 +262,9 @@ const detail = computed(() => (report.value?.detail || null) as Record<string, u
|
||||
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 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[]) : []))
|
||||
@@ -263,13 +306,39 @@ 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 gridCells = computed(() => {
|
||||
const cards = signCards.value
|
||||
const slots: { key: string; nick: string; role: string; center?: boolean }[] = []
|
||||
const order = ['sun', 'moon', 'rise', 'venus', 'mars', 'mercury', 'north_node', 'juno', 'jupiter']
|
||||
const byKey = new Map(cards.map((c) => [c.key, c]))
|
||||
const planetByKey = new Map(planets.value.map((p) => [p.key, p]))
|
||||
for (let i = 0; i < 9; i++) {
|
||||
if (i === 4) {
|
||||
slots.push({ key: '_center', nick: '', role: '', center: true })
|
||||
continue
|
||||
}
|
||||
const key = order[i < 4 ? i : i - 1] || cards[i]?.key || `p${i}`
|
||||
const c = byKey.get(key)
|
||||
const p = planetByKey.get(key)
|
||||
if (c) {
|
||||
slots.push({
|
||||
key: c.key,
|
||||
nick: (c.teaser || c.label || c.title).slice(0, 6),
|
||||
role: `${c.title}${c.label}`,
|
||||
})
|
||||
} else if (p) {
|
||||
slots.push({ key: p.key, nick: p.sign, role: `${p.title}${p.sign}` })
|
||||
} else if (cards[i < 4 ? i : i - 1]) {
|
||||
const x = cards[i < 4 ? i : i - 1]
|
||||
slots.push({ key: x.key, nick: (x.teaser || x.label).slice(0, 6), role: `${x.title}${x.label}` })
|
||||
} else {
|
||||
slots.push({ key: `empty${i}`, nick: '—', role: '待生成' })
|
||||
}
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
const sharePayload = computed<PortraitSharePayload | null>(() => {
|
||||
if (!report.value) return null
|
||||
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
|
||||
@@ -284,6 +353,20 @@ function onWheelSelect(key: string) {
|
||||
track(AnalyticsEvent.StarWheelViewed, { planet: key })
|
||||
}
|
||||
|
||||
function onGridTap(cell: { key: string; center?: boolean }) {
|
||||
if (cell.center) {
|
||||
view.value = 'chart'
|
||||
return
|
||||
}
|
||||
if (cell.key.startsWith('empty')) return
|
||||
signTab.value = cell.key
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
needBirth.value = true
|
||||
report.value = null
|
||||
}
|
||||
|
||||
async function generate(y: number, m: number, d: number) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -299,6 +382,7 @@ async function generate(y: number, m: number, d: number) {
|
||||
birth_place: birthPlace.value || undefined,
|
||||
})
|
||||
report.value = await api.createStar(profile.id)
|
||||
view.value = 'overview'
|
||||
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
@@ -327,8 +411,10 @@ async function load() {
|
||||
needBirth.value = false
|
||||
try {
|
||||
report.value = await api.getReport(reportId)
|
||||
view.value = 'overview'
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
needBirth.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -338,6 +424,9 @@ async function load() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -363,42 +452,235 @@ 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;
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-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;
|
||||
.who, .who-static {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
.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;
|
||||
.who-static { pointer-events: none; }
|
||||
.share-ico {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
font-size: 16px;
|
||||
}
|
||||
.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;
|
||||
.body { padding: 8px 16px 24px; }
|
||||
.intro-card { text-align: center; padding: 20px 12px 8px; }
|
||||
.intro-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.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}
|
||||
.intro-sub { font-size: 13px; color: #888; margin-top: 6px; line-height: 1.5; }
|
||||
.form-card { margin-top: 12px; }
|
||||
.cta {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta.soft {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid #f5c4bc;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cta:disabled { opacity: 0.45; }
|
||||
.birth-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.bd-time {
|
||||
width: 108px;
|
||||
padding: 10px 6px;
|
||||
border: 1.5px solid #eee;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
background: #fdfaf8;
|
||||
}
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 14px;
|
||||
background: #fafafa;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
}
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 9px 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-tab.on {
|
||||
background: #fff;
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.hl {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: #222;
|
||||
}
|
||||
.lead { margin-top: 8px; font-size: 13px; color: #777; line-height: 1.55; }
|
||||
.spotlight {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(145deg, #fff0ec, #ffe4dc);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.sp-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sp-head strong { font-size: 14px; color: #222; }
|
||||
.sp-ai { font-size: 12px; color: var(--color-primary); font-weight: 600; }
|
||||
.sp-text { font-size: 12px; color: #666; line-height: 1.5; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: #8a4a3a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.planet-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.cell {
|
||||
min-height: 88px;
|
||||
border: 1px solid #f0ebe8;
|
||||
background: #fafafa;
|
||||
border-radius: 16px;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.cell.on {
|
||||
border-color: #f5c4bc;
|
||||
background: #fff7f4;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.cell.center {
|
||||
background: linear-gradient(160deg, #eef4ff, #ffe8e0);
|
||||
border-color: transparent;
|
||||
}
|
||||
.nick { font-size: 14px; font-weight: 700; color: #222; line-height: 1.2; }
|
||||
.role { font-size: 11px; color: var(--color-primary); font-weight: 600; }
|
||||
.detail-card {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.detail-card h3 { font-size: 15px; margin-bottom: 6px; color: #222; }
|
||||
.detail-card p { font-size: 13px; color: #555; line-height: 1.6; }
|
||||
.ask-bar {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: #fff;
|
||||
border: 1.5px solid #f0ebe8;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.fortune-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ftab {
|
||||
flex: 1;
|
||||
min-width: 52px;
|
||||
border: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
padding: 10px 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.ftab.on {
|
||||
border-color: var(--color-primary);
|
||||
background: #fff5f4;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fortune-card { padding: 16px; border-radius: var(--radius-xl); background: #fafafa; }
|
||||
.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; margin: 0; padding: 0; }
|
||||
.planet-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.planet-row strong { font-size: 14px; color: #222; }
|
||||
.planet-row span { font-size: 12px; color: #888; }
|
||||
.lock-card {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: linear-gradient(145deg, #fff8e8, #ffeec8);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.bottom-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 16px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.wheel-settings {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 4px 0 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
.house-row, .aspect-line { font-size: 13px; color: #555; margin: 4px 0; }
|
||||
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
|
||||
.yxg-label:first-child { margin-top: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<BackButton />
|
||||
<PageTitle feature="star" title="合盘邀请" sub="填写生日,与好友生成合盘" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<div class="top-row">
|
||||
<BackButton />
|
||||
<span class="who-static">合盘邀请</span>
|
||||
<span class="spacer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<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">
|
||||
<div class="hero-block">
|
||||
<HomeToolIcon name="synastry" :size="64" />
|
||||
<h1 class="hero-title">{{ meta.host_name || '好友' }} 邀请你合盘</h1>
|
||||
<p class="hero-sub">填写生日,一起看见彼此的星盘互动与相处参考</p>
|
||||
</div>
|
||||
|
||||
<div v-if="meta.already_accepted" class="notice-card">
|
||||
<p>该邀请已使用,可直接去合盘页再测。</p>
|
||||
<router-link class="cta" to="/synastry">回合盘页</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="yxg-card form-card">
|
||||
<label class="yxg-label">你的称呼</label>
|
||||
<input v-model="name" class="name-in" placeholder="我" />
|
||||
<input v-model="name" class="yxg-input" 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">
|
||||
<button class="cta" 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>
|
||||
<router-link v-if="!meta.already_accepted" class="ghost-link" to="/synastry">先看看合盘介绍 ›</router-link>
|
||||
</template>
|
||||
<p v-else class="yxg-err">{{ error || '邀请无效或已过期' }}</p>
|
||||
|
||||
<div v-else class="empty-hero">
|
||||
<HomeToolIcon name="synastry" :size="56" />
|
||||
<p class="hero-sub">{{ error || '邀请无效或已过期' }}</p>
|
||||
<router-link class="cta" to="/synastry">去合盘页</router-link>
|
||||
</div>
|
||||
|
||||
<p class="yxg-disc">合盘为自我探索参考,不是占卜或算命。</p>
|
||||
</div>
|
||||
</main>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -34,7 +55,8 @@ 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 HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import { AnalyticsEvent, track } from '../lib/analytics'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -46,10 +68,7 @@ const name = ref('我')
|
||||
const ty = ref('')
|
||||
const tm = ref('')
|
||||
const td = ref('')
|
||||
const meta = ref<{
|
||||
host_name: string
|
||||
already_accepted: boolean
|
||||
} | null>(null)
|
||||
const meta = ref<{ host_name: string; already_accepted: boolean } | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.params.token || '')
|
||||
@@ -90,9 +109,58 @@ async function accept() {
|
||||
</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;
|
||||
.top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.mt{margin-top:14px}
|
||||
.who-static { flex: 1; font-size: 16px; font-weight: 700; text-align: center; }
|
||||
.spacer { width: 36px; }
|
||||
.body { padding: 8px 16px 24px; }
|
||||
.hero-block, .empty-hero { text-align: center; padding: 16px 8px; }
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-top: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: #222;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.hero-sub { font-size: 13px; color: #888; margin-top: 8px; line-height: 1.5; }
|
||||
.form-card { margin-top: 8px; }
|
||||
.notice-card {
|
||||
margin-top: 12px;
|
||||
padding: 20px 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: #fafafa;
|
||||
text-align: center;
|
||||
}
|
||||
.notice-card p { font-size: 14px; color: #666; margin-bottom: 14px; }
|
||||
.cta {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
border: none;
|
||||
border-radius: 22px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
.cta:disabled { opacity: 0.45; }
|
||||
.yxg-label { display: block; margin: 12px 0 6px; font-size: 12px; color: #999; }
|
||||
.yxg-label:first-child { margin-top: 0; }
|
||||
.ghost-link {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty-hero .cta { max-width: 280px; margin: 16px auto 0; }
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ const { api } = vi.hoisted(() => ({
|
||||
vi.mock('../api/client', () => ({ api }))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe('SynastryPage', () => {
|
||||
|
||||
@@ -1,13 +1,105 @@
|
||||
<template>
|
||||
<main class="yxg-page">
|
||||
<div class="yxg-hero">
|
||||
<PageShell>
|
||||
<template #hero>
|
||||
<BackButton />
|
||||
<PageTitle feature="star" title="合盘" sub="比较 · 组合 · 时空 · 马克斯 · 配对" />
|
||||
</div>
|
||||
<div class="yxg-sheet sheet-pad">
|
||||
<div class="head">
|
||||
<HomeToolIcon name="synastry" :size="48" />
|
||||
<div>
|
||||
<h1 class="title">{{ report ? '合盘结果' : '缘分合盘' }}</h1>
|
||||
<p class="sub">{{ report ? headline : '比较 · 组合 · 时空 · 马克斯 · 配对' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="body">
|
||||
<!-- ── Landing ── -->
|
||||
<template v-if="!report">
|
||||
<div class="yxg-card">
|
||||
<p class="yxg-lead">选择双方档案,一次生成五主盘与次限推运。</p>
|
||||
<h2 class="hero-title">看看你和 TA 的默契指数</h2>
|
||||
|
||||
<div class="rel-chips" role="group" aria-label="关系类型">
|
||||
<button
|
||||
v-for="r in relationTypes"
|
||||
:key="r"
|
||||
type="button"
|
||||
class="rel-chip"
|
||||
:class="{ on: relationType === r }"
|
||||
@click="relationType = r"
|
||||
>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dual-pick">
|
||||
<div class="pick-side self-side">
|
||||
<div class="av-ring on">
|
||||
<span class="av-inner">{{ selfInitial }}</span>
|
||||
</div>
|
||||
<span class="pick-label">自己</span>
|
||||
<span v-if="profileAName" class="pick-name">{{ profileAName }}</span>
|
||||
</div>
|
||||
<span class="pick-link" aria-hidden="true">×</span>
|
||||
<div class="pick-side ta-side">
|
||||
<div class="scroll-profiles" role="list">
|
||||
<button
|
||||
v-for="p in pickableProfiles"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="profile-chip"
|
||||
:class="{ on: profileB === p.id }"
|
||||
role="listitem"
|
||||
@click="profileB = p.id"
|
||||
>
|
||||
<span class="av-ring sm" :class="{ on: profileB === p.id }">
|
||||
<span class="av-inner sm">{{ profileInitial(p) }}</span>
|
||||
</span>
|
||||
<span class="chip-name">{{ p.display_name || '未命名' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="n in nearby"
|
||||
:key="'n' + n.profile.id"
|
||||
type="button"
|
||||
class="profile-chip"
|
||||
:class="{ on: profileB === n.profile.id }"
|
||||
@click="profileB = n.profile.id"
|
||||
>
|
||||
<span class="av-ring sm nearby" :class="{ on: profileB === n.profile.id }">
|
||||
<span class="av-inner sm">附</span>
|
||||
</span>
|
||||
<span class="chip-name">{{ n.profile.display_name || '匿名' }}</span>
|
||||
</button>
|
||||
<button type="button" class="profile-chip add-chip" @click="showQuickAdd = !showQuickAdd">
|
||||
<span class="av-ring sm add">
|
||||
<span class="av-inner sm">+</span>
|
||||
</span>
|
||||
<span class="chip-name">添加档案</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showQuickAdd" class="yxg-card soft add-form">
|
||||
<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 cta-main"
|
||||
type="button"
|
||||
:disabled="loading || !profileA || !profileB"
|
||||
@click="generate"
|
||||
>
|
||||
{{ loading ? '合盘中…' : '立即合盘' }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="toggle-classic" @click="showClassicSelects = !showClassicSelects">
|
||||
{{ showClassicSelects ? '收起档案选择' : '直接选择档案' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showClassicSelects" class="yxg-card soft classic-panel">
|
||||
<label class="yxg-label">我(档案 A)</label>
|
||||
<select v-model="profileA" class="sel">
|
||||
<option disabled value="">请选择</option>
|
||||
@@ -27,113 +119,128 @@
|
||||
</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>
|
||||
|
||||
<div class="social-row" :class="{ pulse: inviteHighlight }">
|
||||
<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 invite-path">邀请链接:{{ invitePath }}(可复制分享)</p>
|
||||
<p v-if="nearbyHint" class="yxg-meta">{{ nearbyHint }}</p>
|
||||
<p v-if="profiles.length < 2 && nearby.length === 0" class="yxg-meta">
|
||||
需要至少两个档案。可先去
|
||||
<router-link class="yxg-link" to="/profile">档案页</router-link>
|
||||
添加 TA,或点「添加档案」快速创建。
|
||||
</p>
|
||||
|
||||
<div class="feature-pair">
|
||||
<div class="feature-card fc-a">
|
||||
<HomeToolIcon name="synastry" :size="40" />
|
||||
<div class="fc-body">
|
||||
<strong>多类别关系分析</strong>
|
||||
<p>恋爱、友情、婚姻指数一次看清,五主盘与次限推运同屏对比。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-card fc-b">
|
||||
<HomeToolIcon name="relation" :size="40" />
|
||||
<div class="fc-body">
|
||||
<strong>TA 的雷点与建议</strong>
|
||||
<p>相处深析与相位解读,帮你理解彼此差异与磨合方向。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="yxg-err">{{ error }}</p>
|
||||
</template>
|
||||
|
||||
<!-- ── Loading ── -->
|
||||
<p v-else-if="loading" class="yxg-hint">合盘计算中…</p>
|
||||
|
||||
<!-- ── Results ── -->
|
||||
<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 class="result-hero yxg-card">
|
||||
<p class="result-headline">{{ 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 note">{{ loveNote }} · 推运 {{ asOfLabel }}</p>
|
||||
</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>
|
||||
<div class="yxg-card chart-card">
|
||||
<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 inset">
|
||||
<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 inset">
|
||||
<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 inset">
|
||||
<h2 class="card-title">相位速览</h2>
|
||||
<p v-for="(a, i) in activeAspectPreview" :key="i" class="aline">{{ a.label }}</p>
|
||||
</div>
|
||||
</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">
|
||||
@@ -157,27 +264,33 @@
|
||||
</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>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||
import { validateBirth } from '@yuxingu/utils'
|
||||
import { api } from '../api/client'
|
||||
import BackButton from '../components/BackButton.vue'
|
||||
import BirthDateInputs from '../components/BirthDateInputs.vue'
|
||||
import HomeToolIcon from '../components/HomeToolIcon.vue'
|
||||
import NatalWheel, { type WheelPlanet } from '../components/NatalWheel.vue'
|
||||
import PageTitle from '../components/PageTitle.vue'
|
||||
import PageShell from '../components/PageShell.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 route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
const paying = ref(false)
|
||||
@@ -186,6 +299,7 @@ const nearbyLoading = ref(false)
|
||||
const error = ref('')
|
||||
const nearbyHint = ref('')
|
||||
const invitePath = ref('')
|
||||
const inviteHighlight = ref(false)
|
||||
const profiles = ref<Profile[]>([])
|
||||
const nearby = ref<{ profile: Profile; distance_km: number }[]>([])
|
||||
const profileA = ref('')
|
||||
@@ -200,6 +314,11 @@ const tName = ref('TA')
|
||||
const mainTab = ref<MainTab>('compare')
|
||||
const subTab = ref<'natal' | 'prog'>('natal')
|
||||
const marksWho = ref<'me' | 'other'>('me')
|
||||
const relationType = ref('伴侣')
|
||||
const showClassicSelects = ref(false)
|
||||
const showQuickAdd = ref(false)
|
||||
|
||||
const relationTypes = ['伴侣', '朋友', '家人', '其他']
|
||||
|
||||
const mainTabs: { key: MainTab; label: string }[] = [
|
||||
{ key: 'compare', label: '比较' },
|
||||
@@ -211,6 +330,22 @@ const mainTabs: { key: MainTab; label: string }[] = [
|
||||
|
||||
const needsSubTab = computed(() => ['composite', 'davison', 'marks'].includes(mainTab.value))
|
||||
|
||||
const pickableProfiles = computed(() => profiles.value.filter((p) => p.id !== profileA.value))
|
||||
|
||||
const profileAName = computed(() => {
|
||||
const p = profiles.value.find((x) => x.id === profileA.value)
|
||||
return p?.display_name || ''
|
||||
})
|
||||
|
||||
const selfInitial = computed(() => {
|
||||
const name = profileAName.value || '我'
|
||||
return name.slice(0, 1)
|
||||
})
|
||||
|
||||
function profileInitial(p: Profile) {
|
||||
return (p.display_name || 'TA').slice(0, 1)
|
||||
}
|
||||
|
||||
const 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>)
|
||||
@@ -365,6 +500,7 @@ async function addTemp() {
|
||||
})
|
||||
await loadProfiles()
|
||||
profileB.value = p.id
|
||||
showQuickAdd.value = false
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '添加失败'
|
||||
} finally {
|
||||
@@ -416,7 +552,6 @@ async function loadNearby() {
|
||||
})
|
||||
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 })
|
||||
@@ -454,45 +589,427 @@ function reset() {
|
||||
report.value = null
|
||||
}
|
||||
|
||||
onMounted(loadProfiles)
|
||||
onMounted(async () => {
|
||||
await loadProfiles()
|
||||
if (route.query.invite === '1') {
|
||||
inviteHighlight.value = true
|
||||
if (profileA.value) {
|
||||
await createInvite()
|
||||
} else {
|
||||
error.value = '请先完成自己的档案,再邀请好友合盘'
|
||||
}
|
||||
void router.replace({ path: '/synastry', query: {} })
|
||||
}
|
||||
})
|
||||
</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;
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.mini{margin-top:12px}
|
||||
.name-in{
|
||||
width:100%;padding:10px;border:1.5px solid #eee;border-radius:10px;margin:8px 0;font-size:14px;
|
||||
.title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.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);
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.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;
|
||||
.body {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
.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;
|
||||
|
||||
.hero-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
margin: 4px 0 16px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rel-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.rel-chip {
|
||||
border: 1.5px solid #eee;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
}
|
||||
.rel-chip.on {
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.22);
|
||||
}
|
||||
|
||||
.dual-pick {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pick-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.self-side {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
}
|
||||
.ta-side {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pick-link {
|
||||
flex-shrink: 0;
|
||||
margin-top: 18px;
|
||||
font-size: 18px;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.pick-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.pick-name {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
max-width: 68px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.av-ring {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.1);
|
||||
}
|
||||
.av-ring.sm {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.av-ring.on {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 14px rgba(229, 77, 66, 0.25);
|
||||
}
|
||||
.av-ring.add {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(229, 77, 66, 0.35);
|
||||
}
|
||||
.av-ring.nearby {
|
||||
background: linear-gradient(145deg, #e8f4ff, #b4d2f5);
|
||||
}
|
||||
.av-inner {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #8a4a3a;
|
||||
}
|
||||
.av-inner.sm {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scroll-profiles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
.scroll-profiles::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.profile-chip {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
min-width: 52px;
|
||||
}
|
||||
.chip-name {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
max-width: 52px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.profile-chip.on .chip-name {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.name-in {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1.5px solid #eee;
|
||||
border-radius: 10px;
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cta-main {
|
||||
margin-top: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
|
||||
box-shadow: 0 6px 18px rgba(229, 77, 66, 0.28);
|
||||
}
|
||||
|
||||
.toggle-classic {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.classic-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.yxg-label {
|
||||
display: block;
|
||||
margin: 12px 0 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.sel {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1.5px solid #eee;
|
||||
border-radius: 12px;
|
||||
background: #fdfaf8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.social-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.social-row.pulse {
|
||||
animation: invitePulse 1.2s ease 2;
|
||||
border-radius: 14px;
|
||||
padding: 6px;
|
||||
background: rgba(229, 77, 66, 0.06);
|
||||
}
|
||||
@keyframes invitePulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(229, 77, 66, 0); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(229, 77, 66, 0.12); }
|
||||
}
|
||||
.social-row .yxg-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
.invite-path { word-break: break-all; }
|
||||
|
||||
.feature-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
.feature-card {
|
||||
border-radius: 16px;
|
||||
padding: 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 140px;
|
||||
box-shadow: var(--shadow-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.fc-a {
|
||||
background: linear-gradient(155deg, #ffd8d0, #ffb4a8);
|
||||
}
|
||||
.fc-b {
|
||||
background: linear-gradient(155deg, #d8eaff, #b4d2f5);
|
||||
}
|
||||
.fc-body strong {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fc-body p {
|
||||
font-size: 11px;
|
||||
color: rgba(0, 0, 0, 0.52);
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.result-headline {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.yxg-card.inset {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.indices {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.idx {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
background: #fff8f6;
|
||||
border-radius: 14px;
|
||||
padding: 12px 8px;
|
||||
box-shadow: inset 0 0 0 1px rgba(229, 77, 66, 0.08);
|
||||
}
|
||||
.idx em {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.idx strong {
|
||||
font-size: 26px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 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(--color-primary);
|
||||
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;
|
||||
}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user