From c5ade459a5e25ec6e4c78863655325dca05d77fb Mon Sep 17 00:00:00 2001 From: txai-dev Date: Sun, 13 Sep 2026 17:45:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=8D=87=E7=BA=A7=E2=80=94=E2=80=94=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=8F=9C=E5=8D=95=E6=97=A0=E6=95=B0=E6=8D=AE=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0(items=E5=AD=97=E6=AE=B5=E4=B8=8D=E5=8C=B9=E9=85=8D+tr?= =?UTF-8?q?ends=E6=8E=A5=E5=8F=A3=E7=BC=BA=E5=A4=B1)=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9F=A5=E8=AF=86=E7=AE=A1=E7=90=86(=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8/=E6=A3=80=E6=B5=8B=E9=A2=98=E7=BC=96=E8=BE=91)/?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E6=97=A5=E5=BF=97/=E7=9C=8B=E6=9D=BFv2(?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E8=AE=BF=E9=97=AE+=E7=82=B9=E4=BA=AE?= =?UTF-8?q?=E5=88=86=E5=B8=83+=E5=85=A8=E9=87=8F=E6=8C=87=E6=A0=87)?= =?UTF-8?q?=EF=BC=8Ctrends=20CRUD=EF=BC=8C=E7=94=A8=E6=88=B7/=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E5=88=97=E8=A1=A8=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/App.vue | 4 + admin/src/views/Dashboard.vue | 89 ++++++++++++++------- admin/src/views/Feedbacks.vue | 45 +++++++---- admin/src/views/Knowledge.vue | 140 ++++++++++++++++++++++++++++++++++ admin/src/views/Logs.vue | 55 +++++++++++++ admin/src/views/Trends.vue | 104 ++++++++++++++++++------- admin/src/views/Users.vue | 79 +++++++++++++++---- server/src/routes/admin.js | 123 ++++++++++++++++++++++++++++- 8 files changed, 555 insertions(+), 84 deletions(-) create mode 100644 admin/src/views/Knowledge.vue create mode 100644 admin/src/views/Logs.vue diff --git a/admin/src/App.vue b/admin/src/App.vue index 231bba0..3c36c9a 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -25,9 +25,11 @@ import { onMounted, ref } from 'vue' import { getToken, setToken, clearToken, apiPost } from './api' import Dashboard from './views/Dashboard.vue' import Users from './views/Users.vue' +import Knowledge from './views/Knowledge.vue' import Trends from './views/Trends.vue' import Quotas from './views/Quotas.vue' import Feedbacks from './views/Feedbacks.vue' +import Logs from './views/Logs.vue' const token = ref(getToken()) const view = ref('dashboard') @@ -36,9 +38,11 @@ const username = ref(''), password = ref(''), busy = ref(false), error = ref('') const menus = [ { key: 'dashboard', label: '数据看板', icon: '📊', comp: Dashboard }, { key: 'users', label: '用户管理', icon: '👥', comp: Users }, + { key: 'knowledge', label: '知识管理', icon: '📚', comp: Knowledge }, { key: 'trends', label: '趋势管理', icon: '📈', comp: Trends }, { key: 'quotas', label: '问答额度', icon: '💬', comp: Quotas }, { key: 'feedbacks', label: '反馈管理', icon: '📝', comp: Feedbacks }, + { key: 'logs', label: '访问日志', icon: '🗂️', comp: Logs }, ] async function login() { diff --git a/admin/src/views/Dashboard.vue b/admin/src/views/Dashboard.vue index 142c8d3..f1bad0f 100644 --- a/admin/src/views/Dashboard.vue +++ b/admin/src/views/Dashboard.vue @@ -1,40 +1,77 @@ diff --git a/admin/src/views/Feedbacks.vue b/admin/src/views/Feedbacks.vue index b7044ab..d58b87d 100644 --- a/admin/src/views/Feedbacks.vue +++ b/admin/src/views/Feedbacks.vue @@ -1,26 +1,39 @@ + + + diff --git a/admin/src/views/Knowledge.vue b/admin/src/views/Knowledge.vue new file mode 100644 index 0000000..29f89e3 --- /dev/null +++ b/admin/src/views/Knowledge.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/admin/src/views/Logs.vue b/admin/src/views/Logs.vue new file mode 100644 index 0000000..6e2e938 --- /dev/null +++ b/admin/src/views/Logs.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/admin/src/views/Trends.vue b/admin/src/views/Trends.vue index 9008451..d1a5acd 100644 --- a/admin/src/views/Trends.vue +++ b/admin/src/views/Trends.vue @@ -1,43 +1,93 @@ + - + + diff --git a/admin/src/views/Users.vue b/admin/src/views/Users.vue index 529f0f7..6162f51 100644 --- a/admin/src/views/Users.vue +++ b/admin/src/views/Users.vue @@ -1,23 +1,74 @@ + - + + diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 909b982..f1ced70 100755 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,7 +1,7 @@ const express = require('express') const router = express.Router() 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 ApiResponse = require('../utils/response') 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