feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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: '情绪整理' },
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user