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
+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' },