feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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('先建立你的个人档案')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') },
|
||||
|
||||
Reference in New Issue
Block a user