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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user