feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi, type HomeToolAdmin, type ScaleAdmin } from '@/api/client'
|
||||
|
||||
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">
|
||||
<div 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 class="btn ghost" type="button" @click="toggleScale(s)">
|
||||
{{ s.status === 'published' ? '下架' : '上架' }}
|
||||
</button>
|
||||
</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>
|
||||
Reference in New Issue
Block a user