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
+35 -2
View File
@@ -94,24 +94,57 @@ export type UserDetail = {
}>
}
export type AdminRole = 'super' | 'ops'
export type AdminMe = { id: string; username: string; role: AdminRole }
export type PushJob = {
id: string
title: string
body: string
audience: string
status: 'draft' | 'cancelled'
created_by: string
created_at: string
updated_at: string
}
export type AdminListItem = {
id: string
username: string
role: AdminRole
status: string
created_at: string
}
export const adminApi = {
login: (username: string, password: string) =>
request<{ token: string; admin: { id: string; username: string } }>('POST', '/auth/login', {
request<{ token: string; admin: AdminMe }>('POST', '/auth/login', {
username,
password,
}),
logout: () => request<{ ok: boolean }>('POST', '/auth/logout'),
me: () => request<{ id: string; username: string }>('GET', '/me'),
me: () => request<AdminMe>('GET', '/me'),
stats: () => request<DashboardStats>('GET', '/stats'),
users: (q = '') =>
request<{ items: UserListItem[] }>('GET', `/users?q=${encodeURIComponent(q)}`),
user: (id: string) => request<UserDetail>('GET', `/users/${id}`),
banUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/ban`),
unbanUser: (id: string) => request<{ ok: boolean }>('POST', `/users/${id}/unban`),
grant: (id: string, plan: string) =>
request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }),
grantAskQuota: (id: string, delta: number) =>
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
delta,
}),
admins: () => request<{ items: AdminListItem[] }>('GET', '/admins'),
patchAdminRole: (id: string, role: AdminRole) =>
request<{ ok: boolean }>('PATCH', `/admins/${id}`, { role }),
pushJobs: () => request<{ items: PushJob[] }>('GET', '/push-jobs'),
createPushJob: (body: { title: string; body?: string; audience?: string }) =>
request<PushJob>('POST', '/push-jobs', body),
patchPushJob: (id: string, body: { title?: string; body?: string; status?: 'draft' | 'cancelled' }) =>
request<PushJob>('PATCH', `/push-jobs/${id}`, body),
orders: (params?: {
status?: string
kind?: string
+4 -2
View File
@@ -29,11 +29,13 @@ async function onLogout() {
<RouterLink to="/content">内容</RouterLink>
<RouterLink to="/users">用户</RouterLink>
<RouterLink to="/orders">订单</RouterLink>
<RouterLink to="/pricing">定价</RouterLink>
<RouterLink v-if="auth.isSuper" to="/pricing">定价</RouterLink>
<RouterLink to="/push">推送</RouterLink>
<RouterLink v-if="auth.isSuper" to="/admins">管理员</RouterLink>
<RouterLink to="/audit">审计</RouterLink>
</nav>
<div class="foot">
<span class="muted">{{ auth.username || '管理员' }}</span>
<span class="muted">{{ auth.username || '管理员' }} · {{ auth.role }}</span>
<button class="btn ghost" type="button" @click="onLogout">退出</button>
</div>
</aside>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type AdminListItem, type AdminRole } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<AdminListItem[]>([])
const msg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.admins()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function setRole(id: string, role: AdminRole) {
msg.value = ''
try {
await adminApi.patchAdminRole(id, role)
msg.value = '角色已更新'
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '更新失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>管理员</h1>
<p class="muted">仅超级管理员可访问角色super全权/ ops只读+封禁+推送草稿</p>
<p v-if="msg" class="muted">{{ msg }}</p>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<table v-else class="tbl">
<thead>
<tr><th>用户名</th><th>角色</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="a in items" :key="a.id">
<td>{{ a.username }}</td>
<td>{{ a.role }}</td>
<td>{{ a.status }}</td>
<td class="ops">
<button class="btn ghost" type="button" @click="setRole(a.id, 'super')">设为 super</button>
<button class="btn ghost" type="button" @click="setRole(a.id, 'ops')">设为 ops</button>
</td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
.ops { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.err { color: var(--accent); }
</style>
+11 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const tab = ref<'grid' | 'scales'>('grid')
const loading = ref(false)
const saving = ref(false)
@@ -112,7 +114,8 @@ async function toggleScale(s: ScaleAdmin) {
<p v-if="msg" class="ok">{{ msg }}</p>
<template v-if="tab === 'grid' && !loading">
<div class="toolbar">
<p v-if="!auth.isSuper" class="muted">只读内容写入需超级管理员</p>
<div v-if="auth.isSuper" class="toolbar">
<button class="btn ghost" type="button" @click="addTool">添加</button>
<button class="btn" type="button" :disabled="saving" @click="saveTools">保存宫格</button>
</div>
@@ -153,9 +156,15 @@ async function toggleScale(s: ScaleAdmin) {
</span>
</td>
<td>
<button class="btn ghost" type="button" @click="toggleScale(s)">
<button
v-if="auth.isSuper"
class="btn ghost"
type="button"
@click="toggleScale(s)"
>
{{ s.status === 'published' ? '下架' : '上架' }}
</button>
<span v-else class="muted"></span>
</td>
</tr>
</tbody>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { adminApi, type PushJob } from '@/api/client'
const loading = ref(false)
const error = ref('')
const items = ref<PushJob[]>([])
const title = ref('')
const body = ref('')
const msg = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await adminApi.pushJobs()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function create() {
msg.value = ''
try {
await adminApi.createPushJob({ title: title.value.trim(), body: body.value.trim(), audience: 'all' })
title.value = ''
body.value = ''
msg.value = '已创建草稿(占位,不下发)'
await load()
} catch (e) {
msg.value = e instanceof Error ? e.message : '创建失败'
}
}
async function cancel(id: string) {
try {
await adminApi.patchPushJob(id, { status: 'cancelled' })
await load()
} catch (e) {
error.value = e instanceof Error ? e.message : '取消失败'
}
}
onMounted(load)
</script>
<template>
<section>
<h1>推送任务</h1>
<p class="muted">占位能力仅草稿 / 取消不接厂商不下发</p>
<div class="card block">
<h2>新建草稿</h2>
<input v-model="title" class="inp" placeholder="标题" maxlength="128" />
<textarea v-model="body" class="ta" rows="3" placeholder="正文(可选)" />
<button class="btn" type="button" :disabled="!title.trim()" @click="create">创建草稿</button>
<span v-if="msg" class="muted">{{ msg }}</span>
</div>
<p v-if="loading" class="muted">加载中</p>
<p v-else-if="error" class="err">{{ error }}</p>
<table v-else class="tbl">
<thead>
<tr><th>标题</th><th>受众</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td>{{ j.title }}</td>
<td>{{ j.audience }}</td>
<td>{{ j.status }}</td>
<td>
<button
v-if="j.status === 'draft'"
class="btn ghost"
type="button"
@click="cancel(j.id)"
>取消</button>
<span v-else class="muted"></span>
</td>
</tr>
<tr v-if="!items.length"><td colspan="4" class="muted">暂无任务</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.block { margin: 1rem 0; display: flex; flex-direction: column; gap: 0.5rem; max-width: 480px; }
.inp, .ta {
border: 1px solid var(--line); border-radius: 10px; padding: 0.55rem 0.7rem;
font: inherit; background: #fff;
}
.tbl { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.tbl th, .tbl td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--line); }
.err { color: var(--accent); }
</style>
+28 -2
View File
@@ -2,8 +2,10 @@
import { onMounted, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { adminApi, type UserDetail } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
const detail = ref<UserDetail | null>(null)
@@ -11,6 +13,7 @@ const plan = ref('month')
const askDelta = ref(10)
const grantMsg = ref('')
const askMsg = ref('')
const banMsg = ref('')
async function load() {
loading.value = true
@@ -46,6 +49,22 @@ async function grantAsk() {
}
}
async function toggleBan() {
banMsg.value = ''
try {
if (detail.value?.status === 'banned') {
await adminApi.unbanUser(String(route.params.id))
banMsg.value = '已解封'
} else {
await adminApi.banUser(String(route.params.id))
banMsg.value = '已封禁'
}
await load()
} catch (e) {
banMsg.value = e instanceof Error ? e.message : '操作失败'
}
}
function fmtTime(iso?: string | null) {
if (!iso) return '—'
try {
@@ -83,6 +102,12 @@ onMounted(load)
<div><span>创建</span><strong>{{ fmtTime(detail.created_at) }}</strong></div>
<div class="wide"><span>ID</span><code>{{ detail.id }}</code></div>
</div>
<div class="grant">
<button class="btn" type="button" @click="toggleBan">
{{ detail.status === 'banned' ? '解封' : '封禁' }}
</button>
<span v-if="banMsg" class="muted">{{ banMsg }}</span>
</div>
</div>
<div class="card block">
@@ -93,7 +118,7 @@ onMounted(load)
会员问答余量 {{ detail.membership.ask_quota_left ?? 0 }} ·
到期 {{ fmtTime(detail.membership.expires_at) }}
</p>
<div class="grant">
<div v-if="auth.isSuper" class="grant">
<select v-model="plan">
<option value="month">月卡</option>
<option value="quarter">季卡</option>
@@ -102,12 +127,13 @@ onMounted(load)
<button class="btn" type="button" @click="grant">授予 / 延长</button>
<span v-if="grantMsg" class="muted">{{ grantMsg }}</span>
</div>
<p v-else class="muted">仅超级管理员可授予会员</p>
</div>
<div class="card block">
<h2>问答额度已购</h2>
<p>已购余量<strong>{{ detail.ask_paid_quota_left }}</strong> </p>
<div class="grant">
<div v-if="auth.isSuper" class="grant">
<select v-model.number="askDelta">
<option :value="10">+10</option>
<option :value="30">+30</option>
+10 -2
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -15,16 +16,23 @@ const router = createRouter({
{ path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue'), meta: { superOnly: true } },
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
],
},
],
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
if (to.meta.public) return true
if (!getToken()) return { name: 'login', query: { redirect: to.fullPath } }
if (to.meta.superOnly) {
const auth = useAuthStore()
if (!auth.username) await auth.hydrate()
if (!auth.isSuper) return { name: 'dashboard' }
}
return true
})
+9 -3
View File
@@ -1,16 +1,20 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { adminApi, getToken, setToken } from '@/api/client'
import { computed, ref } from 'vue'
import { adminApi, getToken, setToken, type AdminRole } from '@/api/client'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const username = ref<string>('')
const role = ref<AdminRole>('ops')
const isSuper = computed(() => role.value === 'super')
async function login(user: string, password: string) {
const res = await adminApi.login(user, password)
setToken(res.token)
token.value = res.token
username.value = res.admin.username
role.value = res.admin.role || 'super'
}
async function hydrate() {
@@ -18,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
const me = await adminApi.me()
username.value = me.username
role.value = me.role || 'super'
return true
} catch {
setToken(null)
@@ -35,7 +40,8 @@ export const useAuthStore = defineStore('auth', () => {
setToken(null)
token.value = null
username.value = ''
role.value = 'ops'
}
return { token, username, login, logout, hydrate }
return { token, username, role, isSuper, login, logout, hydrate }
})