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