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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user