落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
7.0 KiB
Vue
210 lines
7.0 KiB
Vue
<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)
|
||
const error = ref('')
|
||
const tools = ref<HomeToolAdmin[]>([])
|
||
const scales = ref<ScaleAdmin[]>([])
|
||
const msg = ref('')
|
||
|
||
const icons = [
|
||
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
|
||
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
|
||
]
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
const [t, s] = await Promise.all([adminApi.homeTools(), adminApi.scales()])
|
||
tools.value = (t.items || []).map((x) => ({ ...x, enabled: !!x.enabled }))
|
||
scales.value = s.items || []
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
|
||
async function saveTools() {
|
||
saving.value = true
|
||
msg.value = ''
|
||
error.value = ''
|
||
try {
|
||
const payload = tools.value.map((t, idx) => ({
|
||
...t,
|
||
sort_order: t.sort_order || idx + 1,
|
||
badge: t.badge || null,
|
||
badge_tone: t.badge_tone || null,
|
||
}))
|
||
await adminApi.saveHomeTools(payload)
|
||
msg.value = '宫格已保存'
|
||
await load()
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '保存失败'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function move(i: number, dir: -1 | 1) {
|
||
const j = i + dir
|
||
if (j < 0 || j >= tools.value.length) return
|
||
const arr = tools.value.slice()
|
||
const tmp = arr[i]
|
||
arr[i] = arr[j]
|
||
arr[j] = tmp
|
||
const counts: Record<number, number> = { 1: 0, 2: 0 }
|
||
tools.value = arr.map((t) => {
|
||
const row = t.row_index === 2 ? 2 : 1
|
||
counts[row] += 1
|
||
return { ...t, row_index: row, sort_order: counts[row] }
|
||
})
|
||
}
|
||
|
||
function addTool() {
|
||
if (tools.value.length >= 24) return
|
||
tools.value.push({
|
||
row_index: 2,
|
||
sort_order: tools.value.length + 1,
|
||
path: '/ask',
|
||
icon: 'ask',
|
||
label: '新入口',
|
||
enabled: true,
|
||
})
|
||
}
|
||
|
||
async function toggleScale(s: ScaleAdmin) {
|
||
const next = s.status === 'published' ? 'draft' : 'published'
|
||
error.value = ''
|
||
try {
|
||
await adminApi.patchScale(s.id, next)
|
||
s.status = next
|
||
msg.value = `${s.title} → ${next === 'published' ? '已上架' : '已下架'}`
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : '更新失败'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section>
|
||
<header class="head">
|
||
<div>
|
||
<h1>内容</h1>
|
||
<p class="muted">首页宫格 · 测评上下架</p>
|
||
</div>
|
||
<button class="btn ghost" type="button" :disabled="loading" @click="load">刷新</button>
|
||
</header>
|
||
|
||
<div class="seg">
|
||
<button type="button" :class="{ on: tab === 'grid' }" @click="tab = 'grid'">首页宫格</button>
|
||
<button type="button" :class="{ on: tab === 'scales' }" @click="tab = 'scales'">测评</button>
|
||
</div>
|
||
|
||
<p v-if="loading" class="muted">加载中…</p>
|
||
<p v-else-if="error" class="err">{{ error }}</p>
|
||
<p v-if="msg" class="ok">{{ msg }}</p>
|
||
|
||
<template v-if="tab === 'grid' && !loading">
|
||
<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>
|
||
<div v-for="(t, i) in tools" :key="t.id || i" class="row">
|
||
<label class="chk"><input v-model="t.enabled" type="checkbox" />显</label>
|
||
<select v-model.number="t.row_index">
|
||
<option :value="1">行1</option>
|
||
<option :value="2">行2</option>
|
||
</select>
|
||
<input v-model="t.label" class="inp" maxlength="16" placeholder="文案" />
|
||
<input v-model="t.path" class="inp path" placeholder="/path" />
|
||
<select v-model="t.icon">
|
||
<option v-for="ic in icons" :key="ic" :value="ic">{{ ic }}</option>
|
||
</select>
|
||
<input v-model="t.badge" class="inp sm" maxlength="4" placeholder="角标" />
|
||
<select v-model="t.badge_tone">
|
||
<option value="">无色</option>
|
||
<option value="hot">hot</option>
|
||
<option value="new">new</option>
|
||
</select>
|
||
<button class="btn ghost" type="button" @click="move(i, -1)">↑</button>
|
||
<button class="btn ghost" type="button" @click="move(i, 1)">↓</button>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="tab === 'scales' && !loading">
|
||
<table>
|
||
<thead>
|
||
<tr><th>标题</th><th>slug</th><th>状态</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="s in scales" :key="s.id">
|
||
<td>{{ s.title }}</td>
|
||
<td class="mono">{{ s.slug }}</td>
|
||
<td>
|
||
<span :class="s.status === 'published' ? 'on' : 'off'">
|
||
{{ s.status === 'published' ? '已上架' : '草稿' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<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>
|
||
</table>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
|
||
h1 { margin: 0; font-size: 1.5rem; }
|
||
.muted { color: var(--muted); }
|
||
.err { color: #c0392b; }
|
||
.ok { color: #2fa866; }
|
||
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-bottom: 1rem; }
|
||
.seg button { border: 0; background: transparent; padding: 0.45rem 0.9rem; cursor: pointer; color: var(--muted); font-weight: 600; }
|
||
.seg button.on { background: linear-gradient(135deg, #fff0ec, #ffe4e0); color: var(--accent); }
|
||
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: auto auto 7rem 1fr 6.5rem 3.5rem 4.5rem auto auto;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
margin-bottom: 0.45rem;
|
||
padding: 0.45rem 0.5rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: rgba(255,253,251,0.9);
|
||
}
|
||
.inp { border: 1px solid var(--line); border-radius: 8px; padding: 0.35rem 0.45rem; min-width: 0; }
|
||
.inp.path { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||
.inp.sm { width: 3.2rem; }
|
||
.chk { font-size: 0.85rem; color: var(--muted); display: flex; gap: 0.25rem; align-items: center; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--line); }
|
||
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||
.on { color: #2fa866; font-weight: 600; }
|
||
.off { color: var(--muted); }
|
||
@media (max-width: 960px) {
|
||
.row { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
</style>
|