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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+29 -15
View File
@@ -9,6 +9,7 @@ const { api } = vi.hoisted(() => ({
getAskQuota: vi.fn(),
createAskThread: vi.fn(),
sendAskMessage: vi.fn(),
sendAskMessageStream: vi.fn(),
},
}))
@@ -36,7 +37,7 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('先建立你的个人档案')
expect(w.text()).toContain('去愈心解码')
})
@@ -46,27 +47,40 @@ describe('AskPage', () => {
})
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
api.createAskThread.mockResolvedValue({ id: 't1', profile_id: 'p1' })
api.sendAskMessage.mockResolvedValue({
user_message: { id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' },
assistant_message: {
id: 'm2',
role: 'assistant',
content: '结合档案的回复',
thread_id: 't1',
created_at: '',
api.sendAskMessageStream.mockImplementation(
async (
_tid: string,
_content: string,
handlers: {
onMeta?: (m: unknown) => void
onDelta?: (t: string) => void
onDone?: (p: unknown) => void
},
) => {
handlers.onMeta?.({ id: 'm1', role: 'user', content: '你好', thread_id: 't1', created_at: '' })
handlers.onDelta?.('结合档案的回复')
handlers.onDone?.({
assistant_message: {
id: 'm2',
role: 'assistant',
content: '结合档案的回复',
thread_id: 't1',
created_at: '',
},
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
})
},
quota: { remaining: 2, free_limit: 3, active_membership: false, source: 'free' },
})
)
const w = mount(AskPage, { global: { stubs: { RouterLink: RouterLinkStub } } })
await flushPromises()
expect(w.text()).toContain('剩余 3 次')
expect(w.text()).toContain('还可聊 3 次')
await w.find('textarea').setValue('你好')
await w.find('form').trigger('submit')
await flushPromises()
expect(api.createAskThread).toHaveBeenCalled()
expect(api.sendAskMessageStream).toHaveBeenCalled()
expect(w.text()).toContain('结合档案的回复')
expect(w.text()).toContain('剩余 2 次')
expect(w.text()).toContain('还可聊 2 次')
})
it('shows boot error with retry', async () => {
@@ -79,6 +93,6 @@ describe('AskPage', () => {
api.getAskQuota.mockResolvedValue({ remaining: 3, free_limit: 3, active_membership: false, source: 'free' })
await w.find('button.retry').trigger('click')
await flushPromises()
expect(w.text()).toContain('还没有个人档案')
expect(w.text()).toContain('先建立你的个人档案')
})
})
+49 -1
View File
@@ -1,7 +1,21 @@
<template>
<PageShell :sheet="false">
<template #hero>
<AskRails v-model="rail" />
<div class="ask-hero">
<div class="ask-top">
<BackButton />
<button
v-if="messages.length"
type="button"
class="clear-btn"
:disabled="sending"
@click="clearChat"
>
清空聊天
</button>
</div>
<AskRails v-model="rail" />
</div>
</template>
<AskAiPane
@@ -24,6 +38,7 @@
@navigate="router.push($event)"
@update:draft="draft = $event"
@send="send"
@quota-refreshed="refreshQuota"
/>
<AskAdvisorPane v-show="rail === 'advisor'" @ask="askAdvisor" />
@@ -34,6 +49,7 @@
import AskAdvisorPane from '../components/ask/AskAdvisorPane.vue'
import AskAiPane from '../components/ask/AskAiPane.vue'
import AskRails from '../components/ask/AskRails.vue'
import BackButton from '../components/BackButton.vue'
import PageShell from '../components/PageShell.vue'
import { useAskPage } from '../composables/useAskPage'
@@ -55,5 +71,37 @@ const {
askAdvisor,
send,
boot,
refreshQuota,
clearChat,
} = useAskPage()
</script>
<style scoped>
.ask-hero {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 4px;
}
.ask-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ask-hero :deep(.back) {
margin-bottom: 0;
align-self: flex-start;
}
.clear-btn {
border: none;
background: transparent;
color: #9a8f8b;
font-size: 13px;
padding: 6px 4px;
flex-shrink: 0;
}
.clear-btn:disabled {
opacity: 0.4;
}
</style>
+2
View File
@@ -2,6 +2,7 @@
<PageShell>
<template #hero>
<div class="head">
<BackButton />
<h1 class="title">探索</h1>
<p class="sub">测评 · 工具 · 自我理解</p>
</div>
@@ -53,6 +54,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
+294
View File
@@ -0,0 +1,294 @@
<template>
<PageShell>
<template #hero>
<div class="hero">
<BackButton />
<div class="brand">
<BrandLogo size="card" class="logo" />
<p class="brand-name">愈心谷</p>
</div>
<h1 class="title">{{ mode === 'login' ? '欢迎回来' : '开启你的成长档案' }}</h1>
<p class="sub">登录后生日探索与对话会保存在你的账号里</p>
</div>
</template>
<div class="panel reveal">
<div class="modes" role="tablist">
<button
type="button"
class="mode"
:class="{ on: mode === 'login' }"
role="tab"
:aria-selected="mode === 'login'"
@click="setMode('login')"
>
登录
</button>
<button
type="button"
class="mode"
:class="{ on: mode === 'register' }"
role="tab"
:aria-selected="mode === 'register'"
@click="setMode('register')"
>
注册
</button>
</div>
<form class="form" @submit.prevent="submit">
<label class="field">
<span class="lbl">账号</span>
<input
v-model="phone"
class="inp"
type="text"
maxlength="32"
autocomplete="username"
placeholder="手机号或任意账号"
/>
</label>
<label class="field">
<span class="lbl">密码</span>
<input
v-model="password"
class="inp"
type="password"
autocomplete="current-password"
placeholder="输入密码"
/>
</label>
<label v-if="mode === 'register'" class="field">
<span class="lbl">昵称 <em>可选</em></span>
<input
v-model="nickname"
class="inp"
type="text"
maxlength="32"
placeholder="怎么称呼你"
/>
</label>
<p v-if="error" class="err" role="alert">{{ error }}</p>
<button class="cta" type="submit" :disabled="busy || !canSubmit">
<span v-if="busy" class="dot" aria-hidden="true" />
{{ busy ? '正在进入…' : mode === 'login' ? '进入愈心谷' : '创建账号' }}
</button>
</form>
<p class="foot">继续即表示你同意将档案与探索结果保存在本账号下</p>
</div>
</PageShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import BrandLogo from '../components/BrandLogo.vue'
import PageShell from '../components/PageShell.vue'
import { setAuthToken } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const mode = ref<'login' | 'register'>('login')
const phone = ref('')
const password = ref('')
const nickname = ref('')
const error = ref('')
const busy = ref(false)
const canSubmit = computed(() => phone.value.trim().length > 0)
function setMode(next: 'login' | 'register') {
mode.value = next
error.value = ''
}
async function submit() {
if (!canSubmit.value || busy.value) return
error.value = ''
busy.value = true
try {
const body = { phone: phone.value.trim(), password: password.value }
const res =
mode.value === 'login'
? await api.authLogin(body)
: await api.authRegister({ ...body, nickname: nickname.value.trim() || undefined })
setAuthToken(res.token)
const redirect = String(route.query.redirect || '/mine')
await router.replace(redirect)
} catch (e) {
error.value = e instanceof Error ? e.message : '失败,请重试'
} finally {
busy.value = false
}
}
</script>
<style scoped>
.hero {
padding: var(--spacing-2xs) 0 var(--spacing-sm);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2xs);
margin: var(--spacing-xs) 0 var(--spacing-md);
}
.logo {
width: 72px !important;
filter: drop-shadow(0 8px 18px rgba(229, 77, 66, 0.18));
}
.brand-name {
font-family: var(--font-display);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.28em;
color: var(--color-text-primary);
}
.title {
margin: 0;
text-align: center;
font-family: var(--font-display);
font-size: 26px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: 0.04em;
}
.sub {
margin: var(--spacing-xs) auto 0;
max-width: 16em;
text-align: center;
font-size: 13px;
line-height: 1.55;
color: rgba(0, 0, 0, 0.42);
}
.panel {
margin-top: var(--spacing-sm);
padding: var(--spacing-md);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-hero);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.modes {
display: flex;
gap: 0;
padding: 4px;
margin-bottom: var(--spacing-md);
border-radius: var(--radius-pill);
background: #fff4f0;
}
.mode {
flex: 1;
border: 0;
background: transparent;
padding: 10px 12px;
border-radius: var(--radius-pill);
font-size: 14px;
font-weight: 650;
color: #9a8f8b;
}
.mode.on {
background: #fff;
color: var(--color-primary);
box-shadow: 0 2px 10px rgba(229, 77, 66, 0.12);
}
.form {
display: grid;
gap: var(--spacing-sm);
}
.field {
display: grid;
gap: 6px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: #8a807c;
letter-spacing: 0.04em;
}
.lbl em {
font-style: normal;
font-weight: 500;
color: var(--color-text-tertiary);
}
.inp {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #f0e6e2;
border-radius: var(--radius-md);
padding: 13px 14px;
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-input-bg);
outline: none;
transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease;
}
.inp:focus {
border-color: #f0b8b0;
box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
}
.inp::placeholder {
color: #c4bbb6;
}
.cta {
margin-top: var(--spacing-xs);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
border: 0;
border-radius: var(--radius-pill);
padding: 14px 18px;
font-size: 15px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff7a6e, var(--color-primary));
box-shadow: 0 10px 22px rgba(229, 77, 66, 0.28);
}
.cta:disabled {
opacity: 0.5;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
animation: pulse 0.9s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
.err {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--color-primary-soft);
color: var(--color-primary);
font-size: 13px;
line-height: 1.45;
}
.foot {
margin: var(--spacing-md) 0 0;
text-align: center;
font-size: 11px;
line-height: 1.5;
color: var(--color-text-tertiary);
}
.reveal {
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+11
View File
@@ -27,6 +27,10 @@
<p class="lead">开通后可查看画像与关系理解的完整分析并获得更多 AI 问答次数</p>
</div>
<p class="sec-title">问答额度包</p>
<AskQuotaBuy lead="免费次数不够用时,可单独加购问答次数(不含深度报告)" @purchased="load" />
<p class="sec-title">成长会员</p>
<div class="plans">
<button
v-for="p in plans"
@@ -53,6 +57,7 @@
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
@@ -148,6 +153,12 @@ onMounted(load)
.ok-title { font-size: 16px; font-weight: 700; color: #222; }
.meta { font-size: 12px; color: #888; margin-top: 4px; }
.lead { font-size: 14px; color: #555; line-height: 1.6; }
.sec-title {
font-size: 13px;
font-weight: 650;
color: #6a615c;
margin: 4px 0 10px;
}
.plans { display: flex; flex-direction: column; gap: 10px; }
.plan {
position: relative;
+34 -4
View File
@@ -6,10 +6,11 @@
<BrandLogo size="card" class="av-logo" />
</span>
<div class="who">
<p class="name">愈心谷用户</p>
<p class="name">{{ accountLabel }}</p>
<p class="meta">档案 {{ profileCount }} </p>
</div>
<router-link class="edit" to="/profile">编辑 </router-link>
<router-link v-if="!loggedIn" class="edit" to="/login">登录 </router-link>
<button v-else type="button" class="edit linkish" @click="logout">退出</button>
</div>
</template>
@@ -70,12 +71,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { MembershipMe } from '@yuxingu/types'
import { useRouter } from 'vue-router'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BrandLogo from '../components/BrandLogo.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { clearAuthToken } from '../lib/authSession'
const router = useRouter()
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
@@ -98,6 +103,10 @@ const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const planLabel = computed(() => {
const p = membership.value?.plan
@@ -111,16 +120,31 @@ async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
me.value = null
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '请先登录'
} finally {
loading.value = false
}
}
async function logout() {
try {
await api.authLogout()
} catch {
/* ignore */
}
clearAuthToken()
me.value = null
await router.push('/login')
}
onMounted(load)
</script>
@@ -164,6 +188,12 @@ onMounted(load)
color: #bbb;
flex-shrink: 0;
}
.linkish {
border: 0;
background: none;
padding: 0;
cursor: pointer;
}
.assets {
display: grid;
grid-template-columns: repeat(4, 1fr);