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