feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-11 18:54:59 +08:00
co-authored by Cursor
parent 0ee4d4f5ae
commit 5ceb3ce749
85 changed files with 2098 additions and 103 deletions
@@ -14,6 +14,14 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => {
const url = route.request().url()
const method = route.request().method()
if (url.includes('/auth/me')) {
await route.fulfill(ok({ id: 'u1', phone: '13800000000' }))
return
}
if (url.includes('/profiles') && method === 'GET') {
await route.fulfill(ok({ items: [] }))
return
}
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill(ok({
id: 'prof-e2e',
+23 -1
View File
@@ -7,6 +7,28 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
const url = req.url()
const method = req.method()
if (url.includes('/auth/me')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'success',
data: { id: 'u1', phone: '13800000000' },
}),
headers: { 'X-Device-Key': 'e2e-device' },
})
return
}
if (url.includes('/profiles') && method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 0, message: 'success', data: { items: [] } }),
headers: { 'X-Device-Key': 'e2e-device' },
})
return
}
if (url.includes('/profiles') && method === 'POST') {
await route.fulfill({
status: 200,
@@ -64,7 +86,7 @@ test('home birth form opens portrait with summary and deep CTA', async ({ page }
await page.goto('/psy/portrait?y=1990&m=5&d=12')
await expect(page.getByRole('navigation', { name: '主导航' })).toBeVisible()
await expect(page.getByRole('heading', { name: '愈心解码' })).toBeVisible()
await expect(page.getByRole('heading', { name: '愈心解码', level: 1 })).toBeVisible()
await expect(page.getByText('模拟摘要').first()).toBeVisible()
await expect(page.getByRole('link', { name: /在报告页打开/ })).toBeVisible()
await expect(page.getByRole('button', { name: /解锁完整分析/ }).first()).toBeVisible()
+7 -1
View File
@@ -1,7 +1,8 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import type { AskMessage, AskQuota, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
export const askScenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
@@ -25,6 +26,7 @@ export const askAdvisors = [
export function useAskPage() {
const router = useRouter()
const route = useRoute()
const rail = ref<'ai' | 'advisor'>('ai')
const bootLoading = ref(true)
@@ -43,6 +45,10 @@ export function useAskPage() {
async function boot() {
bootLoading.value = true
bootError.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
bootLoading.value = false
return
}
try {
const [list, q] = await Promise.all([api.listProfiles(), api.getAskQuota()])
profiles.value = list.items || []
@@ -1,8 +1,10 @@
import { computed, onMounted, ref } from 'vue'
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 } from '../lib/authSession'
export const promptRows = [
{ icon: '🌊', label: '最近情绪有点乱,帮我理一理', scene: '情绪整理' },
@@ -12,6 +14,8 @@ export const promptRows = [
]
export function useImageCardPage() {
const router = useRouter()
const route = useRoute()
const scenes = ref<{ key: string; label: string }[]>([])
const scene = ref('情绪整理')
const quota = ref<{ remaining: number; daily_free: number; unlimited: boolean } | null>(null)
@@ -70,6 +74,7 @@ export function useImageCardPage() {
}
async function boot() {
if (!(await ensureAccount(router, route.fullPath))) return
try {
const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()])
scenes.value = sc.items || []
@@ -82,6 +87,7 @@ export function useImageCardPage() {
}
async function draw() {
if (!(await ensureAccount(router, route.fullPath))) return
error.value = ''
const y = Number(year.value)
const m = Number(month.value)
@@ -70,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.getLatestReport(profile.id, 'rhythm')
report.value = await api.createRhythm(profile.id)
tab.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'rhythm' })
} catch (e) {
@@ -46,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.getLatestReport(profile.id, 'portrait')
report.value = await api.createPortrait(profile.id)
track(AnalyticsEvent.PortraitCompleted, { source: 'form' })
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
@@ -1,7 +1,9 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport, Profile } from '@yuxingu/types'
import { api } from '../api/client'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
import type { RelationSharePayload } from '../lib/shareLink'
export const focusTabs = [
@@ -24,6 +26,8 @@ export const resultTabs = [
]
export function useRelationPage() {
const router = useRouter()
const route = useRoute()
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('1992')
@@ -97,6 +101,7 @@ export function useRelationPage() {
}
async function run() {
if (!(await ensureAccount(router, route.fullPath))) return
loading.value = true
error.value = ''
needSelf.value = false
@@ -1,7 +1,8 @@
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 { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import type { WheelAspect, WheelPlanet } from '../components/NatalWheel.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import {
@@ -33,6 +34,7 @@ export { reportStarPanels, reportSynTabs, reportFortuneKeys }
export function useReportPage() {
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
@@ -91,6 +93,10 @@ export function useReportPage() {
loading.value = true
error.value = ''
report.value = null
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const id = String(route.params.id || '')
if (!id) throw new Error('缺少报告 id')
+7 -1
View File
@@ -1,11 +1,13 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import { ensureAccount } from '../lib/authSession'
import { clearScaleDraft, loadScaleDraft, saveScaleDraft } from '../lib/scaleDraft'
import type { PortraitSharePayload } from '../lib/shareLink'
export function useScalePage() {
const route = useRoute()
const router = useRouter()
const slug = String(route.params.slug || '')
const title = ref('')
const description = ref('')
@@ -100,6 +102,10 @@ export function useScalePage() {
loadingScale.value = true
loadError.value = ''
needProfile.value = false
if (!(await ensureAccount(router, route.fullPath))) {
loadingScale.value = false
return
}
try {
const d = await api.getScale(slug)
title.value = d.title
@@ -118,7 +118,7 @@ export function useStarProfilePage() {
birth_time: bt,
birth_place: birthPlace.value || undefined,
})
report.value = await api.getLatestReport(profile.id, 'star')
report.value = await api.createStar(profile.id)
view.value = 'overview'
track(AnalyticsEvent.PortraitCompleted, { source: 'star' })
} catch (e) {
@@ -18,6 +18,7 @@ import {
sectionsFromDetail,
type SynastryMainTab,
} from '../lib/synastryChart'
import { ensureAccount } from '../lib/authSession'
import { createSynastryPageActions } from './synastryPageActions'
export type MainTab = SynastryMainTab
@@ -135,6 +136,7 @@ export function useSynastryPage() {
})
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) return
await loadProfiles()
if (route.query.invite === '1') {
inviteHighlight.value = true
+1 -1
View File
@@ -29,7 +29,7 @@ export async function ensureAccount(router: Router, redirect: string): Promise<A
export async function findSelfProfile(): Promise<Profile | null> {
const { items } = await api.listProfiles()
return items.find((p) => p.relation === 'self') || null
return (items ?? []).find((p) => p.relation === 'self') || null
}
/** Load cached solo report for self profile; null report means need archive. */
+4 -2
View File
@@ -5,6 +5,7 @@ import AskPage from './AskPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getAskQuota: vi.fn(),
createAskThread: vi.fn(),
@@ -16,8 +17,8 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/ask' }),
useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }),
}))
const RouterLinkStub = defineComponent({
@@ -30,6 +31,7 @@ const RouterLinkStub = defineComponent({
describe('AskPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows empty state when no profiles', async () => {
+9 -3
View File
@@ -4,20 +4,26 @@ import CompanionPage from './CompanionPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getSolarTermsToday: vi.fn(),
getMoodToday: vi.fn(),
getMoodsRecent: vi.fn(),
saveMood: vi.fn(),
},
}))
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/companion' }),
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ path: '/companion', fullPath: '/companion' }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
describe('CompanionPage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.getMoodsRecent.mockResolvedValue({ items: [] })
})
it('shows solar term and saves mood', async () => {
api.getSolarTermsToday.mockResolvedValue({ name: '立秋', tip: '收敛节奏', date: '2026-08-02' })
+6
View File
@@ -91,11 +91,15 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const moodMarks = ['◦', '○', '◎', '●', '◉']
const term = ref({ name: '…', tip: '加载中…', date: '' })
const score = ref<number | null>(null)
@@ -111,6 +115,7 @@ const todayLabel = computed(() =>
)
async function load() {
if (!(await ensureAccount(router, route.fullPath))) return
track('companion_viewed')
try {
term.value = await api.getSolarTermsToday()
@@ -135,6 +140,7 @@ async function load() {
async function save() {
if (!score.value) return
if (!(await ensureAccount(router, route.fullPath))) return
saving.value = true
moodErr.value = ''
saved.value = false
@@ -33,11 +33,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
type Item = {
@@ -59,6 +60,7 @@ type Cat = {
}
const route = useRoute()
const router = useRouter()
const cat = ref<Cat | null>(null)
const loading = ref(true)
const error = ref('')
@@ -67,6 +69,10 @@ async function load() {
loading.value = true
error.value = ''
cat.value = null
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
cat.value = await api.getExploreCategory(String(route.params.category))
} catch (e) {
+9
View File
@@ -53,11 +53,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon, { type HomeToolIconName } from '../components/HomeToolIcon.vue'
import ListRow from '../components/ListRow.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
type Cat = {
@@ -69,6 +71,9 @@ type Cat = {
items?: { badge?: string }[]
}
const router = useRouter()
const route = useRoute()
const featured: { to: string; label: string; icon: HomeToolIconName; badge?: string; badgeTone?: 'hot' | 'new' }[] = [
{ to: '/scales/mbti-lite', label: '人格测试', icon: 'mbti' },
{ to: '/star', label: '星座', icon: 'star' },
@@ -96,6 +101,10 @@ function hotBadge(c: Cat) {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.getExploreCatalog()
categories.value = res.categories || []
@@ -41,11 +41,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const plans = ref<{ id: string; title: string; focus: string }[]>([])
const checkins = ref<Record<string, { id: string; day: string; note?: string }[]>>({})
const title = ref('')
@@ -57,6 +61,10 @@ const err = ref('')
async function load() {
loading.value = true
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listGrowthPlans()
plans.value = res.items || []
+4 -2
View File
@@ -4,6 +4,7 @@ import ImageCardPage from './ImageCardPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listImageCardScenes: vi.fn(),
getImageCardQuota: vi.fn(),
createProfile: vi.fn(),
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/cards' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('ImageCardPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listImageCardScenes.mockResolvedValue({
items: [
{ key: '情绪整理', label: '情绪整理' },
+10 -3
View File
@@ -4,6 +4,9 @@ import LifeRhythmPage from './LifeRhythmPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createRhythm: vi.fn(),
getReport: vi.fn(),
@@ -14,12 +17,16 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' } }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: { y: '1992', m: '6', d: '8' }, fullPath: '/rhythm' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('LifeRhythmPage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
})
it('creates rhythm report from birth query', async () => {
api.createProfile.mockResolvedValue({ id: 'p1' })
+10 -1
View File
@@ -83,7 +83,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
@@ -126,6 +126,15 @@ async function submit() {
busy.value = false
}
}
onMounted(async () => {
try {
await api.authMe()
await router.replace(String(route.query.redirect || '/mine'))
} catch {
/* stay on login */
}
})
</script>
<style scoped>
@@ -4,6 +4,7 @@ import MembershipPage from './MembershipPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getMembership: vi.fn(),
createOrder: vi.fn(),
payMock: vi.fn(),
@@ -13,13 +14,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/membership' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('MembershipPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows subscribe CTA when inactive', async () => {
@@ -55,6 +55,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import AskQuotaBuy from '../components/ask/AskQuotaBuy.vue'
@@ -62,6 +63,10 @@ import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const router = useRouter()
const route = useRoute()
const plans = [
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
@@ -93,6 +98,10 @@ function formatDate(iso: string) {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
me.value = await api.getMembership()
} catch (e) {
+13 -6
View File
@@ -71,7 +71,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { AuthUser, MembershipMe } from '@yuxingu/types'
import { api } from '../api/client'
import BrandLogo from '../components/BrandLogo.vue'
@@ -80,8 +79,6 @@ import PageShell from '../components/PageShell.vue'
import type { HomeToolIconName } from '../components/HomeToolIcon.vue'
import { clearAuthToken } from '../lib/authSession'
const router = useRouter()
const groupArchive: { to: string; label: string; icon: HomeToolIconName }[] = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
@@ -121,14 +118,22 @@ async function load() {
error.value = ''
try {
me.value = await api.authMe()
} catch {
// 未登录:展示游客态(未登录 + 登录入口),不把 401 当成整页错误
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) {
me.value = null
membership.value = null
profileCount.value = 0
error.value = e instanceof Error ? e.message : '请先登录'
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
@@ -142,7 +147,9 @@ async function logout() {
}
clearAuthToken()
me.value = null
await router.push('/login')
membership.value = null
profileCount.value = 0
await load()
}
onMounted(load)
+7 -2
View File
@@ -4,6 +4,9 @@ import PortraitPage from './PortraitPage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createPortrait: vi.fn(),
getReport: vi.fn(),
@@ -16,8 +19,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: routeQuery, fullPath: '/portrait' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
RouterLink: {
name: 'RouterLink',
props: ['to'],
@@ -57,6 +60,8 @@ describe('PortraitPage', () => {
routeQuery.y = ''
routeQuery.m = ''
routeQuery.d = ''
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
})
it('shows birth form when opened without query', async () => {
+6 -2
View File
@@ -5,6 +5,7 @@ import ProfilePage from './ProfilePage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
@@ -14,7 +15,7 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRoute: () => ({ query: {}, fullPath: '/profile' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
@@ -26,7 +27,10 @@ const RouterLinkStub = defineComponent({
})
describe('ProfilePage', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('shows empty state', async () => {
api.listProfiles.mockResolvedValue({ items: [] })
+4 -2
View File
@@ -4,6 +4,7 @@ import RelationPage from './RelationPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
createRelationInsight: vi.fn(),
@@ -16,13 +17,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: {}, fullPath: '/relation' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('RelationPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
})
it('generates relation insight summary and gates tips', async () => {
+8
View File
@@ -65,13 +65,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
import BackButton from '../components/BackButton.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { ensureAccount } from '../lib/authSession'
import { toolIconFor } from '../lib/toolIconMap'
const router = useRouter()
const route = useRoute()
const items = ref<GrowthReport[]>([])
const loading = ref(true)
const error = ref('')
@@ -132,6 +136,10 @@ function badgeLabel(b: 'new' | 'hot') {
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
try {
const res = await api.listReports()
items.value = res.items || []
+4 -1
View File
@@ -4,6 +4,7 @@ import ScalePage from './ScalePage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
getScale: vi.fn(),
listProfiles: vi.fn(),
submitScale: vi.fn(),
@@ -17,7 +18,8 @@ vi.mock('../lib/scaleDraft', () => ({
clearScaleDraft: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { slug: 'mbti-lite' } }),
useRoute: () => ({ params: { slug: 'mbti-lite' }, fullPath: '/scales/mbti-lite' }),
useRouter: () => ({ replace: vi.fn() }),
}))
const scaleDetail = {
@@ -53,6 +55,7 @@ const scaleDetail = {
describe('ScalePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.getScale.mockResolvedValue(scaleDetail)
api.listProfiles.mockResolvedValue({
items: [{ id: 'p1', relation: 'self', display_name: '我', birth_date: '1990-01-01' }],
@@ -4,6 +4,9 @@ import StarProfilePage from './StarProfilePage.vue'
const { api, routeQuery } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
getLatestReport: vi.fn(),
createProfile: vi.fn(),
createStar: vi.fn(),
getReport: vi.fn(),
@@ -15,8 +18,8 @@ const { api, routeQuery } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routeQuery }),
useRouter: () => ({ back: vi.fn() }),
useRoute: () => ({ query: routeQuery, fullPath: '/star' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
const summary = {
@@ -42,6 +45,8 @@ const summary = {
describe('StarProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({ items: [] })
api.createProfile.mockResolvedValue({ id: 'p1' })
api.createStar.mockResolvedValue({
id: 'r1',
@@ -58,6 +58,7 @@ import BirthDateInputs from '../components/BirthDateInputs.vue'
import HomeToolIcon from '../components/HomeToolIcon.vue'
import PageShell from '../components/PageShell.vue'
import { AnalyticsEvent, track } from '../lib/analytics'
import { ensureAccount } from '../lib/authSession'
const route = useRoute()
const router = useRouter()
@@ -71,6 +72,10 @@ const td = ref('')
const meta = ref<{ host_name: string; already_accepted: boolean } | null>(null)
onMounted(async () => {
if (!(await ensureAccount(router, route.fullPath))) {
loading.value = false
return
}
const token = String(route.params.token || '')
try {
meta.value = await api.getSynastryInvite(token)
@@ -82,6 +87,7 @@ onMounted(async () => {
})
async function accept() {
if (!(await ensureAccount(router, route.fullPath))) return
const y = Number(ty.value)
const m = Number(tm.value)
const d = Number(td.value)
+3 -1
View File
@@ -4,6 +4,7 @@ import SynastryPage from './SynastryPage.vue'
const { api } = vi.hoisted(() => ({
api: {
authMe: vi.fn(),
listProfiles: vi.fn(),
createProfile: vi.fn(),
createSynastry: vi.fn(),
@@ -18,13 +19,14 @@ const { api } = vi.hoisted(() => ({
vi.mock('../api/client', () => ({ api }))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRoute: () => ({ query: {}, fullPath: '/synastry' }),
useRouter: () => ({ back: vi.fn(), replace: vi.fn() }),
}))
describe('SynastryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
api.authMe.mockResolvedValue({ id: 'u1' })
api.listProfiles.mockResolvedValue({
items: [
{ id: 'a1', relation: 'self', display_name: '我', birth_date: '1990-05-12' },