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
+75 -6
View File
@@ -91,21 +91,88 @@ export function useAskPage() {
sending.value = true
sendError.value = ''
quotaExhausted.value = false
draft.value = ''
const streamId = `stream-${Date.now()}`
let gotMeta = false
try {
const tid = await ensureThread()
const out = await api.sendAskMessage(tid, content)
messages.value = [...messages.value, out.user_message, out.assistant_message]
quota.value = out.quota
draft.value = ''
await api.sendAskMessageStream(tid, content, {
onMeta: (userMsg) => {
gotMeta = true
messages.value = [
...messages.value,
userMsg,
{
id: streamId,
thread_id: tid,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
},
]
},
onDelta: (text) => {
const list = messages.value
const last = list[list.length - 1]
if (!last || last.id !== streamId) return
messages.value = [...list.slice(0, -1), { ...last, content: last.content + text }]
},
onDone: (payload) => {
const base = messages.value.filter(
(m) => m.id !== streamId && m.id !== payload.assistant_message.id,
)
messages.value = [...base, payload.assistant_message]
quota.value = payload.quota
},
onError: (msg) => {
sendError.value = msg
quotaExhausted.value =
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
messages.value = messages.value.filter((m) => m.id !== streamId)
if (!gotMeta) draft.value = content
},
})
} catch (e) {
const msg = e instanceof Error ? e.message : '发送失败'
sendError.value = msg
quotaExhausted.value = msg.includes('次数已用完') || msg.includes('成长会员')
if (!sendError.value) sendError.value = msg
quotaExhausted.value =
msg.includes('次数已用完') || msg.includes('购买额度') || msg.includes('成长会员')
messages.value = messages.value.filter((m) => m.id !== streamId)
if (!gotMeta && !draft.value) draft.value = content
} finally {
sending.value = false
}
}
async function refreshQuota() {
try {
quota.value = await api.getAskQuota()
if (quota.value.remaining > 0) {
quotaExhausted.value = false
sendError.value = ''
}
} catch {
/* ignore; user can retry send */
}
}
async function clearChat() {
if (sending.value) return
if (!messages.value.length && !threadId.value) return
sendError.value = ''
const tid = threadId.value
messages.value = []
threadId.value = ''
draft.value = ''
if (tid) {
try {
await api.clearAskThread(tid)
} catch {
/* local clear still applies */
}
}
}
onMounted(boot)
return {
@@ -126,5 +193,7 @@ export function useAskPage() {
askAdvisor,
send,
boot,
refreshQuota,
clearChat,
}
}
+71 -4
View File
@@ -1,14 +1,82 @@
import { computed, ref } from 'vue'
import { computed, onMounted, reactive, ref, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { homeFeeds, homeGridRow1, homeGridRow2, homeSearchHints } from '../lib/homeCatalog'
import {
homeFeeds,
homeGridRow1,
homeGridRow2,
homeSearchHints,
type HomeTool,
} from '../lib/homeCatalog'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { useHomeMood } from './useHomeMood'
const ICONS = new Set([
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
])
function mapTools(
items: Array<{
row_index: number
sort_order: number
path: string
icon: string
label: string
badge?: string | null
badge_tone?: string | null
}>,
): { row1: HomeTool[]; row2: HomeTool[] } {
const toTool = (it: (typeof items)[0]): HomeTool | null => {
if (!ICONS.has(it.icon)) return null
const t: HomeTool = {
to: it.path,
icon: it.icon as HomeToolIconName,
label: it.label,
}
if (it.badge) t.badge = it.badge
if (it.badge_tone === 'hot' || it.badge_tone === 'new') t.badgeTone = it.badge_tone
return t
}
const sorted = [...items].sort(
(a, b) => a.row_index - b.row_index || a.sort_order - b.sort_order,
)
const row1: HomeTool[] = []
const row2: HomeTool[] = []
for (const it of sorted) {
const t = toTool(it)
if (!t) continue
if (it.row_index === 1) row1.push(t)
else if (it.row_index === 2) row2.push(t)
}
return { row1, row2 }
}
export function useHomePage() {
const router = useRouter()
const profileLabel = ref('自己')
const plusOpen = ref(false)
const mood = useHomeMood()
const grid = reactive({
gridRow1: [...homeGridRow1] as HomeTool[],
gridRow2: [...homeGridRow2] as HomeTool[],
})
onMounted(() => {
void api
.getHomeTools()
.then((res) => {
const mapped = mapTools(res.items || [])
if (mapped.row1.length || mapped.row2.length) {
grid.gridRow1 = mapped.row1
grid.gridRow2 = mapped.row2
}
})
.catch(() => {
/* keep static fallback */
})
})
const searchHint = computed(() => {
const i = new Date().getHours() % homeSearchHints.length
@@ -39,8 +107,7 @@ export function useHomePage() {
plusOpen,
mood,
searchHint,
gridRow1: homeGridRow1,
gridRow2: homeGridRow2,
...toRefs(grid),
feeds: homeFeeds,
goPlus,
trackGrid,
@@ -1,10 +1,11 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
@@ -15,6 +16,7 @@ export const resultTabs = [
export function useLifeRhythmPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -68,7 +70,7 @@ export function useLifeRhythmPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
report.value = await api.getLatestReport(profile.id, 'rhythm')
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
@@ -92,6 +94,7 @@ export function useLifeRhythmPage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -107,14 +110,35 @@ export function useLifeRhythmPage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
loading.value = true
try {
const cached = await loadSelfLatest('rhythm')
if (cached.report) {
report.value = cached.report
needBirth.value = false
tab.value = 'overview'
return
}
if (cached.profile) {
report.value = await api.createRhythm(cached.profile.id)
needBirth.value = false
tab.value = 'overview'
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}
+36 -13
View File
@@ -1,13 +1,15 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import type { PortraitSharePayload } from '../lib/shareLink'
export function usePortraitPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -44,7 +46,7 @@ export function usePortraitPage() {
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createPortrait(profile.id)
report.value = await api.getLatestReport(profile.id, 'portrait')
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -67,6 +69,7 @@ export function usePortraitPage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -83,18 +86,38 @@ export function usePortraitPage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
return
loading.value = true
error.value = ''
try {
const cached = await loadSelfLatest('portrait')
if (cached.report) {
report.value = cached.report
needBirth.value = false
track(AnalyticsEvent.PortraitCompleted, { source: 'cache' })
return
}
if (cached.profile) {
report.value = await api.createPortrait(cached.profile.id)
needBirth.value = false
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
return
}
needBirth.value = true
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
needBirth.value = true
loading.value = false
}
function retry() {
@@ -1,6 +1,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { ensureAccount } from '../lib/authSession'
import {
avatarInitial,
createProfilePageActions,
@@ -102,6 +103,7 @@ export function useProfilePage() {
)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) return
await load()
applyQueryFlags()
})
@@ -1,10 +1,11 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest } from '../lib/authSession'
import {
ascLonFromSummary,
fortuneBundleFrom,
@@ -30,6 +31,7 @@ export { starViewTabs, starFortuneKeys, fortuneLabel }
export function useStarProfilePage() {
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
@@ -116,7 +118,7 @@ export function useStarProfilePage() {
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.createStar(profile.id)
report.value = await api.getLatestReport(profile.id, 'star')
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
@@ -140,6 +142,7 @@ export function useStarProfilePage() {
}
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
const reportId = String(route.query.report_id || '')
if (reportId) {
loading.value = true
@@ -155,14 +158,35 @@ export function useStarProfilePage() {
}
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
loading.value = true
try {
const cached = await loadSelfLatest('star')
if (cached.report) {
report.value = cached.report
needBirth.value = false
view.value = 'overview'
return
}
if (cached.profile) {
report.value = await api.createStar(cached.profile.id)
needBirth.value = false
view.value = 'overview'
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (y && m && d && !validateBirth(y, m, d)) {
year.value = String(y)
month.value = String(m)
day.value = String(d)
await generate(y, m, d)
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
} finally {
loading.value = false
}
}