feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+200
View File
@@ -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>