feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+20 -6
View File
@@ -22,8 +22,12 @@
<AskMessageList :messages="messages" :sending="sending" />
<p v-if="sendError" class="err pad">
{{ sendError }}
<router-link v-if="quotaExhausted" class="retry" to="/membership">开通成长会员</router-link>
</p>
<AskQuotaBuy
v-if="showBuy"
:lead="quotaExhausted ? '问答次数已用完,购买后可继续聊' : '想聊更多?可先加购额度'"
@purchased="$emit('quota-refreshed')"
/>
<AskComposer
:draft="draft"
:sending="sending"
@@ -36,14 +40,16 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import AskComposer from './AskComposer.vue'
import AskEmpty from './AskEmpty.vue'
import AskGreet from './AskGreet.vue'
import AskMessageList from './AskMessageList.vue'
import AskProfileBar from './AskProfileBar.vue'
import AskQuotaBuy from './AskQuotaBuy.vue'
defineProps<{
const props = defineProps<{
bootLoading: boolean
bootError: string
profiles: Profile[]
@@ -65,25 +71,33 @@ defineEmits<{
navigate: [to: string]
'update:draft': [v: string]
send: []
'quota-refreshed': []
}>()
const showBuy = computed(
() => props.quotaExhausted || (props.quota !== null && props.quota.remaining <= 0),
)
</script>
<style scoped>
.ai-pane {
padding: 12px 16px 100px;
padding: 8px 16px 108px;
position: relative;
z-index: 2;
}
.hint {
font-size: 13px;
color: #999;
color: #9a8f8b;
text-align: center;
padding: 24px;
padding: 28px 16px;
}
.err {
font-size: 13px;
color: var(--color-primary);
padding: 8px 0;
padding: 10px 12px;
margin: 4px 0 8px;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
}
.pad { padding: 0 4px; }
.retry {
+76 -36
View File
@@ -1,27 +1,35 @@
<template>
<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="$emit('send')">
<textarea
class="ta"
:value="draft"
rows="2"
placeholder="让我来解答你的问题吧"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
/>
<button
class="send"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
>
发送
</button>
</form>
<p class="ai-note">部分内容由 AI 生成仅供参考</p>
<div class="bar">
<p v-if="quota" class="quota">
<span class="q-dot" aria-hidden="true" />
还可聊 {{ quota.remaining }}
<span v-if="(quota.paid_left ?? 0) > 0" class="q-free">已购 {{ quota.paid_left }}</span>
<span v-else-if="!quota.active_membership" class="q-free">免费额度 {{ quota.free_limit }}</span>
</p>
<form class="composer" @submit.prevent="$emit('send')">
<textarea
class="ta"
:value="draft"
rows="2"
placeholder="说说你此刻在意的事…"
:disabled="sending || (quota !== null && quota.remaining <= 0)"
@input="$emit('update:draft', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
/>
<button
class="send"
type="submit"
:disabled="sending || !draft.trim() || (quota !== null && quota.remaining <= 0)"
aria-label="发送"
>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M3 9h10M9 4l5 5-5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</form>
<p class="ai-note">部分内容由 AI 生成 · 仅作自我探索参考</p>
</div>
</div>
</template>
@@ -45,12 +53,34 @@ defineEmits<{
position: sticky;
bottom: 72px;
padding-top: 8px;
background: linear-gradient(180deg, transparent, var(--color-bg-sheet) 24%);
z-index: 5;
}
.bar {
padding: 10px 12px 8px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(255, 255, 255, 0.98);
box-shadow: 0 -2px 20px rgba(180, 90, 70, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.quota {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #aaa;
margin-bottom: 6px;
color: #a39893;
margin-bottom: 8px;
}
.q-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
opacity: 0.7;
}
.q-free {
color: #c4bbb6;
}
.composer {
display: flex;
@@ -60,36 +90,46 @@ defineEmits<{
.ta {
flex: 1;
resize: none;
min-height: 44px;
padding: 11px 14px;
min-height: 46px;
max-height: 120px;
padding: 12px 14px;
border-radius: 16px;
border: 1.5px solid #eee;
background: #fff;
border: 1.5px solid #f0e6e2;
background: var(--color-input-bg);
font-size: 14px;
font-family: inherit;
line-height: 1.45;
color: var(--color-text-primary);
outline: none;
}
.ta:focus {
border-color: #f0b8b0;
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.08);
}
.ta::placeholder {
color: #c4bbb6;
}
.send {
flex-shrink: 0;
padding: 11px 16px;
width: 46px;
height: 46px;
border: none;
border-radius: 22px;
border-radius: 50%;
color: #fff;
font-size: 14px;
font-weight: 600;
display: grid;
place-items: center;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
box-shadow: 0 8px 18px rgba(229, 77, 66, 0.3);
}
.send:disabled {
opacity: 0.45;
opacity: 0.4;
box-shadow: none;
}
.ai-note {
margin-top: 8px;
font-size: 10px;
color: #ccc;
color: #d0c6c1;
text-align: center;
letter-spacing: 0.02em;
}
</style>
+52 -16
View File
@@ -1,8 +1,12 @@
<template>
<div class="card empty">
<HomeToolIcon name="ask" :size="56" />
<p>还没有个人档案完成愈心解码后再来提问回答会更贴合你</p>
<div class="card empty reveal">
<div class="av" aria-hidden="true">
<HomeToolIcon name="ask" :size="48" />
</div>
<p class="title">先建立你的个人档案</p>
<p class="desc">完成愈心解码后问答会结合你的性格与节律来回答更贴合你</p>
<router-link class="cta" to="/portrait">去愈心解码</router-link>
<router-link class="link" to="/profile">或手动创建档案</router-link>
</div>
</template>
@@ -12,30 +16,62 @@ import HomeToolIcon from '../HomeToolIcon.vue'
<style scoped>
.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;
gap: 10px;
background: rgba(255, 255, 255, 0.92);
border-radius: var(--radius-xl);
padding: 28px 20px;
box-shadow: var(--shadow-hero);
border: 1px solid rgba(255, 255, 255, 0.95);
}
.av {
width: 72px;
height: 72px;
border-radius: 22px;
display: grid;
place-items: center;
background: linear-gradient(145deg, #ffe8e0, #ffd4c8);
margin-bottom: 4px;
}
.title {
font-family: var(--font-display);
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
}
.desc {
font-size: 13px;
line-height: 1.55;
color: #9a8f8b;
max-width: 16em;
}
.cta {
margin-top: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border-radius: 22px;
padding: 12px 22px;
border-radius: var(--radius-pill);
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
font-weight: 600;
font-weight: 700;
font-size: 14px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.28);
box-shadow: 0 8px 18px rgba(229, 77, 66, 0.28);
}
.link {
font-size: 12px;
color: #b0a49f;
text-decoration: underline;
text-underline-offset: 3px;
}
.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); }
}
</style>
+63 -16
View File
@@ -1,21 +1,27 @@
<template>
<div class="card greet reveal">
<div class="greet-row">
<HomeToolIcon name="ask" :size="48" />
<div class="av" aria-hidden="true">
<HomeToolIcon name="ask" :size="40" />
</div>
<div>
<p class="hi">Hi我是愈心 AI</p>
<p class="hi-sub">最近有什么事都可以和我聊聊</p>
<p class="hi-sub">懂你一点的成长伙伴 · 认识自己 · 理解关系 · 整理情绪</p>
</div>
</div>
<p class="sec-label">今天想从哪里开始</p>
<div class="prompts">
<button
v-for="s in guidePrompts"
v-for="(s, i) in guidePrompts"
:key="s.key"
type="button"
class="prompt"
:style="{ '--d': `${80 + i * 40}ms` }"
@click="$emit('pick-scene', s)"
>
{{ s.label }}
<span class="p-mark" aria-hidden="true"></span>
<span class="p-text">{{ s.label }}</span>
<span class="p-go" aria-hidden="true"></span>
</button>
</div>
<button type="button" class="advisor-link" @click="$emit('switch-advisor')">
@@ -38,27 +44,48 @@ const guidePrompts = askScenes
<style scoped>
.card {
background: var(--color-surface);
background: rgba(255, 255, 255, 0.92);
border-radius: var(--radius-xl);
padding: 16px;
padding: var(--spacing-md);
box-shadow: var(--shadow-hero);
margin-bottom: 12px;
margin-bottom: var(--spacing-sm);
border: 1px solid rgba(255, 255, 255, 0.95);
}
.greet-row {
display: flex;
gap: 12px;
gap: var(--spacing-sm);
align-items: center;
margin-bottom: 14px;
margin-bottom: var(--spacing-md);
}
.av {
width: 56px;
height: 56px;
border-radius: 18px;
display: grid;
place-items: center;
background: linear-gradient(145deg, #ffe8e0, #ffd4c8);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
flex-shrink: 0;
}
.hi {
font-size: 16px;
font-family: var(--font-display);
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.02em;
}
.hi-sub {
font-size: 12px;
color: #888;
margin-top: 2px;
color: #9a8f8b;
margin-top: 4px;
line-height: 1.45;
}
.sec-label {
font-size: 11px;
font-weight: 650;
letter-spacing: 0.08em;
color: #b0a49f;
margin-bottom: 8px;
}
.prompts {
display: flex;
@@ -66,18 +93,38 @@ const guidePrompts = askScenes
gap: 8px;
}
.prompt {
display: flex;
align-items: center;
gap: 10px;
text-align: left;
border: 1px solid #f0ebe8;
background: #fffaf8;
border: 1px solid #f3e8e4;
background: linear-gradient(180deg, #fffdfb, #fff7f4);
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
padding: 12px 12px 12px 14px;
color: #444;
line-height: 1.45;
animation: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--d, 0ms);
}
.prompt:active {
background: #fff0ec;
border-color: #f5c4bc;
transform: scale(0.99);
}
.p-mark {
color: var(--color-primary);
opacity: 0.7;
font-size: 12px;
}
.p-text {
flex: 1;
font-size: 13px;
font-weight: 500;
}
.p-go {
color: #d0c4bf;
font-size: 18px;
line-height: 1;
}
.advisor-link {
margin-top: 12px;
@@ -1,14 +1,27 @@
<template>
<div class="thread" ref="threadRef">
<div v-for="m in messages" :key="m.id" :class="['bubble', m.role]">
<p>{{ m.content }}</p>
<div class="thread">
<div
v-for="m in messages"
:id="`ask-msg-${m.id}`"
:key="m.id"
:class="['row', m.role]"
>
<div v-if="m.role === 'assistant'" class="av" aria-hidden="true"></div>
<div :class="['bubble', m.role]">
<p>{{ m.content }}</p>
</div>
</div>
<div v-if="sending && !streamingStarted" class="row assistant" id="ask-msg-typing">
<div class="av" aria-hidden="true"></div>
<div class="bubble assistant typing">
<span /><span /><span />
</div>
</div>
<p v-if="sending" class="hint pad">助手正在回复</p>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, watch } from 'vue'
import type { AskMessage } from '@yuxingu/types'
const props = defineProps<{
@@ -16,48 +29,94 @@ const props = defineProps<{
sending: boolean
}>()
const threadRef = ref<HTMLElement | null>(null)
const streamingStarted = computed(() => {
const last = props.messages[props.messages.length - 1]
return last?.role === 'assistant'
})
watch(
() => props.messages.length,
() => [props.messages.length, props.sending] as const,
async () => {
await nextTick()
if (threadRef.value) threadRef.value.scrollTop = threadRef.value.scrollHeight
const last = props.sending
? document.getElementById('ask-msg-typing')
: props.messages.length
? document.getElementById(`ask-msg-${props.messages[props.messages.length - 1].id}`)
: null
last?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
},
)
</script>
<style scoped>
.thread {
overflow-y: auto;
max-height: 36vh;
/* 平铺在页面里,用整页滚动,避免内嵌滚动条难操作 */
overflow: visible;
max-height: none;
padding: 4px 0 12px;
}
.row {
display: flex;
align-items: flex-end;
gap: 8px;
margin: 10px 0;
}
.row.user {
justify-content: flex-end;
}
.av {
width: 28px;
height: 28px;
border-radius: 10px;
flex-shrink: 0;
display: grid;
place-items: center;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
color: #c23b32;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
}
.bubble {
margin: 8px 0;
max-width: min(86%, 340px);
padding: 12px 14px;
border-radius: 16px;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
box-shadow: var(--shadow-card);
background: #fff;
word-break: break-word;
}
.bubble.user {
margin-left: 28px;
color: #333;
background: linear-gradient(135deg, #ff8a7c, var(--color-primary));
color: #fff;
border-bottom-right-radius: 6px;
box-shadow: 0 6px 16px rgba(229, 77, 66, 0.22);
}
.bubble.assistant {
margin-right: 28px;
background: #fff5f4;
color: #444;
box-shadow: none;
background: #fff;
color: #3d3734;
border-bottom-left-radius: 6px;
border: 1px solid #f3e8e4;
box-shadow: var(--shadow-card);
}
.hint {
font-size: 13px;
color: #999;
text-align: center;
padding: 24px;
.typing {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 52px;
padding-block: 14px;
}
.typing span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #e0b5ad;
animation: blink 1.1s ease-in-out infinite;
}
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-2px); }
}
.pad { padding: 0 4px; }
</style>
@@ -1,18 +1,24 @@
<template>
<div class="card controls">
<label class="lbl">档案</label>
<select
class="sel"
:value="profileId"
@change="
$emit('update:profileId', ($event.target as HTMLSelectElement).value);
$emit('profile-change')
"
>
<option v-for="p in profiles" :key="p.id" :value="p.id">
{{ p.relation === 'self' ? '我的档案' : `TA · ${p.display_name || '未命名'}` }}
</option>
</select>
<div class="head">
<span class="lbl">解读对象</span>
<span class="hint">切换档案回答更贴合</span>
</div>
<div class="chips" role="listbox" aria-label="选择档案">
<button
v-for="p in profiles"
:key="p.id"
type="button"
class="chip"
:class="{ on: p.id === profileId }"
role="option"
:aria-selected="p.id === profileId"
@click="pick(p.id)"
>
<span class="chip-mark">{{ p.relation === 'self' ? '我' : 'TA' }}</span>
<span class="chip-name">{{ p.relation === 'self' ? '我的档案' : p.display_name || '未命名' }}</span>
</button>
</div>
<div class="quick">
<button
v-for="q in quickTools"
@@ -31,42 +37,94 @@
import type { Profile } from '@yuxingu/types'
import { askQuickTools } from '../../composables/useAskPage'
defineProps<{
const props = defineProps<{
profiles: Profile[]
profileId: string
}>()
defineEmits<{
const emit = defineEmits<{
'update:profileId': [v: string]
'profile-change': []
navigate: [to: string]
}>()
const quickTools = askQuickTools
function pick(id: string) {
if (id === props.profileId) return
emit('update:profileId', id)
emit('profile-change')
}
</script>
<style scoped>
.card {
background: var(--color-surface);
background: rgba(255, 255, 255, 0.9);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-hero);
padding: 14px;
box-shadow: var(--shadow-card);
margin-bottom: 12px;
border: 1px solid #f6ece8;
}
.controls .lbl {
display: block;
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
}
.lbl {
font-size: 12px;
color: #999;
margin-bottom: 6px;
font-weight: 650;
color: #8a807c;
letter-spacing: 0.04em;
}
.sel {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1.5px solid #eee;
background: var(--color-input-bg);
font-size: 14px;
outline: none;
.hint {
font-size: 11px;
color: #c4bbb6;
}
.chips {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 8px;
border-radius: var(--radius-pill);
border: 1.5px solid #f0e6e2;
background: #fffaf8;
color: #666;
}
.chip.on {
border-color: #f5c4bc;
background: #fff;
color: var(--color-primary);
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.12);
}
.chip-mark {
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
background: #ffe4de;
color: #c23b32;
}
.chip.on .chip-mark {
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
color: #fff;
}
.chip-name {
font-size: 13px;
font-weight: 600;
}
.quick {
display: flex;
@@ -75,6 +133,7 @@ const quickTools = askQuickTools
overflow-x: auto;
scrollbar-width: none;
}
.quick::-webkit-scrollbar { display: none; }
.q-btn {
flex-shrink: 0;
padding: 7px 12px;
@@ -82,6 +141,10 @@ const quickTools = askQuickTools
border: 1px solid #f0ebe8;
background: #fff;
font-size: 12px;
color: #666;
color: #888;
}
.q-btn:active {
border-color: #f5c4bc;
color: var(--color-primary);
}
</style>
@@ -0,0 +1,143 @@
<template>
<section class="buy">
<p class="lead">{{ lead }}</p>
<div class="packs">
<button
v-for="p in packs"
:key="p.plan"
type="button"
class="pack"
:class="{ on: p.featured }"
:disabled="busy"
@click="buy(p.plan)"
>
<strong>{{ p.label }}</strong>
<span class="times">{{ p.times }} </span>
<span class="price">{{ p.price }}</span>
</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<router-link class="vip" to="/membership">或开通成长会员 · 完整分析 + 更多次数 </router-link>
<p class="note">模拟支付购买后立刻到账</p>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '../../api/client'
import { AnalyticsEvent, track } from '../../lib/analytics'
withDefaults(
defineProps<{
lead?: string
}>(),
{ lead: '免费次数用完了,可购买更多问答额度' },
)
const emit = defineEmits<{
purchased: []
}>()
const packs = [
{ plan: 'pack10', label: '轻量包', times: 10, price: '模拟 ¥9.9', featured: false },
{ plan: 'pack30', label: '常用包', times: 30, price: '模拟 ¥19.8', featured: true },
{ plan: 'pack100', label: '畅聊包', times: 100, price: '模拟 ¥49.9', featured: false },
]
const busy = ref(false)
const error = ref('')
const ok = ref('')
async function buy(plan: string) {
if (busy.value) return
busy.value = true
error.value = ''
ok.value = ''
try {
const { order_id } = await api.createOrder({ kind: 'ask_pack', plan })
await api.payMock(order_id)
track(AnalyticsEvent.PurchaseCompleted, { kind: 'ask_pack', plan })
ok.value = '额度已到账,可以继续聊'
emit('purchased')
} catch (e) {
error.value = e instanceof Error ? e.message : '购买失败'
} finally {
busy.value = false
}
}
</script>
<style scoped>
.buy {
margin: 8px 0 12px;
padding: 14px 14px 12px;
border-radius: 16px;
background: linear-gradient(145deg, #fff7f4, #ffe9e2);
border: 1px solid #f5c4bc;
}
.lead {
font-size: 13px;
color: #5c524e;
line-height: 1.5;
margin-bottom: 10px;
}
.packs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.pack {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 8px;
border-radius: 12px;
border: 1.5px solid #f0e6e2;
background: #fff;
text-align: center;
}
.pack.on {
border-color: var(--color-primary);
box-shadow: 0 4px 12px rgba(229, 77, 66, 0.12);
}
.pack strong {
font-size: 13px;
color: #2f2a28;
}
.times {
font-size: 12px;
font-weight: 650;
color: var(--color-primary);
}
.price {
font-size: 10px;
color: #a39893;
}
.pack:disabled {
opacity: 0.55;
}
.err {
margin-top: 8px;
font-size: 12px;
color: var(--color-primary);
}
.ok {
margin-top: 8px;
font-size: 12px;
color: #2f7a4f;
}
.vip {
display: block;
margin-top: 10px;
font-size: 12px;
color: var(--color-accent-blue);
text-decoration: none;
}
.note {
margin-top: 6px;
font-size: 10px;
color: #c4bbb6;
text-align: center;
}
</style>
+14 -11
View File
@@ -34,38 +34,41 @@ defineEmits<{
.rails {
display: flex;
gap: 0;
background: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.78);
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);
box-shadow: 0 6px 18px rgba(180, 90, 70, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rail {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px;
padding: 11px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 600;
color: #888;
font-weight: 650;
color: #9a8f8b;
position: relative;
transition: color var(--duration-fast) ease;
}
.rail.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(229, 77, 66, 0.12);
box-shadow: 0 3px 10px rgba(229, 77, 66, 0.14);
}
.rail-tag {
position: absolute;
top: -2px;
right: 8px;
top: -3px;
right: 10px;
font-size: 9px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
padding: 1px 5px;
border-radius: 6px;
padding: 2px 6px;
border-radius: 7px;
letter-spacing: 0.02em;
}
</style>
@@ -1,5 +1,5 @@
<template>
<section class="self-card reveal" style="--d: 80ms" @click="$emit('open-portrait')">
<section class="self-card reveal" style="--d: 80ms" data-track="home_self_card" @click="$emit('open-portrait')">
<div class="self-head">
<button type="button" class="who-btn" @click.stop="$emit('open-profile')">
{{ profileLabel }}
+75 -6
View File
@@ -91,21 +91,88 @@ export function useAskPage() {
sending.value = true
sendError.value = ''
quotaExhausted.value = false
draft.value = ''
const streamId = `stream-${Date.now()}`
let gotMeta = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
await api.sendAskMessageStream(tid, content, {
onMeta: (userMsg) => {
gotMeta = true
messages.value = [
...messages.value,
userMsg,
{
id: streamId,
thread_id: tid,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
},
]
},
onDelta: (text) => {
const list = messages.value
const last = list[list.length - 1]
if (!last || last.id !== streamId) return
messages.value = [...list.slice(0, -1), { ...last, content: last.content + text }]
},
onDone: (payload) => {
const base = messages.value.filter(
(m) => m.id !== streamId && m.id !== payload.assistant_message.id,
)
messages.value = [...base, payload.assistant_message]
quota.value = payload.quota
},
onError: (msg) => {
sendError.value = msg
quotaExhausted.value =
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
messages.value = messages.value.filter((m) => m.id !== streamId)
if (!gotMeta) draft.value = content
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
if (!sendError.value) sendError.value = msg
quotaExhausted.value =
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
messages.value = messages.value.filter((m) => m.id !== streamId)
if (!gotMeta && !draft.value) draft.value = content
} finally {
sending.value = false
}
}
async function refreshQuota() {
try {
quota.value = await api.getAskQuota()
if (quota.value.remaining > 0) {
quotaExhausted.value = false
sendError.value = ''
}
} catch {
/* ignore; user can retry send */
}
}
async function clearChat() {
if (sending.value) return
if (!messages.value.length && !threadId.value) return
sendError.value = ''
const tid = threadId.value
messages.value = []
threadId.value = ''
draft.value = ''
if (tid) {
try {
await api.clearAskThread(tid)
} catch {
/* local clear still applies */
}
}
}
onMounted(boot)
return {
@@ -126,5 +193,7 @@ export function useAskPage() {
askAdvisor,
send,
boot,
refreshQuota,
clearChat,
}
}
+71 -4
View File
@@ -1,14 +1,82 @@
import { computed, ref } from 'vue'
import { computed, onMounted, reactive, ref, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog'
import {
homeFeeds,
homeGridRow1,
homeGridRow2,
homeSearchHints,
type HomeTool,
} from '../lib/homeCatalog'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { useHomeMood } from './useHomeMood'
const ICONS = new Set([
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
])
function mapTools(
items: Array<{
row_index: number
sort_order: number
path: string
icon: string
label: string
badge?: string | null
badge_tone?: string | null
}>,
): { row1: HomeTool[]; row2: HomeTool[] } {
const toTool = (it: (typeof items)[0]): HomeTool | null => {
if (!ICONS.has(it.icon)) return null
const t: HomeTool = {
to: it.path,
icon: it.icon as HomeToolIconName,
label: it.label,
}
if (it.badge) t.badge = it.badge
if (it.badge_tone === 'hot' || it.badge_tone === 'new') t.badgeTone = it.badge_tone
return t
}
const sorted = [...items].sort(
(a, b) => a.row_index - b.row_index || a.sort_order - b.sort_order,
)
const row1: HomeTool[] = []
const row2: HomeTool[] = []
for (const it of sorted) {
const t = toTool(it)
if (!t) continue
if (it.row_index === 1) row1.push(t)
else if (it.row_index === 2) row2.push(t)
}
return { row1, row2 }
}
export function useHomePage() {
const router = useRouter()
const profileLabel = ref('自己')
const plusOpen = ref(false)
const mood = useHomeMood()
const grid = reactive({
gridRow1: [...homeGridRow1] as HomeTool[],
gridRow2: [...homeGridRow2] as HomeTool[],
})
onMounted(() => {
void api
.getHomeTools()
.then((res) => {
const mapped = mapTools(res.items || [])
if (mapped.row1.length || mapped.row2.length) {
grid.gridRow1 = mapped.row1
grid.gridRow2 = mapped.row2
}
})
.catch(() => {
/* keep static fallback */
})
})
const searchHint = computed(() => {
const i = new Date().getHours() % homeSearchHints.length
@@ -39,8 +107,7 @@ export function useHomePage() {
plusOpen,
mood,
searchHint,
gridRow1: homeGridRow1,
gridRow2: homeGridRow2,
...toRefs(grid),
feeds: homeFeeds,
goPlus,
trackGrid,
@@ -1,10 +1,11 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
@@ -15,6 +16,7 @@ export const resultTabs = [
export function useLifeRhythmPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -68,7 +70,7 @@ export function useLifeRhythmPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
report.value = await api.getLatestReport(profile.id, 'rhythm')
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
@@ -92,6 +94,7 @@ export function useLifeRhythmPage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -107,14 +110,35 @@ export function useLifeRhythmPage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
loading.value = true
try {
const cached = await loadSelfLatest('rhythm')
if (cached.report) {
report.value = cached.report
needBirth.value = false
tab.value = 'overview'
return
}
if (cached.profile) {
report.value = await api.createRhythm(cached.profile.id)
needBirth.value = false
tab.value = 'overview'
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
+36 -13
View File
@@ -1,13 +1,15 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import type { PortraitSharePayload } from '../lib/shareLink'
export function usePortraitPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -44,7 +46,7 @@ export function usePortraitPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createPortrait(profile.id)
report.value = await api.getLatestReport(profile.id, 'portrait')
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -67,6 +69,7 @@ export function usePortraitPage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -83,18 +86,38 @@ export function usePortraitPage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
return
loading.value = true
error.value = ''
try {
const cached = await loadSelfLatest('portrait')
if (cached.report) {
report.value = cached.report
needBirth.value = false
track(AnalyticsEvent.PortraitCompleted, { source: 'cache' })
return
}
if (cached.profile) {
report.value = await api.createPortrait(cached.profile.id)
needBirth.value = false
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
return
}
needBirth.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
needBirth.value = true
loading.value = false
}
function retry() {
@@ -1,6 +1,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { ensureAccount } from '../lib/authSession'
import {
avatarInitial,
createProfilePageActions,
@@ -102,6 +103,7 @@ export function useProfilePage() {
)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) return
await load()
applyQueryFlags()
})
@@ -1,10 +1,11 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import {
ascLonFromSummary,
fortuneBundleFrom,
@@ -30,6 +31,7 @@ export { starViewTabs, starFortuneKeys, fortuneLabel }
export function useStarProfilePage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -116,7 +118,7 @@ export function useStarProfilePage() {
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
report.value = await api.getLatestReport(profile.id, 'star')
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
@@ -140,6 +142,7 @@ export function useStarProfilePage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -155,14 +158,35 @@ export function useStarProfilePage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
loading.value = true
try {
const cached = await loadSelfLatest('star')
if (cached.report) {
report.value = cached.report
needBirth.value = false
view.value = 'overview'
return
}
if (cached.profile) {
report.value = await api.createStar(cached.profile.id)
needBirth.value = false
view.value = 'overview'
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
+43 -4
View File
@@ -1,12 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AnalyticsEvent, track, trackPageView } from './analytics'
vi.mock('@/api/client', () => ({
api: {
postAnalyticsEvents: vi.fn().mockResolvedValue({ accepted: 1 }),
},
}))
import {
AnalyticsEvent,
track,
trackPageView,
_resetAnalyticsForTests,
_peekQueueForTests,
_setOwnEnabledForTests,
ensureSession,
} from './analytics'
describe('analytics.track', () => {
beforeEach(() => {
_resetAnalyticsForTests()
_setOwnEnabledForTests(true)
sessionStorage.clear()
window.gtag = vi.fn()
})
afterEach(() => {
delete window.gtag
_resetAnalyticsForTests()
})
it('calls gtag with event name and params', () => {
@@ -24,11 +43,31 @@ describe('analytics.track', () => {
expect(() => track(AnalyticsEvent.RelationCompleted)).not.toThrow()
})
it('trackPageView sends page_path', () => {
it('trackPageView sends page_path and queues page_leave on next path', () => {
trackPageView('/portrait', '个人画像')
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', {
expect(window.gtag).toHaveBeenCalledWith('event', 'page_view', expect.objectContaining({
page_path: '/portrait',
page_title: '个人画像',
})
}))
trackPageView('/ask', 'ask')
const names = _peekQueueForTests().map((q) => q.name)
expect(names).toContain('page_leave')
expect(names).toContain('page_view')
})
it('ensureSession returns stable id within idle window', () => {
const a = ensureSession(true)
const b = ensureSession()
expect(a).toBe(b)
expect(a.startsWith('s_')).toBe(true)
})
it('queues own events with session_id', () => {
track(AnalyticsEvent.UiClick, { element_id: 'home_cta' })
const q = _peekQueueForTests()
expect(q.length).toBe(1)
expect(q[0].name).toBe('ui_click')
expect(q[0].session_id).toBeTruthy()
expect(q[0].props?.element_id).toBe('home_cta')
})
})
+197 -10
View File
@@ -1,8 +1,10 @@
/**
* P1 growth analytics — see .ai/product/feature-spec/analytics.md
* P1 growth analytics + Ops-B own pipeline — see feature-spec analytics.md / ops-analytics.md
* Pages MUST use track(); do not call window.gtag directly.
*/
import { api } from '@/api/client'
export type AnalyticsParams = Record<string, string | number | boolean | undefined>
/** Frozen P1 core funnel events (+ page_view via router). */
@@ -18,6 +20,10 @@ export const AnalyticsEvent = {
SynastryNearbyOpened: 'synastry_nearby_opened',
StarWheelViewed: 'star_wheel_viewed',
PageView: 'page_view',
SessionStart: 'session_start',
SessionEnd: 'session_end',
PageLeave: 'page_leave',
UiClick: 'ui_click',
} as const
export type CoreAnalyticsEvent = (typeof AnalyticsEvent)[keyof typeof AnalyticsEvent]
@@ -29,22 +35,50 @@ declare global {
}
}
let gaReady = false
const SESSION_KEY = 'yxg_analytics_sid'
const LAST_ACTIVE_KEY = 'yxg_analytics_active'
const SESSION_START_KEY = 'yxg_analytics_started'
const SESSION_IDLE_MS = 30 * 60 * 1000
const FLUSH_MS = 8000
const MAX_QUEUE = 80
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Safe to call once from main. */
type QueueItem = {
name: string
session_id: string
page_path?: string
client_ts: string
props?: Record<string, string | number | boolean>
}
let gaReady = false
let queue: QueueItem[] = []
let flushTimer: ReturnType<typeof setInterval> | null = null
let pageEnteredAt = 0
let currentPath = ''
let referrerPath = ''
let listenersBound = false
let ownEnabled = true
/** Load GA4 when VITE_GA_MEASUREMENT_ID is set. Starts own session pipeline. */
export function initAnalytics(): void {
initGA()
ensureSession(true)
bindLifecycle()
startFlushLoop()
track(AnalyticsEvent.SessionStart, { cold: true, app_ver: String(import.meta.env.VITE_APP_VER || 'h5') })
}
function initGA(): void {
const id = (import.meta.env.VITE_GA_MEASUREMENT_ID || '').trim()
if (!id || typeof document === 'undefined') return
if (gaReady) return
gaReady = true
window.dataLayer = window.dataLayer || []
window.gtag = function gtag(...args: unknown[]) {
window.dataLayer!.push(args)
}
window.gtag('js', new Date())
window.gtag('config', id, { send_page_view: false })
const s = document.createElement('script')
s.async = true
s.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`
@@ -55,7 +89,133 @@ function debugEnabled(): boolean {
return import.meta.env.DEV && String(import.meta.env.VITE_ANALYTICS_DEBUG || '') === '1'
}
/** Fire a custom event. No-op when gtag missing; never throws. */
function nowISO(): string {
return new Date().toISOString()
}
function touchActive(): void {
try {
sessionStorage.setItem(LAST_ACTIVE_KEY, String(Date.now()))
} catch {
/* ignore */
}
}
/** session_id in sessionStorage; refresh after 30min idle. */
export function ensureSession(forceNew = false): string {
try {
const last = Number(sessionStorage.getItem(LAST_ACTIVE_KEY) || 0)
let sid = sessionStorage.getItem(SESSION_KEY) || ''
const idle = !last || Date.now() - last > SESSION_IDLE_MS
if (forceNew || !sid || idle) {
sid = `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
sessionStorage.setItem(SESSION_KEY, sid)
sessionStorage.setItem(SESSION_START_KEY, String(Date.now()))
}
touchActive()
return sid
} catch {
return `s_${Date.now().toString(36)}`
}
}
function enqueue(name: string, params?: AnalyticsParams): void {
if (!ownEnabled) return
const sid = ensureSession()
const clean: Record<string, string | number | boolean> = {}
let pagePath = currentPath
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v === undefined) continue
clean[k] = v
if (k === 'page_path' && typeof v === 'string') pagePath = v
}
}
queue.push({
name,
session_id: sid,
page_path: pagePath || undefined,
client_ts: nowISO(),
props: Object.keys(clean).length ? clean : undefined,
})
if (queue.length > MAX_QUEUE) queue = queue.slice(-MAX_QUEUE)
touchActive()
}
async function flushQueue(beacon = false): Promise<void> {
if (!queue.length) return
const items = queue.splice(0, MAX_QUEUE)
try {
if (beacon) {
await api.postAnalyticsEvents({ items }, { keepalive: true })
} else {
await api.postAnalyticsEvents({ items })
}
} catch {
queue = items.concat(queue).slice(0, MAX_QUEUE)
}
}
function startFlushLoop(): void {
if (typeof window === 'undefined' || flushTimer) return
flushTimer = setInterval(() => {
void flushQueue(false)
}, FLUSH_MS)
}
function emitPageLeave(path: string): void {
if (!path || !pageEnteredAt) return
const dwell = Math.max(0, Date.now() - pageEnteredAt)
track(AnalyticsEvent.PageLeave, { page_path: path, dwell_ms: dwell })
}
function endSession(exitPage: string): void {
ensureSession()
let started = Date.now()
try {
started = Number(sessionStorage.getItem(SESSION_START_KEY) || Date.now())
} catch {
/* ignore */
}
const duration = Math.max(0, Date.now() - started)
track(AnalyticsEvent.SessionEnd, {
exit_page: exitPage,
duration_ms: duration,
page_path: exitPage,
})
void flushQueue(true)
}
function bindLifecycle(): void {
if (typeof document === 'undefined' || listenersBound) return
listenersBound = true
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
emitPageLeave(currentPath)
void flushQueue(true)
} else {
ensureSession()
}
})
window.addEventListener('pagehide', () => {
emitPageLeave(currentPath)
endSession(currentPath || '/')
})
document.addEventListener(
'click',
(ev) => {
const t = ev.target as HTMLElement | null
const el = t?.closest?.('[data-track]') as HTMLElement | null
if (!el) return
const id = el.getAttribute('data-track') || ''
if (!id) return
track(AnalyticsEvent.UiClick, { element_id: id, page_path: currentPath })
},
true,
)
}
/** Fire a custom event. Dual-write GA + own queue; never throws. */
export function track(event: string, params?: AnalyticsParams): void {
try {
const clean: Record<string, string | number | boolean> = {}
@@ -64,20 +224,47 @@ export function track(event: string, params?: AnalyticsParams): void {
if (v !== undefined) clean[k] = v
}
}
if (debugEnabled()) {
console.debug('[analytics]', event, clean)
}
if (debugEnabled()) console.debug('[analytics]', event, clean)
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('event', event, clean)
}
enqueue(event, params)
} catch {
/* never block UX */
}
}
export function trackPageView(path: string, title?: string): void {
if (currentPath && currentPath !== path) {
emitPageLeave(currentPath)
referrerPath = currentPath
}
currentPath = path
pageEnteredAt = Date.now()
track(AnalyticsEvent.PageView, {
page_path: path,
page_title: title || document.title,
page_title: title || (typeof document !== 'undefined' ? document.title : ''),
referrer_path: referrerPath || undefined,
})
}
/** Test helpers */
export function _resetAnalyticsForTests(): void {
queue = []
pageEnteredAt = 0
currentPath = ''
referrerPath = ''
listenersBound = false
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = null
}
}
export function _peekQueueForTests(): QueueItem[] {
return queue.slice()
}
export function _setOwnEnabledForTests(v: boolean): void {
ownEnabled = v
}
+47
View File
@@ -0,0 +1,47 @@
import type { AuthUser, GrowthReport, Profile } from '@yuxingu/types'
import type { Router } from 'vue-router'
import { api } from '../api/client'
const TOKEN_KEY = 'yxg_token'
export function setAuthToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearAuthToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function getAuthToken() {
return localStorage.getItem(TOKEN_KEY)
}
/** Ensure logged-in account; redirect to /login on failure. */
export async function ensureAccount(router: Router, redirect: string): Promise<AuthUser | null> {
try {
return await api.authMe()
} catch {
clearAuthToken()
await router.replace({ path: '/login', query: { redirect } })
return null
}
}
export async function findSelfProfile(): Promise<Profile | null> {
const { items } = await api.listProfiles()
return items.find((p) => p.relation === 'self') || null
}
/** Load cached solo report for self profile; null report means need archive. */
export async function loadSelfLatest(
type: 'portrait' | 'star' | 'rhythm',
): Promise<{ profile: Profile | null; report: GrowthReport | null }> {
const profile = await findSelfProfile()
if (!profile) return { profile: null, report: null }
try {
const report = await api.getLatestReport(profile.id, type)
return { profile, report }
} catch {
return { profile, report: null }
}
}
+29 -15
View File
@@ -9,6 +9,7 @@ const { api } = vi.hoisted(() => ({
getAskQuota: vi.fn(),
createAskThread: vi.fn(),
sendAskMessage: vi.fn(),
sendAskMessageStream: vi.fn(),
},
}))
@@ -36,7 +37,7 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('先建立你的个人档案')
expect(w.text()).toContain('去愈心解码')
})
@@ -46,27 +47,40 @@ describe('AskPage', () => {
})
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' })
api.sendAskMessage.mockResolvedValue({
user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' },
assistant_message: {
id: 'm2',
role: 'assistant',
content: '结合档案的回复',
thread_id: 't1',
created_at: '',
api.sendAskMessageStream.mockImplementation(
async (
_tid: string,
_content: string,
handlers: {
onMeta?: (m: unknown) => void
onDelta?: (t: string) => void
onDone?: (p: unknown) => void
},
) => {
handlers.onMeta?.({ id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' })
handlers.onDelta?.('结合档案的回复')
handlers.onDone?.({
assistant_message: {
id: 'm2',
role: 'assistant',
content: '结合档案的回复',
thread_id: 't1',
created_at: '',
},
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
})
},
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
})
)
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('剩余 3 次')
expect(w.text()).toContain('还可聊 3 次')
await w.find('textarea').setValue('你好')
await w.find('form').trigger('submit')
await flushPromises()
expect(api.createAskThread).toHaveBeenCalled()
expect(api.sendAskMessageStream).toHaveBeenCalled()
expect(w.text()).toContain('结合档案的回复')
expect(w.text()).toContain('剩余 2 次')
expect(w.text()).toContain('还可聊 2 次')
})
it('shows boot error with retry', async () => {
@@ -79,6 +93,6 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
await w.find('button.retry').trigger('click')
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('先建立你的个人档案')
})
})
+49 -1
View File
@@ -1,7 +1,21 @@
<template>
<PageShell :sheet="false">
<template #hero>
<AskRails v-model="rail" />
<div class="ask-hero">
<div class="ask-top">
<BackButton />
<button
v-if="messages.length"
type="button"
class="clear-btn"
:disabled="sending"
@click="clearChat"
>
清空聊天
</button>
</div>
<AskRails v-model="rail" />
</div>
</template>
<AskAiPane
@@ -24,6 +38,7 @@
@navigate="router.push($event)"
@update:draft="draft = $event"
@send="send"
@quota-refreshed="refreshQuota"
/>
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
@@ -34,6 +49,7 @@
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
import AskAiPane from '../components/ask/AskAiPane.vue'
import AskRails from '../components/ask/AskRails.vue'
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import { useAskPage } from '../composables/useAskPage'
@@ -55,5 +71,37 @@ const {
askAdvisor,
send,
boot,
refreshQuota,
clearChat,
} = useAskPage()
</script>
<style scoped>
.ask-hero {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 4px;
}
.ask-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ask-hero :deep(.back) {
margin-bottom: 0;
align-self: flex-start;
}
.clear-btn {
border: none;
background: transparent;
color: #9a8f8b;
font-size: 13px;
padding: 6px 4px;
flex-shrink: 0;
}
.clear-btn:disabled {
opacity: 0.4;
}
</style>
+2
View File
@@ -2,6 +2,7 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">探索</h1>
<p class="sub">测评 · 工具 · 自我理解</p>
</div>
@@ -53,6 +54,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
+294
View File
@@ -0,0 +1,294 @@
<template>
<PageShell>
<template #hero>
<div class="hero">
<BackButton />
<div class="brand">
<BrandLogo size="card" class="logo" />
<p class="brand-name">愈心谷</p>
</div>
<h1 class="title">{{ mode === 'login' ? '欢迎回来' : '开启你的成长档案' }}</h1>
<p class="sub">登录后生日探索与对话会保存在你的账号里</p>
</div>
</template>
<div class="panel reveal">
<div class="modes" role="tablist">
<button
type="button"
class="mode"
:class="{ on: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="setMode('login')"
>
登录
</button>
<button
type="button"
class="mode"
:class="{ on: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="setMode('register')"
>
注册
</button>
</div>
<form class="form" @submit.prevent="submit">
<label class="field">
<span class="lbl">账号</span>
<input
v-model="phone"
class="inp"
type="text"
maxlength="32"
autocomplete="username"
placeholder="手机号或任意账号"
/>
</label>
<label class="field">
<span class="lbl">密码</span>
<input
v-model="password"
class="inp"
type="password"
autocomplete="current-password"
placeholder="输入密码"
/>
</label>
<label v-if="mode === 'register'" class="field">
<span class="lbl">昵称 <em>可选</em></span>
<input
v-model="nickname"
class="inp"
type="text"
maxlength="32"
placeholder="怎么称呼你"
/>
</label>
<p v-if="error" class="err" role="alert">{{ error }}</p>
<button class="cta" type="submit" :disabled="busy || !canSubmit">
<span v-if="busy" class="dot" aria-hidden="true" />
{{ busy ? '正在进入…' : mode === 'login' ? '进入愈心谷' : '创建账号' }}
</button>
</form>
<p class="foot">继续即表示你同意将档案与探索结果保存在本账号下</p>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BrandLogo from '../components/BrandLogo.vue'
import PageShell from '../components/PageShell.vue'
import { setAuthToken } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const mode = ref<'login' | 'register'>('login')
const phone = ref('')
const password = ref('')
const nickname = ref('')
const error = ref('')
const busy = ref(false)
const canSubmit = computed(() => phone.value.trim().length > 0)
function setMode(next: 'login' | 'register') {
mode.value = next
error.value = ''
}
async function submit() {
if (!canSubmit.value || busy.value) return
error.value = ''
busy.value = true
try {
const body = { phone: phone.value.trim(), password: password.value }
const res =
mode.value === 'login'
? await api.authLogin(body)
: await api.authRegister({ ...body, nickname: nickname.value.trim() || undefined })
setAuthToken(res.token)
const redirect = String(route.query.redirect || '/mine')
await router.replace(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : '失败,请重试'
} finally {
busy.value = false
}
}
</script>
<style scoped>
.hero {
padding: var(--spacing-2xs) 0 var(--spacing-sm);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2xs);
margin: var(--spacing-xs) 0 var(--spacing-md);
}
.logo {
width: 72px !important;
filter: drop-shadow(0 8px 18px rgba(229, 77, 66, 0.18));
}
.brand-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.28em;
color: var(--color-text-primary);
}
.title {
margin: 0;
text-align: center;
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
}
.sub {
margin: var(--spacing-xs) auto 0;
max-width: 16em;
text-align: center;
font-size: 13px;
line-height: 1.55;
color: rgba(0, 0, 0, 0.42);
}
.panel {
margin-top: var(--spacing-sm);
padding: var(--spacing-md);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-hero);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.modes {
display: flex;
gap: 0;
padding: 4px;
margin-bottom: var(--spacing-md);
border-radius: var(--radius-pill);
background: #fff4f0;
}
.mode {
flex: 1;
border: 0;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 650;
color: #9a8f8b;
}
.mode.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 10px rgba(229, 77, 66, 0.12);
}
.form {
display: grid;
gap: var(--spacing-sm);
}
.field {
display: grid;
gap: 6px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: #8a807c;
letter-spacing: 0.04em;
}
.lbl em {
font-style: normal;
font-weight: 500;
color: var(--color-text-tertiary);
}
.inp {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #f0e6e2;
border-radius: var(--radius-md);
padding: 13px 14px;
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-input-bg);
outline: none;
transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
}
.inp:focus {
border-color: #f0b8b0;
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
}
.inp::placeholder {
color: #c4bbb6;
}
.cta {
margin-top: var(--spacing-xs);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
border: 0;
border-radius: var(--radius-pill);
padding: 14px 18px;
font-size: 15px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 10px 22px rgba(229, 77, 66, 0.28);
}
.cta:disabled {
opacity: 0.5;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: pulse 0.9s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.err {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
color: var(--color-primary);
font-size: 13px;
line-height: 1.45;
}
.foot {
margin: var(--spacing-md) 0 0;
text-align: center;
font-size: 11px;
line-height: 1.5;
color: var(--color-text-tertiary);
}
.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); }
}
</style>
+11
View File
@@ -27,6 +27,10 @@
<p class="lead">开通后可查看画像与关系理解的完整分析并获得更多 AI 问答次数</p>
</div>
<p class="sec-title">问答额度包</p>
<AskQuotaBuy lead="免费次数不够用时,可单独加购问答次数(不含深度报告)" @purchased="load" />
<p class="sec-title">成长会员</p>
<div class="plans">
<button
v-for="p in plans"
@@ -53,6 +57,7 @@
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
@@ -148,6 +153,12 @@ onMounted(load)
.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; }
.sec-title {
font-size: 13px;
font-weight: 650;
color: #6a615c;
margin: 4px 0 10px;
}
.plans { display: flex; flex-direction: column; gap: 10px; }
.plan {
position: relative;
+34 -4
View File
@@ -6,10 +6,11 @@
<BrandLogo size="card" class="av-logo" />
</span>
<div class="who">
<p class="name">愈心谷用户</p>
<p class="name">{{ accountLabel }}</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<router-link class="edit" to="/profile">编辑 </router-link>
<router-link v-if="!loggedIn" class="edit" to="/login">登录 </router-link>
<button v-else type="button" class="edit linkish" @click="logout">退出</button>
</div>
</template>
@@ -70,12 +71,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { useRouter } from 'vue-router'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
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'
import { clearAuthToken } from '../lib/authSession'
const router = useRouter()
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
@@ -98,6 +103,10 @@ const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const planLabel = computed(() => {
const p = membership.value?.plan
@@ -111,16 +120,31 @@ async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
me.value = null
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '请先登录'
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.authLogout()
} catch {
/* ignore */
}
clearAuthToken()
me.value = null
await router.push('/login')
}
onMounted(load)
</script>
@@ -164,6 +188,12 @@ onMounted(load)
color: #bbb;
flex-shrink: 0;
}
.linkish {
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
.assets {
display: grid;
grid-template-columns: repeat(4, 1fr);
+1
View File
@@ -18,6 +18,7 @@ const router = createRouter({
{ path: '/ask', name: 'ask', component: () => import('../pages/AskPage.vue') },
{ path: '/companion', name: 'companion', component: () => import('../pages/CompanionPage.vue') },
{ path: '/mine', name: 'mine', component: () => import('../pages/MinePage.vue') },
{ path: '/login', name: 'login', component: () => import('../pages/LoginPage.vue') },
{ path: '/profile', name: 'profile', component: () => import('../pages/ProfilePage.vue') },
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue') },
{ path: '/star', name: 'star', component: () => import('../pages/StarProfilePage.vue') },