feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点

落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
@@ -1,6 +1,7 @@
import type { Ref } from 'vue'
import type { Router } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import { copyText } from '../lib/shareLink'
@@ -180,6 +181,8 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
try {
const birth = joinBirth(r.formY.value, r.formM.value, r.formD.value)
if (!birth) throw new Error('请填写生日')
const nameCheck = validateUserText('display_name', r.formName.value)
if (!nameCheck.ok) throw new Error(nameCheck.message)
const body: {
display_name: string
birth_date: string
@@ -187,7 +190,7 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
birth_place?: string
geo_visible?: boolean
} = {
display_name: r.formName.value.trim(),
display_name: nameCheck.value,
birth_date: birth,
birth_place: r.formPlace.value || '',
}
@@ -224,10 +227,13 @@ export function createProfilePageActions(r: ProfilePageActionRefs) {
try {
const birth = joinBirth(r.newY.value, r.newM.value, r.newD.value)
if (!birth) throw new Error('请填写生日')
const nameRaw = r.newName.value.trim() || 'TA'
const nameCheck = validateUserText('display_name', nameRaw)
if (!nameCheck.ok) throw new Error(nameCheck.message)
await api.createProfile({
relation: 'other',
birth_date: birth,
display_name: r.newName.value.trim() || 'TA',
display_name: nameCheck.value,
relation_type: r.newRelationType.value,
birth_place: r.newPlace.value || undefined,
})
@@ -94,7 +94,7 @@ export function createSynastryPageActions(r: SynastryPageActionRefs) {
try {
const res = await api.createSynastryInvite(r.profileA.value)
r.invitePath.value = res.path
track(AnalyticsEvent.SynastryInviteCreated, { token: res.token })
track(AnalyticsEvent.SynastryInviteCreated)
} catch (e) {
r.error.value = e instanceof Error ? e.message : '邀请失败'
} finally {
+107 -12
View File
@@ -1,8 +1,10 @@
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { validateUserText } from '@yuxingu/utils'
import { api } from '../api/client'
import { ensureAccount, pickableArchiveList } from '../lib/authSession'
import { useAccountNickname } from '../lib/accountNickname'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
@@ -19,15 +21,45 @@ export const askQuickTools = [
]
export const askAdvisors = [
{ name: '心语', mark: '心', bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)', meta: '好评率 98% · 情绪整理', tags: '文字沟通', scene: 'emotion' as const },
{ name: '明朗', mark: '明', bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)', meta: '好评率 97% · 关系理解', tags: '文字沟通', scene: 'relation' as const },
{ name: '志远', mark: '志', bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)', meta: '好评率 96% · 职业探索', tags: '文字沟通', scene: 'career' as const },
{
key: 'xinyu',
name: '心语',
mark: '心',
bg: 'linear-gradient(145deg,#ffd0c8,#ffb0a4)',
meta: '好评率 98% · 情绪整理',
tags: '文字沟通',
scene: 'emotion' as const,
},
{
key: 'minglang',
name: '明朗',
mark: '明',
bg: 'linear-gradient(145deg,#d6e8ff,#b0d0f5)',
meta: '好评率 97% · 关系理解',
tags: '文字沟通',
scene: 'relation' as const,
},
{
key: 'zhiyuan',
name: '志远',
mark: '志',
bg: 'linear-gradient(145deg,#ffe6c8,#f5c98a)',
meta: '好评率 96% · 职业探索',
tags: '文字沟通',
scene: 'career' as const,
},
]
export function findAskAdvisor(key: string | undefined | null) {
if (!key) return null
return askAdvisors.find((a) => a.key === key) || null
}
export function useAskPage() {
const router = useRouter()
const route = useRoute()
const rail = ref<'ai' | 'advisor'>('ai')
const { selfLabel } = useAccountNickname()
const bootLoading = ref(true)
const bootError = ref('')
@@ -42,6 +74,10 @@ export function useAskPage() {
const quotaExhausted = ref(false)
const quota = ref<AskQuota | null>(null)
const activeAdvisor = computed(() =>
findAskAdvisor(typeof route.query.advisor === 'string' ? route.query.advisor : ''),
)
async function boot() {
bootLoading.value = true
bootError.value = ''
@@ -55,8 +91,11 @@ export function useAskPage() {
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
threadId.value = ''
messages.value = []
if (!activeAdvisor.value) {
threadId.value = ''
messages.value = []
}
syncAdvisorFromRoute()
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -64,6 +103,20 @@ export function useAskPage() {
}
}
function syncAdvisorFromRoute() {
const a = activeAdvisor.value
if (!a) return
rail.value = 'advisor'
const nextScene = `advisor:${a.key}`
if (scene.value !== nextScene) {
scene.value = nextScene
threadId.value = ''
messages.value = []
const s = askScenes.find((x) => x.key === a.scene)
draft.value = s?.prompt || ''
}
}
function onProfileChange() {
threadId.value = ''
messages.value = []
@@ -77,10 +130,36 @@ export function useAskPage() {
messages.value = []
}
function askAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'ai'
const s = askScenes.find((x) => x.key === a.scene) || askScenes[0]
pickScene(s)
function openAdvisor(a: (typeof askAdvisors)[number]) {
rail.value = 'advisor'
scene.value = `advisor:${a.key}`
const s = askScenes.find((x) => x.key === a.scene)
draft.value = s?.prompt || ''
threadId.value = ''
messages.value = []
sendError.value = ''
void router.push({ path: '/ask', query: { advisor: a.key } })
}
function closeAdvisor() {
threadId.value = ''
messages.value = []
draft.value = ''
sendError.value = ''
scene.value = 'self'
void router.replace({ path: '/ask', query: {} })
rail.value = 'advisor'
}
function setRail(v: 'ai' | 'advisor') {
rail.value = v
if (v === 'ai' && route.query.advisor) {
threadId.value = ''
messages.value = []
draft.value = ''
scene.value = 'self'
void router.replace({ path: '/ask', query: {} })
}
}
async function ensureThread(): Promise<string> {
@@ -92,7 +171,12 @@ export function useAskPage() {
}
async function send() {
const content = draft.value.trim()
const checked = validateUserText('ask_content', draft.value)
if (!checked.ok) {
sendError.value = checked.message
return
}
const content = checked.value
if (!content || sending.value) return
sending.value = true
sendError.value = ''
@@ -181,13 +265,23 @@ export function useAskPage() {
onMounted(boot)
watch(
() => route.query.advisor,
() => {
syncAdvisorFromRoute()
},
)
return {
router,
rail,
setRail,
activeAdvisor,
bootLoading,
bootError,
profiles,
profileId,
selfLabel,
messages,
draft,
sending,
@@ -196,7 +290,8 @@ export function useAskPage() {
quota,
onProfileChange,
pickScene,
askAdvisor,
openAdvisor,
closeAdvisor,
send,
boot,
refreshQuota,
+112 -11
View File
@@ -1,4 +1,4 @@
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { HomeDailyTips as ApiTips } from '@yuxingu/types'
import { api } from '../api/client'
@@ -13,11 +13,26 @@ export type HomeDailyTips = {
needBirth?: boolean
guaName?: string
source?: string
asOf?: string
shichen?: number
shichenName?: string
validUntil?: string
}
function localDayKey(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** Local 十二时辰 index (子时 23:00 起). */
function localShichenIndex(d = new Date()) {
return ((d.getHours() + 1) % 24) >> 1
}
function localFallback(): HomeDailyTips {
const n = new Date()
const seed = n.getFullYear() * 10000 + (n.getMonth() + 1) * 100 + n.getDate() + n.getHours()
const day = Math.floor(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate()) / 86400000)
const shi = localShichenIndex(n)
const seed = day * 13 + shi * 19
const palettes: ColorChip[][] = [
[
{ name: '米白', hex: '#F5F0E8' },
@@ -31,19 +46,50 @@ function localFallback(): HomeDailyTips {
{ name: '浅灰', hex: '#D8D6D4' },
{ name: '雾粉', hex: '#E8B8C4' },
],
[
{ name: '天青', hex: '#9BB8D4' },
{ name: '陶土', hex: '#C4A484' },
],
]
const clothing = [
'轻薄透气更舒服,外套可备一件薄衫应付温差。',
'今日宜层搭:薄内搭+透气外衫,方便随时增减。',
'选柔软面料贴身,活动一整天也不易紧绷。',
'宽松剪裁更自在,适合慢节奏出门与散步。',
]
const notes = ['清爽干净,不抢戏。', '今日偏清透,层次更轻。', '温柔耐看,适合日常。', '柔和提气色,不显沉。']
const well = [
'午后泡一杯温茶,给身心一点缓冲。',
'今日做三次深呼吸,拉长呼气更易放松。',
'今晚早点放下屏幕,让眼睛歇一会儿。',
'走路时把肩膀放松,呼吸会顺很多。',
]
const i = seed % palettes.length
const end = nextShichenEnd(n)
return {
clothingIndex: 62 + (seed % 31),
clothing: '轻薄透气更舒服,外套可备一件薄衫应付温差。',
colorNote: '清爽干净,不抢戏。',
clothingIndex: 55 + (seed % 41),
clothing: clothing[i],
colorNote: notes[i],
palette: palettes[i],
wellness: '午后泡一杯温茶,给身心一点缓冲。',
wellness: well[i],
source: 'fallback',
needBirth: true,
asOf: localDayKey(n),
shichen: shi,
validUntil: end.toISOString(),
}
}
function nextShichenEnd(d = new Date()) {
const shi = localShichenIndex(d)
const startHour = (shi * 2 + 23) % 24
const start = new Date(d)
start.setHours(startHour, 0, 0, 0)
if (start.getTime() > d.getTime()) start.setDate(start.getDate() - 1)
const end = new Date(start.getTime() + 2 * 3600 * 1000)
return end
}
function fromApi(t: ApiTips): HomeDailyTips {
return {
clothingIndex: t.clothing_index,
@@ -54,23 +100,78 @@ function fromApi(t: ApiTips): HomeDailyTips {
needBirth: !!t.need_birth,
guaName: t.gua_name,
source: t.source,
asOf: t.as_of || localDayKey(),
shichen: t.shichen,
shichenName: t.shichen_name,
validUntil: t.valid_until,
}
}
/** Load daily tips from API (LLM + 易经种子); local fallback on failure. */
/** Load tips from API; silent refresh at each 时辰 boundary. */
export function useHomeDailyTips() {
const tips = ref<HomeDailyTips>(localFallback())
const loading = ref(true)
let timer: ReturnType<typeof setTimeout> | null = null
let upgradeTimer: ReturnType<typeof setTimeout> | null = null
let loadedShichen = tips.value.shichen ?? localShichenIndex()
onMounted(async () => {
loading.value = true
function clearTimers() {
if (timer) clearTimeout(timer)
if (upgradeTimer) clearTimeout(upgradeTimer)
timer = null
upgradeTimer = null
}
function schedule(validUntil?: string) {
if (timer) clearTimeout(timer)
const end = validUntil ? new Date(validUntil).getTime() : nextShichenEnd().getTime()
const ms = Math.max(1000, end - Date.now() + 400)
timer = setTimeout(() => {
void load({ silent: true })
}, ms)
}
async function load(opts?: { silent?: boolean }) {
const silent = !!opts?.silent
if (!silent) loading.value = true
try {
tips.value = fromApi(await api.getHomeDailyTips())
const next = fromApi(await api.getHomeDailyTips())
tips.value = next
loadedShichen = next.shichen ?? localShichenIndex()
schedule(next.validUntil)
if (!next.needBirth && next.source === 'fallback') {
if (upgradeTimer) clearTimeout(upgradeTimer)
upgradeTimer = setTimeout(() => {
void load({ silent: true })
}, 2800)
}
} catch {
/* keep local fallback */
if (!silent) {
tips.value = localFallback()
loadedShichen = localShichenIndex()
schedule(tips.value.validUntil)
}
} finally {
loading.value = false
}
}
function onVisibility() {
if (document.visibilityState !== 'visible') return
if (localShichenIndex() !== loadedShichen) {
void load({ silent: true })
return
}
schedule(tips.value.validUntil)
}
onMounted(() => {
void load({ silent: true })
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearTimers()
document.removeEventListener('visibilitychange', onVisibility)
})
return { tips, loading }
+10 -12
View File
@@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref, toRefs } from 'vue'
import { onMounted, reactive, ref, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
@@ -6,11 +6,11 @@ import {
homeFeeds,
homeGridRow1,
homeGridRow2,
homeSearchHints,
type HomeTool,
} from '../lib/homeCatalog'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { useHomeDailyTips } from './useHomeDailyTips'
import { setAccountNickname } from '../lib/accountNickname'
const ICONS = new Set([
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
@@ -56,7 +56,7 @@ function mapTools(
export function useHomePage() {
const router = useRouter()
const profileLabel = ref('访客')
const plusOpen = ref(false)
const avatarUrl = ref('')
const { tips, loading: tipsLoading } = useHomeDailyTips()
const grid = reactive({
gridRow1: [...homeGridRow1] as HomeTool[],
@@ -67,10 +67,15 @@ export function useHomePage() {
void api
.authMe()
.then((me) => {
if (me.nickname) profileLabel.value = me.nickname
if (me.nickname) {
profileLabel.value = me.nickname
setAccountNickname(me.nickname)
}
avatarUrl.value = me.avatar_url || ''
})
.catch(() => {
profileLabel.value = '访客'
avatarUrl.value = ''
})
void api
.getHomeTools()
@@ -86,13 +91,7 @@ export function useHomePage() {
})
})
const searchHint = computed(() => {
const i = new Date().getHours() % homeSearchHints.length
return homeSearchHints[i]
})
function goPlus(kind: 'inviteFill' | 'add' | 'synastry') {
plusOpen.value = false
track(AnalyticsEvent.HomeCtaPortrait, { source: 'home_plus', kind })
if (kind === 'inviteFill') {
router.push({ path: '/profile', query: { inviteFill: '1' } })
@@ -112,10 +111,9 @@ export function useHomePage() {
return {
router,
profileLabel,
plusOpen,
avatarUrl,
tips,
tipsLoading,
searchHint,
...toRefs(grid),
feeds: homeFeeds,
goPlus,
@@ -1,4 +1,4 @@
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
@@ -6,6 +6,7 @@ import { api } from '../api/client'
import type { WuxingBar } from '../components/WuxingBars.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
export const resultTabs = [
{ key: 'overview' as const, label: '概览' },
@@ -26,6 +27,8 @@ export function useLifeRhythmPage() {
const year = ref(String(route.query.y || ''))
const month = ref(String(route.query.m || ''))
const day = ref(String(route.query.d || ''))
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -63,6 +66,47 @@ export function useLifeRhythmPage() {
.filter((b) => b.el)
})
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
if (!report.value) return
const s = (report.value.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
if (!report.value) return
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
} else {
const cached = await loadSelfLatest('rhythm')
if (cached.report) report.value = cached.report
}
scheduleDayRefresh()
} catch {
/* keep current */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
async function generate(y: number, m: number, d: number) {
loading.value = true
error.value = ''
@@ -72,7 +116,8 @@ export function useLifeRhythmPage() {
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
scheduleDayRefresh()
track(AnalyticsEvent.RhythmCompleted)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -102,6 +147,7 @@ export function useLifeRhythmPage() {
try {
report.value = await api.getReport(reportId)
tab.value = 'overview'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -117,12 +163,14 @@ export function useLifeRhythmPage() {
report.value = cached.report
needBirth.value = false
tab.value = 'overview'
scheduleDayRefresh()
return
}
if (cached.profile) {
report.value = await api.createRhythm(cached.profile.id)
needBirth.value = false
tab.value = 'overview'
scheduleDayRefresh()
return
}
const y = Number(route.query.y)
@@ -151,6 +199,7 @@ export function useLifeRhythmPage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -159,7 +208,14 @@ export function useLifeRhythmPage() {
}
}
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
+143
View File
@@ -0,0 +1,143 @@
import { computed, onMounted, ref } from 'vue'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import { apiAssetURL } from '../lib/apiAsset'
import { AnalyticsEvent, track } from '../lib/analytics'
import { clearAuthToken } from '../lib/authSession'
import { DEFAULT_AVATAR_SRC } from '../lib/defaultAvatar'
import { mineGroupArchive, mineGroupGrowth } from '../lib/mineCatalog'
function avatarFailReason(msg: string): string {
const m = msg.toLowerCase()
if (m.includes('2mb') || m.includes('过大') || m.includes('too large') || m.includes('size')) {
return 'too_large'
}
if (m.includes('type') || m.includes('格式') || m.includes('jpeg') || m.includes('png') || m.includes('webp')) {
return 'bad_type'
}
if (m.includes('network') || m.includes('fetch') || m.includes('网络')) return 'network'
if (m.includes('401') || m.includes('403') || m.includes('500') || m.includes('服务')) return 'server'
return 'unknown'
}
export function useMinePage() {
const loading = ref(true)
const error = ref('')
const membership = ref<MembershipMe | null>(null)
const profileCount = ref(0)
const me = ref<AuthUser | null>(null)
const sheetOpen = ref(false)
const avatarBusy = ref(false)
const avatarErr = ref('')
const avatarSource = ref<'album' | 'camera'>('album')
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const avatarSrc = computed(() => apiAssetURL(me.value?.avatar_url) || DEFAULT_AVATAR_SRC)
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '会员中'
})
function openAvatarSheet() {
if (!loggedIn.value) return
avatarErr.value = ''
sheetOpen.value = true
track(AnalyticsEvent.AvatarSheetOpened, { surface: 'mine' })
}
function onAvatarPickSource(source: 'album' | 'camera') {
avatarSource.value = source
}
async function onFilePicked(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const source = avatarSource.value
sheetOpen.value = false
avatarBusy.value = true
avatarErr.value = ''
try {
me.value = await api.authUploadAvatar(file, file.name || 'avatar.jpg')
track(AnalyticsEvent.AvatarUploadSucceeded, { source })
} catch (e) {
const msg = e instanceof Error ? e.message : '上传失败'
avatarErr.value = msg
sheetOpen.value = true
track(AnalyticsEvent.AvatarUploadFailed, {
source,
reason: avatarFailReason(msg),
})
} finally {
avatarBusy.value = false
}
}
async function load() {
loading.value = true
error.value = ''
try {
me.value = await api.authMe()
} catch {
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([api.getMembership(), api.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
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
membership.value = null
profileCount.value = 0
track(AnalyticsEvent.AuthLogout, { surface: 'mine' })
await load()
}
onMounted(load)
return {
groupArchive: mineGroupArchive,
groupGrowth: mineGroupGrowth,
loading,
error,
membership,
profileCount,
sheetOpen,
avatarBusy,
avatarErr,
loggedIn,
accountLabel,
avatarSrc,
planLabel,
openAvatarSheet,
onAvatarPickSource,
onFilePicked,
load,
logout,
}
}
@@ -3,6 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import type { Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { setAccountNickname } from '../lib/accountNickname'
import {
avatarInitial,
createProfilePageActions,
@@ -106,6 +107,7 @@ export function useProfilePage() {
function onNicknameUpdated(n: string) {
accountNickname.value = n
setAccountNickname(n)
}
onMounted(async () => {
@@ -113,6 +115,7 @@ export function useProfilePage() {
try {
const me = await api.authMe()
accountNickname.value = me.nickname || ''
setAccountNickname(accountNickname.value)
} catch {
accountNickname.value = ''
}
+54 -2
View File
@@ -1,8 +1,9 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import {
@@ -43,6 +44,8 @@ export function useReportPage() {
const panelKey = ref('chart')
const signTab = ref('sun')
const fortuneKey = ref<(typeof reportFortuneKeys)[number]>('daily')
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -89,10 +92,50 @@ export function useReportPage() {
buildReportSharePayload(report.value, summary.value, headline.value, oneLiner.value, keywords.value),
)
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
const typ = report.value?.type
if (typ !== 'star' && typ !== 'rhythm') return
const s = (report.value?.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
const id = String(route.params.id || '')
if (!id) return
try {
report.value = await api.getReport(id)
scheduleDayRefresh()
} catch {
/* keep */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
const typ = report.value.type
if (typ !== 'star' && typ !== 'rhythm') return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
async function load() {
loading.value = true
error.value = ''
report.value = null
clearDayTimer()
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
@@ -104,6 +147,7 @@ export function useReportPage() {
panelKey.value = report.value.type === 'synastry' ? 'compare' : 'chart'
signTab.value = 'sun'
fortuneKey.value = 'daily'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -119,6 +163,7 @@ export function useReportPage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -128,7 +173,14 @@ export function useReportPage() {
}
watch(() => route.params.id, load)
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
+118 -12
View File
@@ -4,20 +4,39 @@ import { api } from '../api/client'
import { ensureAccount, findSelfProfile } from '../lib/authSession'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
import { AnalyticsEvent, track } from '../lib/analytics'
function scaleAccessLabel(raw: string): string {
if (raw === 'membership' || raw === 'member') return 'member'
return 'free'
}
export function useScalePage() {
const route = useRoute()
const router = useRouter()
const slug = String(route.params.slug || '')
const slug = computed(() => String(route.params.slug || ''))
const title = ref('')
const description = ref('')
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
const questions = ref<
{
id: string
body: {
prompt: string
format?: string
left?: string
right?: string
options: { key: string; text: string }[]
}
}[]
>([])
const answers = reactive<Record<string, string>>({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const needProfile = ref(false)
const locked = ref(false)
const access = ref('free')
const phase = ref<'intro' | 'answering' | 'result'>('intro')
const resultLabel = ref('')
const shareLine = ref('')
@@ -39,7 +58,9 @@ export function useScalePage() {
answers,
() => {
if (!persistReady.value || phase.value === 'result') return
saveScaleDraft(slug, { ...answers })
const s = slug.value
if (!s) return
saveScaleDraft(s, { ...answers })
},
{ deep: true },
)
@@ -76,14 +97,60 @@ export function useScalePage() {
}
})
function applyResult(out: { result: Record<string, unknown> }) {
resultRaw.value = out.result
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
phase.value = 'result'
}
function clearAnswers() {
for (const k of Object.keys(answers)) delete answers[k]
}
function resetViewState() {
persistReady.value = false
draftRestored.value = false
resultRaw.value = null
resultLabel.value = ''
shareLine.value = ''
submitError.value = ''
needProfile.value = false
locked.value = false
currentIdx.value = 0
clearAnswers()
phase.value = 'intro'
}
function firstUnansweredIdx(): number {
const idx = questions.value.findIndex((q) => !answers[q.id])
return idx >= 0 ? idx : 0
}
function startTest() {
if (locked.value) return
phase.value = 'answering'
currentIdx.value = draftRestored.value ? firstUnansweredIdx() : 0
track(AnalyticsEvent.ScaleStarted, { slug: slug.value, access: scaleAccessLabel(access.value) })
}
function startFull() {
router.push('/scales/mbti-full')
}
function retake() {
const s = slug.value
clearScaleDraft(s)
clearAnswers()
draftRestored.value = false
resultRaw.value = null
resultLabel.value = ''
shareLine.value = ''
submitError.value = ''
needProfile.value = false
currentIdx.value = 0
phase.value = 'intro'
track(AnalyticsEvent.ScaleRetakeClicked, { slug: s })
}
function prevQ() {
@@ -99,22 +166,52 @@ export function useScalePage() {
}
async function load() {
const s = slug.value
loadingScale.value = true
loadError.value = ''
needProfile.value = false
resetViewState()
if (!s) {
loadError.value = '无效量表'
loadingScale.value = false
return
}
if (!(await ensureAccount(router, route.fullPath))) {
loadingScale.value = false
return
}
try {
const d = await api.getScale(slug)
const d = await api.getScale(s)
title.value = d.title
description.value = d.description
questions.value = d.questions.map((q) => ({
access.value = d.access || 'free'
locked.value = !!d.locked
questions.value = (d.questions || []).map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body as unknown as string) : q.body,
}))
const draft = loadScaleDraft(slug)
if (locked.value) {
phase.value = 'intro'
persistReady.value = true
track(AnalyticsEvent.ScaleLockedViewed, { slug: s })
return
}
try {
const latest = await api.getScaleResult(s)
clearScaleDraft(s)
clearAnswers()
draftRestored.value = false
applyResult(latest)
persistReady.value = true
track(AnalyticsEvent.ScaleResultReopened, { slug: s })
return
} catch {
/* no prior result · continue intro / draft */
}
const draft = loadScaleDraft(s)
if (draft) {
const ids = new Set(questions.value.map((q) => q.id))
let n = 0
@@ -136,9 +233,11 @@ export function useScalePage() {
function openShare() {
shareOpen.value = true
track(AnalyticsEvent.ScaleShareClicked, { slug: slug.value })
}
async function submit() {
const s = slug.value
submitting.value = true
submitError.value = ''
needProfile.value = false
@@ -151,13 +250,12 @@ export function useScalePage() {
needProfile.value = true
throw new Error('请先在首页完成性格探索,创建个人档案')
}
const out = await api.submitScale(slug, self.id, { ...answers })
clearScaleDraft(slug)
const out = await api.submitScale(s, self.id, { ...answers })
clearScaleDraft(s)
draftRestored.value = false
phase.value = 'result'
resultRaw.value = out.result as Record<string, unknown>
resultLabel.value = String(out.result.label || '探索结果')
shareLine.value = String(out.result.share_line || '')
clearAnswers()
applyResult(out)
track(AnalyticsEvent.ScaleCompleted, { slug: s, access: scaleAccessLabel(access.value) })
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
@@ -170,8 +268,12 @@ export function useScalePage() {
}
onMounted(load)
watch(slug, (next, prev) => {
if (next && next !== prev) void load()
})
return {
slug,
title,
description,
questions,
@@ -181,6 +283,8 @@ export function useScalePage() {
loadError,
submitError,
needProfile,
locked,
access,
phase,
resultLabel,
shareLine,
@@ -196,6 +300,8 @@ export function useScalePage() {
sharePayload,
load,
startTest,
startFull,
retake,
prevQ,
onNext,
openShare,
@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { validateBirth } from '@yuxingu/utils'
@@ -6,6 +6,7 @@ import { api } from '../api/client'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount, loadSelfLatest, ensureSelfProfile } from '../lib/authSession'
import { localDayKey, scheduleAtValidUntil } from '../lib/scheduleRefresh'
import {
ascLonFromSummary,
fortuneBundleFrom,
@@ -49,6 +50,8 @@ export function useStarProfilePage() {
const settings = loadWheelSettings()
const showOuter = ref(settings.showOuter)
const tightOrb = ref(settings.tightOrb)
let dayTimer: ReturnType<typeof setTimeout> | null = null
let loadedDay = localDayKey()
watch([showOuter, tightOrb], () => {
saveWheelSettings(showOuter.value, tightOrb.value)
@@ -85,6 +88,47 @@ export function useStarProfilePage() {
return { type: 'portrait', title: headline.value || '我的星座', line: oneLiner.value, keywords: keywords.value }
})
function clearDayTimer() {
if (dayTimer) clearTimeout(dayTimer)
dayTimer = null
}
function scheduleDayRefresh() {
clearDayTimer()
if (!report.value) return
const s = (report.value.summary || {}) as Record<string, unknown>
loadedDay = String(s.as_of || localDayKey())
const until = s.valid_until ? String(s.valid_until) : undefined
dayTimer = scheduleAtValidUntil(until, () => {
void silentRefresh()
})
}
async function silentRefresh() {
if (!report.value) return
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
} else {
const cached = await loadSelfLatest('star')
if (cached.report) report.value = cached.report
}
scheduleDayRefresh()
} catch {
/* keep current */
}
}
function onVisibility() {
if (document.visibilityState !== 'visible' || !report.value) return
if (localDayKey() !== loadedDay) {
void silentRefresh()
return
}
scheduleDayRefresh()
}
function onWheelSelect(key: string) {
if (['sun', 'moon', 'rise'].includes(key)) signTab.value = key
track(AnalyticsEvent.StarWheelViewed, { planet: key })
@@ -102,6 +146,7 @@ export function useStarProfilePage() {
function resetForm() {
needBirth.value = true
report.value = null
clearDayTimer()
}
async function generate(y: number, m: number, d: number) {
@@ -119,7 +164,8 @@ export function useStarProfilePage() {
})
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
scheduleDayRefresh()
track(AnalyticsEvent.StarCompleted)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -149,6 +195,7 @@ export function useStarProfilePage() {
try {
report.value = await api.getReport(reportId)
view.value = 'overview'
scheduleDayRefresh()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
needBirth.value = true
@@ -164,12 +211,14 @@ export function useStarProfilePage() {
report.value = cached.report
needBirth.value = false
view.value = 'overview'
scheduleDayRefresh()
return
}
if (cached.profile) {
report.value = await api.createStar(cached.profile.id)
needBirth.value = false
view.value = 'overview'
scheduleDayRefresh()
return
}
const y = Number(route.query.y)
@@ -198,6 +247,7 @@ export function useStarProfilePage() {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
scheduleDayRefresh()
track(AnalyticsEvent.PurchaseCompleted, { kind: 'deep_access' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
@@ -206,7 +256,14 @@ export function useStarProfilePage() {
}
}
onMounted(load)
onMounted(() => {
void load()
document.addEventListener('visibilitychange', onVisibility)
})
onUnmounted(() => {
clearDayTimer()
document.removeEventListener('visibilitychange', onVisibility)
})
return {
loading,
@@ -19,6 +19,7 @@ import {
type SynastryMainTab,
} from '../lib/synastryChart'
import { ensureAccount } from '../lib/authSession'
import { useAccountNickname } from '../lib/accountNickname'
import { createSynastryPageActions } from './synastryPageActions'
export type MainTab = SynastryMainTab
@@ -60,11 +61,12 @@ export function useSynastryPage() {
profiles.value.filter((p) => p.relation === 'other' && p.id !== profileA.value),
)
const selfProfiles = computed(() => profiles.value.filter((p) => p.relation === 'self'))
const { selfLabel, selfInitial } = useAccountNickname()
const profileAName = computed(() => {
const p = profiles.value.find((x) => x.id === profileA.value)
if (p?.relation === 'self') return selfLabel.value
return p?.display_name || ''
})
const selfInitial = computed(() => (profileAName.value || '我').slice(0, 1))
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
@@ -183,6 +185,7 @@ export function useSynastryPage() {
pickableProfiles,
selfProfiles,
profileAName,
selfLabel,
selfInitial,
profileInitial,
headline,