Files
digital-psychology/apps/admin-h5/src/pages/ContentPage.vue
T
jackyu66gitandCursor 5ceb3ce749
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s
feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:54:59 +08:00

210 lines
7.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>