feat: 管理后台全面升级——修复菜单无数据根因(items字段不匹配+trends接口缺失),新增知识管理(审核/检测题编辑)/访问日志/看板v2(最近访问+点亮分布+全量指标),trends CRUD,用户/反馈列表修正

This commit is contained in:
2026-09-13 17:45:29 +08:00
parent a4da3722ef
commit c5ade459a5
8 changed files with 555 additions and 84 deletions
+4
View File
@@ -25,9 +25,11 @@ import { onMounted, ref } from 'vue'
import { getToken, setToken, clearToken, apiPost } from './api' import { getToken, setToken, clearToken, apiPost } from './api'
import Dashboard from './views/Dashboard.vue' import Dashboard from './views/Dashboard.vue'
import Users from './views/Users.vue' import Users from './views/Users.vue'
import Knowledge from './views/Knowledge.vue'
import Trends from './views/Trends.vue' import Trends from './views/Trends.vue'
import Quotas from './views/Quotas.vue' import Quotas from './views/Quotas.vue'
import Feedbacks from './views/Feedbacks.vue' import Feedbacks from './views/Feedbacks.vue'
import Logs from './views/Logs.vue'
const token = ref(getToken()) const token = ref(getToken())
const view = ref('dashboard') const view = ref('dashboard')
@@ -36,9 +38,11 @@ const username = ref(''), password = ref(''), busy = ref(false), error = ref('')
const menus = [ const menus = [
{ key: 'dashboard', label: '数据看板', icon: '📊', comp: Dashboard }, { key: 'dashboard', label: '数据看板', icon: '📊', comp: Dashboard },
{ key: 'users', label: '用户管理', icon: '👥', comp: Users }, { key: 'users', label: '用户管理', icon: '👥', comp: Users },
{ key: 'knowledge', label: '知识管理', icon: '📚', comp: Knowledge },
{ key: 'trends', label: '趋势管理', icon: '📈', comp: Trends }, { key: 'trends', label: '趋势管理', icon: '📈', comp: Trends },
{ key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas }, { key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas },
{ key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks }, { key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks },
{ key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs },
] ]
async function login() { async function login() {
+63 -26
View File
@@ -1,40 +1,77 @@
<template> <template>
<div> <div class="page">
<h2>数据看板</h2>
<div class="cards"> <div class="cards">
<el-card><div class="num">{{ s.newUsers ?? '-' }}</div><div class="lbl">今日新增</div></el-card> <div class="card"><b>{{ d.stats?.totalUsers ?? '-' }}</b><span>用户总数</span><i>7日新增 {{ d.stats?.newUsers7d ?? 0 }}</i></div>
<el-card><div class="num">{{ s.activeUsers ?? '-' }}</div><div class="lbl">今日活跃</div></el-card> <div class="card"><b>{{ d.stats?.approvedKnowledge ?? '-' }}</b><span>已审知识</span><i>共 {{ d.stats?.totalKnowledge ?? 0 }} · 含题 {{ d.stats?.withQuiz ?? 0 }}</i></div>
<el-card><div class="num">{{ s.newOrders ?? '-' }}</div><div class="lbl">今日订单</div></el-card> <div class="card"><b>{{ d.stats?.totalLits ?? '-' }}</b><span>点亮星球</span><i>{{ d.stats?.litUsers ?? 0 }} 人参与</i></div>
<el-card><div class="num ok">{{ s.aiChatToday ?? '-' }}</div><div class="lbl">问答总数</div></el-card> <div class="card"><b>{{ d.stats?.aiChatUsed ?? '-' }}</b><span>AI 问答次数</span><i>{{ d.stats?.aiChatUsers ?? 0 }} 人用过</i></div>
<el-card><div class="num">{{ s.aiChatUsers ?? '-' }}</div><div class="lbl">问答用户</div></el-card> <div class="card"><b>{{ d.stats?.totalTrends ?? '-' }}</b><span>趋势资讯</span><i>待审反馈 {{ d.stats?.pendingFeedbacks ?? 0 }}</i></div>
<el-card><div class="num">{{ s.aiChatShares ?? '-' }}</div><div class="lbl">分享次数</div></el-card> <div class="card"><b>{{ d.stats?.paidOrders ?? '-' }}</b><span>付费订单</span><i>共 {{ d.stats?.totalOrders ?? 0 }} 单</i></div>
</div>
<div class="row">
<div class="panel">
<h3>星域点亮分布</h3>
<div v-for="b in d.litByDim || []" :key="b.dim" class="bar-row">
<span class="bar-name">{{ b.name }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: pct(b.count) }"></div></div>
<span class="bar-num">{{ b.count }}</span>
</div>
<p v-if="!(d.litByDim || []).some(b => b.count)" class="empty">还没有点亮记录</p>
</div>
<div class="panel grow">
<h3>最近访问记录</h3>
<el-table :data="d.recentLogins || []" size="small" stripe>
<el-table-column prop="createdAt" label="时间" width="160">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="openid" label="用户" width="150">
<template #default="{ row }">{{ (row.openid || '-').slice(0, 12) }}</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="70" />
<el-table-column prop="method" label="方式" width="110" />
<el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="结果">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
</template>
</el-table-column>
</el-table>
<p v-if="!(d.recentLogins || []).length" class="empty">暂无访问记录</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { ref, onMounted } from 'vue'
import { apiGet } from '../api' import { apiGet } from '../api'
const s = ref({}) const d = ref({})
onMounted(async () => { function pct(n) {
try { const max = Math.max(1, ...(d.value.litByDim || []).map(b => b.count))
const d = await apiGet('/api/admin/dashboard') return Math.round((n / max) * 100) + '%'
const stats = await apiGet('/api/admin/ai-chat/stats').catch(() => ({}))
s.value = {
newUsers: d?.today?.newUsers ?? '-',
activeUsers: d?.today?.activeUsers ?? '-',
newOrders: d?.today?.newOrders ?? '-',
aiChatToday: stats?.totalQuestions ?? '-',
aiChatUsers: stats?.totalUsers ?? '-',
aiChatShares: stats?.totalShares ?? '-',
} }
} catch (e) { console.error(e) } function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '-' }
onMounted(async () => {
d.value = (await apiGet('/api/admin/dashboard-v2')) || {}
}) })
</script> </script>
<style scoped> <style scoped>
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; } .page { display: flex; flex-direction: column; gap: 16px; }
.num { font-size:26px; font-weight:700; color:#1f2333; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.num.ok { color:#18a058; } .card { background: #fff; border-radius: 12px; padding: 16px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.lbl { font-size:12px; color:#8a8fa3; margin-top:4px; } .card b { font-size: 24px; color: #1f2333; }
.card span { font-size: 13px; color: #6b7080; }
.card i { font-size: 11px; color: #a6abbb; font-style: normal; }
.row { display: grid; grid-template-columns: 300px 1fr; gap: 12px; }
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
.panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.panel h3 { margin: 0 0 12px; font-size: 14px; color: #1f2333; }
.bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.bar-name { width: 64px; font-size: 12px; color: #6b7080; flex-shrink: 0; }
.bar-track { flex: 1; height: 8px; border-radius: 4px; background: #eef0f6; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #5b5ff8, #8b5cf6); border-radius: 4px; }
.bar-num { width: 28px; text-align: right; font-size: 12px; color: #6b7080; }
.empty { color: #b6bac9; font-size: 12px; text-align: center; padding: 12px 0; }
</style> </style>
+29 -16
View File
@@ -1,26 +1,39 @@
<template> <template>
<div> <div class="page">
<div class="head"><h2>反馈管理</h2></div> <el-table :data="list" stripe v-loading="loading">
<el-table :data="list"> <el-table-column prop="userName" label="用户" width="110" />
<el-table-column prop="content" label="内容" show-overflow-tooltip /> <el-table-column prop="content" label="反馈内容" min-width="260" show-overflow-tooltip />
<el-table-column prop="user" label="用户" width="120" /> <el-table-column prop="contact" label="联系方式" width="140" show-overflow-tooltip />
<el-table-column label="状态" width="90"> <el-table-column prop="type" label="类型" width="90" />
<template #default="{row}"><el-tag :type="row.status==='resolved'?'success':'info'" size="small">{{ row.status==='resolved'?'已处理':'待处理' }}</el-tag></template> <el-table-column label="状态" width="80">
<template #default="{ row }"><el-tag :type="row.status === 'resolved' ? 'success' : 'warning'" size="small">{{ row.status === 'resolved' ? '已处理' : '待处理' }}</el-tag></template>
</el-table-column> </el-table-column>
<el-table-column prop="createdAt" label="时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column> <el-table-column label="时间" width="150">
<el-table-column label="操作" width="100"> <template #default="{ row }">{{ fmt(row.createdAt) }}</template>
<template #default="{row}"><el-button size="small" type="danger" plain @click="del(row)">删除</el-button></template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
<p v-if="!loading && !list.length" class="empty">还没有收到反馈</p>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { apiGet } from '../api' import { apiGet } from '../api'
const list = ref([]) const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
async function load() { const r = await apiGet('/api/admin/feedback'); list.value = r.feedbacks || r.data || [] } function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function del(row) { await ElMessageBox.confirm('确认删除?'); await apiDelete(`/api/admin/feedback/${row._id}`); load() } async function load() {
async function apiDelete(url) { await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }) } loading.value = true
try {
const r = await apiGet(`/api/admin/feedback?page=${page.value}&limit=${pageSize}`)
list.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
onMounted(load) onMounted(load)
</script> </script>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.empty { color: #b6bac9; text-align: center; padding: 24px 0; font-size: 13px; }
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="page">
<div class="toolbar">
<el-select v-model="statusFilter" style="width: 130px" @change="search">
<el-option label="全部状态" value="" /><el-option label="已过审" value="approved" /><el-option label="草稿" value="draft" /><el-option label="待审" value="pending" />
</el-select>
<el-select v-model="dimFilter" style="width: 130px" @change="search">
<el-option label="全部维度" value="" /><el-option v-for="(n, i) in DIMS" :key="i" :label="n" :value="i + 1" />
</el-select>
<el-button type="primary" @click="openEdit(null)">新增知识</el-button>
</div>
<el-table :data="list" stripe v-loading="loading">
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column label="维度" width="90">
<template #default="{ row }">{{ DIMS[row.dim - 1] || row.dim }}</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="90" />
<el-table-column label="检测题" width="80">
<template #default="{ row }">
<el-tag :type="(row.options || []).length ? 'success' : 'info'" size="small">{{ (row.options || []).length ? '有' : '无' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="viewCount" label="浏览" width="70" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'approved' ? 'success' : 'warning'" size="small">{{ STATUS[row.status] || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" type="success" v-if="row.status !== 'approved'" @click="review(row, 'approved')">过审</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
<el-dialog v-model="dlg" :title="form._id ? '编辑知识' : '新增知识'" width="640px" top="4vh">
<el-form label-width="76px">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="维度/分类">
<el-select v-model="form.dim" style="width: 110px"><el-option v-for="(n, i) in DIMS" :key="i" :label="n" :value="i + 1" /></el-select>
<el-select v-model="form.category" style="width: 130px; margin-left: 8px"><el-option v-for="c in ['concept','history','application','question']" :key="c" :label="CATS[c]" :value="c" /></el-select>
<el-select v-model="form.status" style="width: 110px; margin-left: 8px"><el-option label="草稿" value="draft" /><el-option label="已过审" value="approved" /></el-select>
</el-form-item>
<el-form-item label="正文"><el-input v-model="form.content" type="textarea" :rows="5" /></el-form-item>
<el-form-item label="标签"><el-input v-model="tagsText" placeholder="逗号分隔" /></el-form-item>
<el-divider content-position="left">读后检测题(答对点亮星球)</el-divider>
<el-form-item label="题干"><el-input v-model="form.question" /></el-form-item>
<el-form-item v-for="(opt, i) in form.options" :key="i" :label="String.fromCharCode(65 + i)">
<div class="opt-row">
<el-input v-model="opt.text" placeholder="选项内容" />
<el-radio v-model="correctIdx" :value="i">正确</el-radio>
<el-button size="small" text type="danger" @click="form.options.splice(i, 1)">删</el-button>
</div>
</el-form-item>
<el-form-item label=" "><el-button size="small" @click="form.options.push({ text: '', isCorrect: false })" :disabled="form.options.length >= 5">+ 加选项</el-button></el-form-item>
<el-form-item label="解析"><el-input v-model="form.answer" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiGet, apiPost, apiDelete } from '../api'
const DIMS = ['AI 起源', 'AI 发展', 'AI 当前', 'AI 学习', 'AI 趋势']
const CATS = { concept: '概念', history: '历史', application: '应用', question: '问答' }
const STATUS = { approved: '已过审', draft: '草稿', pending: '待审', rejected: '已驳回' }
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
const statusFilter = ref(''), dimFilter = ref('')
const dlg = ref(false), correctIdx = ref(0), tagsText = ref('')
const form = ref({ title: '', dim: 1, category: 'concept', status: 'draft', content: '', tags: [], question: '', options: [], answer: '' })
function search() { page.value = 1; load() }
async function load() {
loading.value = true
try {
const qs = `page=${page.value}&limit=${pageSize}${statusFilter.value ? `&status=${statusFilter.value}` : ''}${dimFilter.value ? `&dim=${dimFilter.value}` : ''}`
const r = await apiGet(`/api/admin/knowledge?${qs}`)
list.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
function openEdit(row) {
if (row) {
form.value = JSON.parse(JSON.stringify(row))
correctIdx.value = Math.max(0, (row.options || []).findIndex(o => o.isCorrect))
tagsText.value = (row.tags || []).join(',')
} else {
form.value = { title: '', dim: 1, category: 'concept', status: 'draft', content: '', tags: [], question: '', options: [{ text: '', isCorrect: true }, { text: '', isCorrect: false }], answer: '' }
correctIdx.value = 0
tagsText.value = ''
}
dlg.value = true
}
async function save() {
const f = form.value
if (!f.title || !f.content) return ElMessage.warning('标题和正文必填')
f.options = (f.options || []).filter(o => o.text.trim())
if (f.options.length) {
if (f.options.length < 2) return ElMessage.warning('检测题至少两个选项')
f.options.forEach((o, i) => (o.isCorrect = i === correctIdx.value))
}
f.tags = tagsText.value.split(/[,,]/).map(t => t.trim()).filter(Boolean)
// 服务端约定:knowledgeId 表示更新;_id/__v 不能进 body;创建态 status 由服务端置 draft(用"过审"按钮发布)
const { _id, __v, createdAt, updatedAt, ...payload } = f
if (_id) payload.knowledgeId = _id
try {
await apiPost('/api/admin/knowledge', payload)
ElMessage.success('已保存')
dlg.value = false
load()
} catch (e) { ElMessage.error(e.message || '保存失败') }
}
async function review(row, status) {
try {
await apiPost('/api/admin/knowledge/review', { knowledgeId: row._id, status, comment: '' })
ElMessage.success('已过审')
load()
} catch (e) { ElMessage.error(e.message || '操作失败') }
}
async function del(row) {
await ElMessageBox.confirm(`删除「${row.title}」?不可恢复`, '确认', { type: 'warning' })
try { await apiDelete(`/api/admin/knowledge/${row._id}`); ElMessage.success('已删除'); load() }
catch (e) { ElMessage.error(e.message || '删除失败') }
}
onMounted(load)
</script>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
.opt-row { display: flex; align-items: center; gap: 10px; width: 100%; }
</style>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="page">
<el-tabs v-model="tab" @tab-change="load">
<el-tab-pane label="登录/访问日志" name="login" />
<el-tab-pane label="管理操作日志" name="admin" />
</el-tabs>
<el-table :data="list" stripe v-loading="loading" size="small">
<template v-if="tab === 'login'">
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column label="用户" width="140"><template #default="{ row }">{{ (row.openid || '-').slice(0, 12) }}</template></el-table-column>
<el-table-column prop="type" label="类型" width="80" />
<el-table-column prop="method" label="方式" width="120" />
<el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="结果">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">{{ row.success ? '成功' : (row.failReason || '失败') }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="userAgent" label="UA" min-width="180" show-overflow-tooltip />
</template>
<template v-else>
<el-table-column label="时间" width="160"><template #default="{ row }">{{ fmt(row.createdAt) }}</template></el-table-column>
<el-table-column prop="adminName" label="管理员" width="100" />
<el-table-column prop="action" label="动作" width="90" />
<el-table-column prop="resource" label="对象" width="120" />
<el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="详情" min-width="200">
<template #default="{ row }">{{ JSON.stringify(row.details || row.meta || {}).slice(0, 100) }}</template>
</el-table-column>
</template>
</el-table>
<el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { apiGet } from '../api'
const tab = ref('login'), list = ref([]), total = ref(0), page = ref(1), pageSize = 20, loading = ref(false)
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
async function load() {
loading.value = true
try {
const ep = tab.value === 'login' ? 'login-logs' : 'logs'
const r = await apiGet(`/api/admin/${ep}?page=${page.value}&limit=${pageSize}`)
list.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
</style>
+77 -27
View File
@@ -1,43 +1,93 @@
<template> <template>
<div> <div class="page">
<div class="head"><h2>趋势管理</h2><el-button type="primary" @click="dialog=true">新增趋势</el-button></div> <div class="toolbar">
<el-table :data="list"> <el-input v-model="q" placeholder="搜索标题" style="width: 220px" clearable @keyup.enter="search" />
<el-table-column prop="title" label="标题" show-overflow-tooltip /> <el-button type="primary" @click="search">搜索</el-button>
<el-table-column prop="source" label="来源" width="100" /> <el-button type="success" @click="openEdit(null)">新增资讯</el-button>
</div>
<el-table :data="list" stripe v-loading="loading">
<el-table-column label="标题" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ row.title }}
<el-tag v-if="row.hot" type="danger" size="small" style="margin-left:4px">热</el-tag>
<el-tag v-if="row.picked" type="warning" size="small" style="margin-left:4px">精</el-tag>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="80" /> <el-table-column prop="category" label="分类" width="80" />
<el-table-column prop="hot" label="热门" width="70"><template #default="{row}"><el-tag :type="row.hot?'danger':'info'" size="small">{{row.hot?'热':'普通'}}</el-tag></template></el-table-column> <el-table-column prop="source" label="来源" width="110" show-overflow-tooltip />
<el-table-column prop="createdAt" label="创建时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column> <el-table-column label="日期" width="110">
<el-table-column label="操作" width="100"><template #default="{row}"><el-button size="small" type="danger" plain @click="del(row)">删除</el-button></template></el-table-column> <template #default="{ row }">{{ (row.newsDate || row.createdAt || '').slice(0, 10) }}</template>
</el-table-column>
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="del(row)">删除</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-dialog v-model="dialog" title="新增趋势" width="480"> <el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
<el-form label-width="70">
<el-dialog v-model="dlg" :title="form._id ? '编辑资讯' : '新增资讯'" width="560px">
<el-form label-width="76px">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item> <el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="来源"><el-input v-model="form.source" placeholder="来源名称" /></el-form-item> <el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="分类"><el-select v-model="form.category"><el-option v-for="c in ['产品','论文','工具','公司','政策','其他']" :key="c" :value="c" :label="c" /></el-select></el-form-item> <el-form-item label="来源/链接">
<el-form-item label="摘要"><el-input v-model="form.summary" type="textarea" :rows="3" /></el-form-item> <el-input v-model="form.source" style="width: 140px" />
<el-input v-model="form.sourceUrl" placeholder="原文链接(选填)" style="flex:1; margin-left:8px" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.category" style="width: 130px"><el-option v-for="c in ['产品','论文','工具','公司','政策','其他']" :key="c" :label="c" :value="c" /></el-select>
<el-switch v-model="form.hot" active-text="热门" style="margin-left: 16px" />
<el-switch v-model="form.picked" active-text="精选" style="margin-left: 12px" />
</el-form-item>
</el-form> </el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template> <template #footer>
<el-button @click="dlg = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { apiGet, apiPost, apiPut, apiDelete } from '../api'
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), loading = ref(false)
const list = ref([]); const dialog = ref(false) const dlg = ref(false)
const form = reactive({ title: '', source: '', category: '其他', summary: '' }) const form = ref({})
async function load() { /* TODO */ } function search() { page.value = 1; load() }
async function load() {
loading.value = true
try {
const r = await apiGet(`/api/admin/trends?page=${page.value}&limit=${pageSize}&keyword=${encodeURIComponent(q.value)}`)
list.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
function openEdit(row) {
form.value = row ? { ...row } : { title: '', summary: '', source: '', sourceUrl: '', category: '产品', hot: false, picked: false }
dlg.value = true
}
async function save() { async function save() {
try { await apiPost('/api/admin/trends', { ...form }); ElMessage.success('已保存'); dialog.value = false; load() } if (!form.value.title) return ElMessage.warning('标题必填')
catch (e) { ElMessage.error(e.message) } try {
if (form.value._id) await apiPut(`/api/admin/trends/${form.value._id}`, form.value)
else await apiPost('/api/admin/trends', form.value)
ElMessage.success('已保存')
dlg.value = false
load()
} catch (e) { ElMessage.error(e.message || '保存失败') }
} }
async function del(row) { async function del(row) {
await ElMessageBox.confirm('确认删除?'); await apiDelete(`/api/admin/trends/${row._id}`); ElMessage.success('已删除'); load() await ElMessageBox.confirm(`删除「${row.title}」?`, '确认', { type: 'warning' })
try { await apiDelete(`/api/admin/trends/${row._id}`); ElMessage.success('已删除'); load() }
catch (e) { ElMessage.error(e.message || '删除失败') }
} }
function apiDelete(url) { return fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }) }
function apiPost(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` }, body: JSON.stringify(body) }).then(r => r.json()) }
function apiGet(path) { return fetch(path, { headers: { Authorization: `Bearer ${localStorage.getItem('dim_admin_token')}` } }).then(r => r.json()) }
onMounted(load) onMounted(load)
</script> </script>
<style scoped>.head{display:flex;justify-content:space-between;align-items:center}</style>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
</style>
+65 -14
View File
@@ -1,23 +1,74 @@
<template> <template>
<div> <div class="page">
<div class="head"><h2>用户管理</h2><el-input v-model="q" placeholder="搜索昵称" style="width:200px" clearable @change="page=1;load()" /></div> <div class="toolbar">
<el-table :data="list"> <el-input v-model="q" placeholder="搜索昵称" style="width: 220px" clearable @keyup.enter="search" />
<el-table-column prop="nickname" label="昵称" width="140" /> <el-button type="primary" @click="search">搜索</el-button>
<el-table-column label="手机号" width="130"><template #default="{row}">{{ row.phone || '-' }}</template></el-table-column> </div>
<el-table-column label="注册时间" width="160"><template #default="{row}">{{ row.createdAt?.slice(0,10) }}</template></el-table-column> <el-table :data="list" stripe v-loading="loading">
<el-table-column label="操作"><template #default="{row}"><el-tag size="small">{{ row._id?.slice(-6) }}</el-tag></template></el-table-column> <el-table-column prop="nickName" label="昵称" min-width="110" />
<el-table-column label="OpenID" min-width="150">
<template #default="{ row }">{{ (row.openid || '').slice(0, 14) }}…</template>
</el-table-column>
<el-table-column label="注册时间" width="160">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最近登录" width="160">
<template #default="{ row }">{{ fmt(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button size="small" @click="openQuota(row)">加问答额度</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-pagination v-model:current-page="page" layout="prev,pager,next,total" :total="total" :page-size="20" style="margin-top:14px" @current-change="load" /> <el-pagination background layout="total, prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="load" style="margin-top: 12px" />
<el-dialog v-model="quotaDlg" title="增加问答额度" width="360px">
<el-form label-width="90px">
<el-form-item label="用户">{{ quotaUser?.nickName }}</el-form-item>
<el-form-item label="OpenID">{{ (quotaUser?.openid || '').slice(0, 16) }}…</el-form-item>
<el-form-item label="增加次数">
<el-input-number v-model="quotaAmount" :min="1" :max="100" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="quotaDlg = false">取消</el-button>
<el-button type="primary" @click="addQuota">确认</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { ref, onMounted } from 'vue'
import { apiGet } from '../api' import { ElMessage } from 'element-plus'
const list = ref([]); const total = ref(0); const page = ref(1); const q = ref('') import { apiGet, apiPost } from '../api'
const list = ref([]), total = ref(0), page = ref(1), pageSize = 20, q = ref(''), loading = ref(false)
const quotaDlg = ref(false), quotaUser = ref(null), quotaAmount = ref(5)
function fmt(t) { return t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '—' }
function search() { page.value = 1; load() }
async function load() { async function load() {
const r = await apiGet(`/api/admin/users?q=${q.value}&page=${page.value}`) loading.value = true
list.value = r.users || []; total.value = r.total || 0 try {
const r = await apiGet(`/api/admin/users?page=${page.value}&limit=${pageSize}&search=${encodeURIComponent(q.value)}`)
list.value = r.items || []
total.value = r.pagination?.total || 0
} finally { loading.value = false }
}
function openQuota(row) { quotaUser.value = row; quotaAmount.value = 5; quotaDlg.value = true }
async function addQuota() {
try {
await apiPost('/api/admin/ai-chat/add-quota', { openid: quotaUser.value.openid, amount: quotaAmount.value })
ElMessage.success('已增加')
quotaDlg.value = false
} catch (e) { ElMessage.error(e.message || '操作失败') }
} }
onMounted(load) onMounted(load)
</script> </script>
<style scoped>.head{display:flex;justify-content:space-between;align-items:center}</style>
<style scoped>
.page { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; }
</style>
+122 -1
View File
@@ -1,7 +1,7 @@
const express = require('express') const express = require('express')
const router = express.Router() const router = express.Router()
const mongoose = require('mongoose') const mongoose = require('mongoose')
const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog } = require('../models') const { User, Order, Knowledge, ShopItem, AdminLog, AIChatQuota, Feedback, LoginLog, Trend, KnowledgeLit } = require('../models')
const { authMiddleware, requirePermission } = require('../middleware/auth') const { authMiddleware, requirePermission } = require('../middleware/auth')
const ApiResponse = require('../utils/response') const ApiResponse = require('../utils/response')
const logger = require('../utils/logger') const logger = require('../utils/logger')
@@ -1077,4 +1077,125 @@ router.get('/login-logs', requirePermission('user:read'), async (req, res) => {
} }
}) })
/**
* ===== 趋势资讯管理 CRUD =====
*/
router.get('/trends', requirePermission('trend:read'), async (req, res) => {
try {
const { page = 1, limit = 20, keyword = '' } = req.query
const query = keyword ? { title: { $regex: keyword, $options: 'i' } } : {}
const total = await Trend.countDocuments(query)
const items = await Trend.find(query).sort({ newsDate: -1, createdAt: -1 })
.skip((parseInt(page) - 1) * parseInt(limit)).limit(parseInt(limit)).lean()
return ApiResponse.paginated(res, items, { page: parseInt(page), limit: parseInt(limit), total })
} catch (error) {
logger.error('获取趋势列表失败:', error)
return ApiResponse.serverError(res, '获取趋势列表失败')
}
})
router.post('/trends', requirePermission('trend:write'), async (req, res) => {
try {
const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate } = req.body
if (!title) return ApiResponse.error(res, '标题不能为空', 400)
const item = new Trend({ title, summary, source, sourceUrl, category, tags, hot: !!hot, picked: !!picked, newsDate: newsDate || new Date() })
await item.save()
await logAction(req, 'create', 'trend', item._id, { title })
return ApiResponse.success(res, item, '创建成功')
} catch (error) {
logger.error('创建趋势失败:', error)
return ApiResponse.serverError(res, '创建失败')
}
})
router.put('/trends/:id', requirePermission('trend:write'), async (req, res) => {
try {
const { title, summary, source, sourceUrl, category, tags, hot, picked, newsDate } = req.body
const item = await Trend.findByIdAndUpdate(req.params.id,
{ title, summary, source, sourceUrl, category, tags, hot: !!hot, picked: !!picked, ...(newsDate ? { newsDate } : {}) },
{ new: true })
if (!item) return ApiResponse.notFound(res, '趋势不存在')
await logAction(req, 'update', 'trend', item._id, { title })
return ApiResponse.success(res, item, '更新成功')
} catch (error) {
logger.error('更新趋势失败:', error)
return ApiResponse.serverError(res, '更新失败')
}
})
router.delete('/trends/:id', requirePermission('trend:write'), async (req, res) => {
try {
const item = await Trend.findByIdAndDelete(req.params.id)
if (!item) return ApiResponse.notFound(res, '趋势不存在')
await logAction(req, 'delete', 'trend', item._id, { title: item.title })
return ApiResponse.success(res, {}, '删除成功')
} catch (error) {
logger.error('删除趋势失败:', error)
return ApiResponse.serverError(res, '删除失败')
}
})
/**
* ===== 增强看板:产品全量指标 + 最近访问记录 =====
* GET /api/admin/dashboard-v2
*/
router.get('/dashboard-v2', requirePermission('dashboard:read'), async (req, res) => {
try {
const [
totalUsers, newUsers7d, totalKnowledge, approvedKnowledge, withQuiz,
totalTrends, totalFeedbacks, pendingFeedbacks, totalOrders, paidOrders,
totalLits, litUsers, recentLogins, litByDimAgg, aiStatsRaw
] = await Promise.all([
User.countDocuments({}),
User.countDocuments({ createdAt: { $gte: new Date(Date.now() - 7 * 86400000) } }),
Knowledge.countDocuments({}),
Knowledge.countDocuments({ status: 'approved' }),
Knowledge.countDocuments({ 'options.0': { $exists: true } }),
Trend.countDocuments({}),
Feedback.countDocuments({}),
Feedback.countDocuments({ status: 'pending' }),
Order.countDocuments({}),
Order.countDocuments({ status: 'paid' }),
KnowledgeLit.countDocuments({}),
KnowledgeLit.distinct('openid'),
LoginLog.find({}).sort({ createdAt: -1 }).limit(10).lean(),
KnowledgeLit.aggregate([{ $group: { _id: '$dim', count: { $sum: 1 } } }]),
AIChatQuota.aggregate([{ $group: { _id: null, used: { $sum: '$usedQuota' }, users: { $sum: 1 } } }]),
])
const litByDim = {}
for (let i = 1; i <= 5; i++) litByDim[i] = 0
litByDimAgg.forEach(r => { if (r._id >= 1 && r._id <= 5) litByDim[r._id] = r.count })
const DIM_NAMES = { 1: 'AI 起源', 2: 'AI 发展', 3: 'AI 当前', 4: 'AI 学习', 5: 'AI 趋势' }
const ai = aiStatsRaw[0] || { used: 0, users: 0 }
return ApiResponse.success(res, {
stats: {
totalUsers, newUsers7d,
totalKnowledge, approvedKnowledge, withQuiz,
totalTrends, totalFeedbacks, pendingFeedbacks,
totalOrders, paidOrders,
totalLits, litUsers: litUsers.length,
aiChatUsed: ai.used, aiChatUsers: ai.users,
},
litByDim: Object.entries(litByDim).map(([dim, count]) => ({ dim: Number(dim), name: DIM_NAMES[dim], count })),
recentLogins: recentLogins.map(l => ({
openid: l.openid,
type: l.type,
method: l.method,
ip: l.ip,
success: l.success,
failReason: l.failReason,
userAgent: (l.userAgent || '').slice(0, 80),
createdAt: l.createdAt,
})),
})
} catch (error) {
logger.error('获取增强看板失败:', error)
return ApiResponse.serverError(res, '获取看板失败')
}
})
module.exports = router module.exports = router