feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交
- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台) - AI互动调度(点赞/收藏/评论/转发) - 日志时间改为北京时间 - 评论达上限后继续执行点赞收藏转发 - 一键登出全部功能 - 浅色主题UI
This commit is contained in:
0
frontend/-H
Normal file
0
frontend/-H
Normal file
0
frontend/-d
Normal file
0
frontend/-d
Normal file
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI虚拟用户新闻互动系统</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue Router history mode
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://ai-virtual-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
}
|
||||
1777
frontend/package-lock.json
generated
Normal file
1777
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ai-virtual-news-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.4.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.4.3",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
76
frontend/src/api/index.js
Normal file
76
frontend/src/api/index.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
request.interceptors.response.use(
|
||||
res => {
|
||||
const data = res.data
|
||||
if (data.code && data.code !== 200) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
return data
|
||||
},
|
||||
err => {
|
||||
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// Dashboard
|
||||
export const getDashboard = () => request.get('/dashboard')
|
||||
export const getTokenTrend = (days = 30) => request.get('/dashboard/token-trend', { params: { days } })
|
||||
export const getMonthlyTokenTrend = () => request.get('/dashboard/monthly-token-trend')
|
||||
|
||||
// Users
|
||||
export const getUsers = (params) => request.get('/users', { params })
|
||||
export const createUser = (data) => request.post('/users', data)
|
||||
export const updateUser = (id, data) => request.put(`/users/${id}`, data)
|
||||
export const deleteUser = (id) => request.delete(`/users/${id}`)
|
||||
export const batchUserAction = (data) => request.post('/users/batch/action', data)
|
||||
export const loginUser = (id) => request.post(`/users/${id}/login`)
|
||||
export const logoutUser = (id) => request.post(`/users/${id}/logout`)
|
||||
export const generatePersonality = (id) => request.post(`/users/${id}/personality/generate`)
|
||||
export const updatePersonality = (id, data) => request.put(`/users/${id}/personality`, data)
|
||||
export const importUsers = (formData) => request.post('/users/excel/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
export const downloadTemplate = () => request.get('/users/excel/template', { responseType: 'blob' })
|
||||
export const exportUsers = () => request.get('/users/excel/export', { responseType: 'blob' })
|
||||
export const deduplicateUsers = () => request.post('/users/deduplicate')
|
||||
export const clearAllUsers = () => request.post('/users/clear-all')
|
||||
export const loginAllUsers = () => request.post('/users/login-all')
|
||||
export const syncAllProfiles = () => request.post('/users/sync-all-profiles')
|
||||
export const cancelInteraction = (id) => request.post(`/interactions/${id}/cancel`)
|
||||
export const runInteractionNow = () => request.post('/system/interaction/run-now')
|
||||
|
||||
// Interactions
|
||||
export const getInteractions = (params) => request.get('/interactions', { params })
|
||||
export const retryInteraction = (id) => request.post(`/interactions/${id}/retry`)
|
||||
export const exportInteractions = (params) => request.get('/interactions/export', { params, responseType: 'blob' })
|
||||
|
||||
// AI Models
|
||||
export const getAIModels = () => request.get('/ai-models')
|
||||
export const createAIModel = (data) => request.post('/ai-models', data)
|
||||
export const updateAIModel = (id, data) => request.put(`/ai-models/${id}`, data)
|
||||
export const deleteAIModel = (id) => request.delete(`/ai-models/${id}`)
|
||||
export const testAIModel = (data) => request.post('/ai-models/test', data)
|
||||
|
||||
// System
|
||||
export const getSystemConfigs = () => request.get('/system/configs')
|
||||
export const updateSystemConfigs = (data) => request.put('/system/configs', data)
|
||||
export const toggleScheduler = (enabled) => request.post('/system/scheduler/toggle', { enabled })
|
||||
export const resetAllSessions = () => request.post('/system/sessions/reset-all')
|
||||
|
||||
// Logs
|
||||
export const getLoginLogs = (params) => request.get('/logs/login', { params })
|
||||
export const getLogFiles = () => request.get('/logs/files')
|
||||
export const tailLogFile = (filename, lines = 100) => request.get(`/logs/files/${filename}/tail`, { params: { lines } })
|
||||
|
||||
export default request
|
||||
export const uploadAvatar = (userId, formData) => request.post(`/users/${userId}/upload-avatar`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
219
frontend/src/layouts/MainLayout.vue
Normal file
219
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">🤖</span>
|
||||
<span class="logo-text">AI互动系统</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" router class="sidebar-menu">
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>数据看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>虚拟用户</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/interactions">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span>互动记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ai-models">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span>AI模型配置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/scheduler">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>调度设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>日志管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="sidebar-footer">
|
||||
<div class="system-status">
|
||||
<span class="status-dot" :class="schedulerEnabled ? 'active' : 'paused'"></span>
|
||||
<span>{{ schedulerEnabled ? '调度运行中' : '调度已暂停' }}</span>
|
||||
</div>
|
||||
<div class="version">v1.0.0</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="page-title-bar">{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="online-badge">
|
||||
<span class="pulse"></span>
|
||||
<span>{{ onlineUsers }} 在线</span>
|
||||
</div>
|
||||
<div class="token-badge">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>今日剩余 {{ tokenRemaining.toLocaleString() }}</span>
|
||||
</div>
|
||||
<el-button size="small" circle @click="refreshAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getDashboard } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
const currentTitle = computed(() => route.meta?.title || 'AI虚拟用户新闻互动系统')
|
||||
|
||||
const onlineUsers = ref(0)
|
||||
const tokenRemaining = ref(0)
|
||||
const schedulerEnabled = ref(true)
|
||||
let timer = null
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
onlineUsers.value = res.data?.online_users || 0
|
||||
tokenRemaining.value = res.data?.token_stats?.remaining || 0
|
||||
schedulerEnabled.value = res.data?.system_status?.scheduler_enabled ?? true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
fetchStatus()
|
||||
window.dispatchEvent(new CustomEvent('page-refresh'))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStatus()
|
||||
timer = setInterval(fetchStatus, 300000) // 5min
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.logo-icon { font-size: 24px; }
|
||||
.logo-text {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
:deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.system-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.active {
|
||||
background: var(--color-accent-green);
|
||||
box-shadow: 0 0 6px var(--color-accent-green);
|
||||
animation: blink 2s infinite;
|
||||
}
|
||||
.status-dot.paused { background: var(--color-accent-orange); }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
.version { font-size: 11px; color: var(--color-text-muted); opacity: 0.5; }
|
||||
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-title-bar { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.online-badge, .token-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.pulse {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-green);
|
||||
animation: blink 1.5s infinite;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn, size: 'default' })
|
||||
app.mount('#app')
|
||||
22
frontend/src/router/index.js
Normal file
22
frontend/src/router/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||
{ path: 'users', component: () => import('@/views/Users.vue'), meta: { title: '虚拟用户管理' } },
|
||||
{ path: 'interactions', component: () => import('@/views/Interactions.vue'), meta: { title: '互动记录' } },
|
||||
{ path: 'ai-models', component: () => import('@/views/AIModels.vue'), meta: { title: 'AI模型配置' } },
|
||||
{ path: 'scheduler', component: () => import('@/views/Scheduler.vue'), meta: { title: '调度设置' } },
|
||||
{ path: 'logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志管理' } },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
131
frontend/src/styles/global.css
Normal file
131
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,131 @@
|
||||
:root {
|
||||
--color-bg: #f5f7fa;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-card: #ffffff;
|
||||
--color-border: #e4e7ed;
|
||||
--color-border-light: #ebeef5;
|
||||
--color-text: #303133;
|
||||
--color-text-muted: #909399;
|
||||
--color-accent: #409eff;
|
||||
--color-accent-green: #67c23a;
|
||||
--color-accent-orange: #e6a23c;
|
||||
--color-accent-red: #f56c6c;
|
||||
--color-accent-purple: #9c7ff5;
|
||||
--color-accent-yellow: #e6a23c;
|
||||
--sidebar-width: 220px;
|
||||
--shadow-sm: 0 1px 4px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-bg-color: #ffffff !important;
|
||||
--el-table-tr-bg-color: #ffffff !important;
|
||||
--el-table-header-bg-color: #f5f7fa !important;
|
||||
--el-table-border-color: var(--color-border) !important;
|
||||
--el-table-text-color: var(--color-text) !important;
|
||||
--el-table-header-text-color: #606266 !important;
|
||||
--el-table-row-hover-bg-color: #f0f7ff !important;
|
||||
--el-table-current-row-bg-color: #ecf5ff !important;
|
||||
--el-fill-color-lighter: #f5f7fa !important;
|
||||
border-radius: 8px !important;
|
||||
overflow: hidden !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
}
|
||||
.el-table__body tr.el-table__row { background: #ffffff !important; }
|
||||
.el-table__body tr.el-table__row--striped td { background: #fafafa !important; }
|
||||
.el-table__header-wrapper { background: #f5f7fa !important; }
|
||||
|
||||
.el-card {
|
||||
--el-card-bg-color: #ffffff !important;
|
||||
--el-card-border-color: var(--color-border) !important;
|
||||
color: var(--color-text) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: #ffffff !important;
|
||||
--el-dialog-border-radius: 12px !important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
.el-dialog__header { border-bottom: 1px solid var(--color-border) !important; padding: 16px 20px !important; }
|
||||
.el-dialog__footer { border-top: 1px solid var(--color-border) !important; padding: 12px 20px !important; }
|
||||
|
||||
.el-input__wrapper, .el-select__wrapper, .el-textarea__inner {
|
||||
background-color: #ffffff !important;
|
||||
box-shadow: 0 0 0 1px var(--color-border) inset !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
.el-input__wrapper:hover, .el-select__wrapper:hover { box-shadow: 0 0 0 1px #c0c4cc inset !important; }
|
||||
.el-input__wrapper.is-focus, .el-select__wrapper.is-focused { box-shadow: 0 0 0 1px var(--color-accent) inset !important; }
|
||||
.el-input__inner, .el-textarea__inner { color: var(--color-text) !important; background: transparent !important; }
|
||||
|
||||
.el-button--primary { background: var(--color-accent) !important; border-color: var(--color-accent) !important; color: #ffffff !important; }
|
||||
.el-button--primary:hover { background: #66b1ff !important; border-color: #66b1ff !important; }
|
||||
.el-button--success { background: var(--color-accent-green) !important; border-color: var(--color-accent-green) !important; color: #fff !important; }
|
||||
.el-button--danger { background: var(--color-accent-red) !important; border-color: var(--color-accent-red) !important; color: #fff !important; }
|
||||
.el-button--default { background: #ffffff !important; border-color: var(--color-border) !important; color: var(--color-text) !important; }
|
||||
.el-button--default:hover { border-color: var(--color-accent) !important; color: var(--color-accent) !important; }
|
||||
.el-button--warning { background: var(--color-accent-orange) !important; border-color: var(--color-accent-orange) !important; color: #fff !important; }
|
||||
|
||||
.el-tag { border-radius: 6px !important; font-size: 12px !important; }
|
||||
.el-tag--success { background: #f0f9eb !important; border-color: #b3e19d !important; color: #67c23a !important; }
|
||||
.el-tag--danger { background: #fef0f0 !important; border-color: #fbc4c4 !important; color: #f56c6c !important; }
|
||||
.el-tag--warning { background: #fdf6ec !important; border-color: #f5dab1 !important; color: #e6a23c !important; }
|
||||
.el-tag--info { background: #f4f4f5 !important; border-color: #d3d4d6 !important; color: #909399 !important; }
|
||||
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: transparent !important;
|
||||
--el-pagination-text-color: var(--color-text) !important;
|
||||
--el-pagination-button-color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.el-form-item__label { color: #606266 !important; font-weight: 500 !important; }
|
||||
.el-radio__label { color: var(--color-text) !important; }
|
||||
.el-checkbox__label { color: var(--color-text) !important; }
|
||||
|
||||
.el-menu { background: transparent !important; border: none !important; }
|
||||
.el-menu-item, .el-sub-menu__title {
|
||||
color: #606266 !important;
|
||||
border-radius: 8px !important;
|
||||
margin: 2px 8px !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
.el-menu-item.is-active { background: #ecf5ff !important; color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||
.el-menu-item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||
|
||||
.el-dropdown-menu { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||
.el-dropdown-menu__item { color: var(--color-text) !important; }
|
||||
.el-dropdown-menu__item:hover { background: #f0f7ff !important; color: var(--color-accent) !important; }
|
||||
|
||||
.el-select-dropdown { background: #ffffff !important; border: 1px solid var(--color-border) !important; box-shadow: var(--shadow-md) !important; }
|
||||
.el-select-dropdown__item { color: var(--color-text) !important; }
|
||||
.el-select-dropdown__item.is-hovering { background: #f0f7ff !important; }
|
||||
.el-select-dropdown__item.is-selected { color: var(--color-accent) !important; font-weight: 600 !important; }
|
||||
|
||||
.el-popover { background: #ffffff !important; border: 1px solid var(--color-border) !important; color: var(--color-text) !important; }
|
||||
.el-alert { border-radius: 8px !important; }
|
||||
.el-switch__core { border-color: #dcdfe6 !important; }
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
|
||||
|
||||
.page-container { padding: 24px; height: 100%; overflow-y: auto; background: var(--color-bg); }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 600; color: var(--color-text); }
|
||||
.stat-card { background: #ffffff; border: 1px solid var(--color-border); border-radius: 12px; padding: 20px; box-shadow: var(--shadow-sm); transition: box-shadow 0.2s; }
|
||||
.stat-card:hover { box-shadow: var(--shadow-md); }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: var(--color-accent); }
|
||||
.stat-label { font-size: 13px; color: var(--color-text-muted); margin-top: 4px; }
|
||||
.sidebar { background: #ffffff !important; border-right: 1px solid var(--color-border) !important; box-shadow: 1px 0 8px rgba(0,0,0,0.04) !important; }
|
||||
239
frontend/src/views/AIModels.vue
Normal file
239
frontend/src/views/AIModels.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">AI模型配置</span>
|
||||
<el-button type="primary" size="small" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon> 添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="models-grid">
|
||||
<div v-for="m in models" :key="m.id" class="model-card" :class="{ 'is-default': m.is_default }">
|
||||
<div class="model-head">
|
||||
<div class="model-name">
|
||||
<span class="provider-badge" :class="m.provider">{{ providerLabels[m.provider] || m.provider }}</span>
|
||||
<span class="model-title">{{ m.model_name }}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
||||
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-meta">
|
||||
<span>版本: {{ m.model_version || '--' }}</span>
|
||||
<span>温度: {{ m.temperature }}</span>
|
||||
<span>Max Tokens: {{ m.max_tokens }}</span>
|
||||
<span>超时: {{ m.timeout_seconds }}s</span>
|
||||
<span>API Key: {{ m.has_api_key ? '✓ 已配置' : '✗ 未配置' }}</span>
|
||||
</div>
|
||||
<div class="model-actions">
|
||||
<el-button size="small" @click="openTest(m)">测试</el-button>
|
||||
<el-button size="small" @click="openEdit(m)">编辑</el-button>
|
||||
<el-button v-if="!m.is_default" size="small" type="primary" @click="setDefault(m)">设为默认</el-button>
|
||||
<el-button size="small" type="danger" @click="doDelete(m)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!models.length" class="empty-state">
|
||||
<el-empty description="暂无模型配置,请添加AI模型" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editModel ? '编辑模型' : '添加AI模型'" width="540px">
|
||||
<el-form :model="form" label-width="110px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="模型名称" prop="model_name">
|
||||
<el-input v-model="form.model_name" placeholder="如: GPT-4 生产环境" />
|
||||
</el-form-item>
|
||||
<el-form-item label="提供商" prop="provider">
|
||||
<el-select v-model="form.provider" style="width:100%" @change="onProviderChange">
|
||||
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="form.api_key" type="password" show-password :placeholder="editModel ? '不修改请留空' : '输入API Key'" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型版本">
|
||||
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="温度">
|
||||
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Max Tokens">
|
||||
<el-input-number v-model="form.max_tokens" :min="100" :max="32000" :step="100" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.timeout_seconds" :min="5" :max="300" style="width:160px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submit" :loading="submitting">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Test Dialog -->
|
||||
<el-dialog v-model="testVisible" title="模型测试" width="560px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="测试指令">
|
||||
<el-input v-model="testPrompt" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="testResult" class="test-result">
|
||||
<div class="result-meta">
|
||||
<el-tag :type="testResult.success ? 'success' : 'danger'">
|
||||
{{ testResult.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
<span v-if="testResult.success" style="font-size:12px;color:var(--color-text-muted)">
|
||||
耗时 {{ testResult.elapsed_seconds }}s · 消耗 {{ testResult.tokens }} tokens
|
||||
</span>
|
||||
</div>
|
||||
<div class="result-content" style="white-space:pre-wrap;word-break:break-all;">
|
||||
{{ testResult.success ? (testResult.content || '(响应为空)') : testResult.error }}
|
||||
</div>
|
||||
<div v-if="testResult.success" style="margin-top:8px;font-size:12px;color:var(--color-text-muted)">
|
||||
耗时 {{ testResult.elapsed_seconds }}s · Token {{ testResult.tokens }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="testVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="runTest" :loading="testing">发送测试</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getAIModels, createAIModel, updateAIModel, deleteAIModel, testAIModel } from '@/api'
|
||||
|
||||
const models = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editModel = ref(null)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
const testVisible = ref(false)
|
||||
const testingModel = ref(null)
|
||||
const testPrompt = ref('你好,请简单介绍一下你自己,并写一条关于"人工智能改变新闻业"的新闻评论。')
|
||||
const testResult = ref(null)
|
||||
const testing = ref(false)
|
||||
|
||||
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
||||
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
|
||||
|
||||
async function load() {
|
||||
const res = await getAIModels()
|
||||
models.value = res.data || []
|
||||
}
|
||||
|
||||
const PROVIDER_DEFAULTS = {
|
||||
openai: { api_base_url: 'https://api.openai.com/v1', model_version: 'gpt-4-turbo' },
|
||||
zhipu: { api_base_url: 'https://open.bigmodel.cn/api/paas/v4', model_version: 'glm-4' },
|
||||
wenxin: { api_base_url: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat', model_version: 'ERNIE-Bot-4' },
|
||||
qianwen: { api_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model_version: 'qwen-turbo' },
|
||||
local: { api_base_url: 'http://127.0.0.1:11434/v1', model_version: 'qwen2' },
|
||||
}
|
||||
|
||||
function onProviderChange(provider) {
|
||||
const def = PROVIDER_DEFAULTS[provider] || {}
|
||||
form.api_base_url = def.api_base_url || ''
|
||||
form.model_version = def.model_version || ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editModel.value = null
|
||||
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(m) {
|
||||
editModel.value = m
|
||||
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = { ...form }
|
||||
if (editModel.value) {
|
||||
if (!data.api_key) delete data.api_key
|
||||
await updateAIModel(editModel.value.id, data)
|
||||
} else {
|
||||
await createAIModel(data)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function doDelete(m) {
|
||||
await ElMessageBox.confirm(`确认删除模型 "${m.model_name}"?`, '确认', { type: 'warning' })
|
||||
await deleteAIModel(m.id)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
}
|
||||
|
||||
async function setDefault(m) {
|
||||
await updateAIModel(m.id, { is_default: 1 })
|
||||
ElMessage.success(`"${m.model_name}" 已设为默认模型`)
|
||||
load()
|
||||
}
|
||||
|
||||
function openTest(m) {
|
||||
testingModel.value = m
|
||||
testResult.value = null
|
||||
testVisible.value = true
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await testAIModel({ model_id: testingModel.value.id, test_prompt: testPrompt.value })
|
||||
testResult.value = res.data
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.models-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; }
|
||||
.model-card {
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border);
|
||||
border-radius: 12px; padding: 18px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.model-card.is-default { border-color: var(--color-accent-green); box-shadow: 0 0 12px rgba(63,185,80,0.15); }
|
||||
.model-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.model-name { display: flex; align-items: center; gap: 8px; }
|
||||
.provider-badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; font-weight: 600; }
|
||||
.provider-badge.openai { background: rgba(16,163,127,0.15); color: #10a37f; }
|
||||
.provider-badge.zhipu { background: rgba(88,166,255,0.15); color: var(--color-accent); }
|
||||
.provider-badge.wenxin { background: rgba(240,136,62,0.15); color: var(--color-accent-orange); }
|
||||
.provider-badge.qianwen { background: rgba(210,153,34,0.15); color: var(--color-accent-yellow); }
|
||||
.provider-badge.local { background: rgba(188,140,255,0.15); color: var(--color-accent-purple); }
|
||||
.model-title { font-size: 15px; font-weight: 600; }
|
||||
.model-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--color-text-muted); margin-bottom: 14px; }
|
||||
.model-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.test-result { margin-top: 16px; }
|
||||
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||
.empty-state { grid-column: 1/-1; padding: 40px; }
|
||||
</style>
|
||||
275
frontend/src/views/Dashboard.vue
Normal file
275
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="page-container dashboard">
|
||||
<!-- Top stat cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" v-for="s in statCards" :key="s.label">
|
||||
<div class="stat-icon" :style="{ background: s.bg }">
|
||||
<el-icon :style="{ color: s.color, fontSize: '20px' }">
|
||||
<component :is="s.icon" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today interaction summary -->
|
||||
<div class="section-row">
|
||||
<el-card class="today-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>今日互动统计</span>
|
||||
<el-tag type="info" size="small">{{ todayDate }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="interact-bars">
|
||||
<div class="interact-item" v-for="it in interactTypes" :key="it.key">
|
||||
<div class="interact-label">{{ it.label }}</div>
|
||||
<el-progress
|
||||
:percentage="getPercent(it.key)"
|
||||
:color="it.color"
|
||||
:stroke-width="10"
|
||||
:show-text="false"
|
||||
class="interact-bar"
|
||||
/>
|
||||
<div class="interact-count" :style="{ color: it.color }">
|
||||
{{ todayStats[it.key] || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="system-card" shadow="never">
|
||||
<template #header><span>系统运行状态</span></template>
|
||||
<div class="sys-items">
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">运行时长</span>
|
||||
<span class="sys-val">{{ systemStatus.uptime || '--' }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">在线用户</span>
|
||||
<span class="sys-val accent-green">{{ onlineUsers }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">今日Token消耗</span>
|
||||
<span class="sys-val accent-blue">{{ tokenStats.today_used?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">Token剩余</span>
|
||||
<span class="sys-val" :class="tokenLow ? 'accent-red' : 'accent-green'">
|
||||
{{ tokenStats.remaining?.toLocaleString() || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">今日AI调用</span>
|
||||
<span class="sys-val">{{ tokenStats.today_calls || 0 }} 次</span>
|
||||
</div>
|
||||
<div class="sys-item">
|
||||
<span class="sys-key">调度器状态</span>
|
||||
<el-tag :type="systemStatus.scheduler_enabled ? 'success' : 'warning'" size="small">
|
||||
{{ systemStatus.scheduler_enabled ? '运行中' : '已暂停' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="tokenPercent"
|
||||
:color="tokenLow ? '#f85149' : '#58a6ff'"
|
||||
:stroke-width="8"
|
||||
style="margin-top: 16px;"
|
||||
>
|
||||
<span style="font-size:11px;color:var(--color-text-muted)">Token使用率 {{ tokenPercent }}%</span>
|
||||
</el-progress>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="charts-row">
|
||||
<el-card class="chart-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>近30天 Token消耗趋势</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" @click="loadTokenTrend(30)">30天</el-button>
|
||||
<el-button size="small" @click="loadTokenTrend(7)">7天</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="dailyChart" class="chart-box"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="chart-card" shadow="never">
|
||||
<template #header><span>近12个月 Token消耗</span></template>
|
||||
<div ref="monthlyChart" class="chart-box"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDashboard, getTokenTrend, getMonthlyTokenTrend } from '@/api'
|
||||
|
||||
const todayDate = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
// data
|
||||
const userStats = ref({})
|
||||
const todayStats = ref({})
|
||||
const tokenStats = ref({})
|
||||
const systemStatus = ref({})
|
||||
const onlineUsers = ref(0)
|
||||
|
||||
const dailyChart = ref(null)
|
||||
const monthlyChart = ref(null)
|
||||
let dailyChartInst = null
|
||||
let monthlyChartInst = null
|
||||
|
||||
const interactTypes = [
|
||||
{ key: 'comment', label: '评论', color: '#58a6ff' },
|
||||
{ key: 'reply', label: '回复', color: '#bc8cff' },
|
||||
{ key: 'like', label: '点赞', color: '#3fb950' },
|
||||
{ key: 'collect', label: '收藏', color: '#f0883e' },
|
||||
{ key: 'forward', label: '转发', color: '#d29922' },
|
||||
]
|
||||
|
||||
const maxInteract = computed(() => {
|
||||
const vals = interactTypes.map(t => todayStats.value[t.key] || 0)
|
||||
return Math.max(...vals, 1)
|
||||
})
|
||||
const getPercent = (key) => Math.round(((todayStats.value[key] || 0) / maxInteract.value) * 100)
|
||||
const tokenPercent = computed(() => {
|
||||
const used = tokenStats.value.today_used || 0
|
||||
const limit = tokenStats.value.daily_limit || 1
|
||||
return Math.min(Math.round((used / limit) * 100), 100)
|
||||
})
|
||||
const tokenLow = computed(() => tokenPercent.value > 80)
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ label: '用户总数', value: userStats.value.total || 0, icon: 'User', color: '#58a6ff', bg: 'rgba(88,166,255,0.1)' },
|
||||
{ label: '正常启用', value: userStats.value.normal || 0, icon: 'CircleCheck', color: '#3fb950', bg: 'rgba(63,185,80,0.1)' },
|
||||
{ label: '当前在线', value: onlineUsers.value, icon: 'Connection', color: '#bc8cff', bg: 'rgba(188,140,255,0.1)' },
|
||||
{ label: '今日互动', value: todayStats.value.total || 0, icon: 'ChatDotRound', color: '#f0883e', bg: 'rgba(240,136,62,0.1)' },
|
||||
{ label: '登录失效', value: userStats.value.abnormal || 0, icon: 'Warning', color: '#d29922', bg: 'rgba(210,153,34,0.1)' },
|
||||
{ label: '封禁用户', value: userStats.value.banned || 0, icon: 'CircleClose', color: '#f85149', bg: 'rgba(248,81,73,0.1)' },
|
||||
])
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const res = await getDashboard()
|
||||
const d = res.data
|
||||
userStats.value = d.user_stats || {}
|
||||
todayStats.value = d.today_interactions || {}
|
||||
tokenStats.value = d.token_stats || {}
|
||||
systemStatus.value = d.system_status || {}
|
||||
onlineUsers.value = d.online_users || 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadTokenTrend(days = 30) {
|
||||
try {
|
||||
const res = await getTokenTrend(days)
|
||||
const data = res.data || []
|
||||
if (!dailyChartInst) return
|
||||
dailyChartInst.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||
grid: { left: 50, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', data: data.map(d => d.date.slice(5)), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'line', data: data.map(d => d.tokens),
|
||||
smooth: true, symbol: 'none',
|
||||
lineStyle: { color: '#58a6ff', width: 2 },
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(88,166,255,0.3)' }, { offset: 1, color: 'rgba(88,166,255,0)' }] } },
|
||||
}]
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadMonthlyTrend() {
|
||||
try {
|
||||
const res = await getMonthlyTokenTrend()
|
||||
const data = res.data || []
|
||||
if (!monthlyChartInst) return
|
||||
monthlyChartInst.setOption({
|
||||
tooltip: { trigger: 'axis', backgroundColor: '#1c2128', borderColor: '#30363d', textStyle: { color: '#e6edf3' } },
|
||||
grid: { left: 55, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', data: data.map(d => d.month), axisLine: { lineStyle: { color: '#30363d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
yAxis: { type: 'value', axisLine: { lineStyle: { color: '#30363d' } }, splitLine: { lineStyle: { color: '#21262d' } }, axisLabel: { color: '#8b949e', fontSize: 11 } },
|
||||
series: [{
|
||||
type: 'bar', data: data.map(d => d.tokens),
|
||||
itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: '#bc8cff' }, { offset: 1, color: 'rgba(188,140,255,0.2)' }] }, borderRadius: [4, 4, 0, 0] },
|
||||
}]
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleResize = () => { dailyChartInst?.resize(); monthlyChartInst?.resize() }
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard()
|
||||
dailyChartInst = echarts.init(dailyChart.value, null, { renderer: 'svg' })
|
||||
monthlyChartInst = echarts.init(monthlyChart.value, null, { renderer: 'svg' })
|
||||
await loadTokenTrend(30)
|
||||
await loadMonthlyTrend()
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('page-refresh', loadDashboard)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('page-refresh', loadDashboard)
|
||||
dailyChartInst?.dispose()
|
||||
monthlyChartInst?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 1400px) { .stats-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.stat-card:hover { border-color: var(--color-accent); }
|
||||
.stat-icon { width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||
.stat-label { font-size: 12px; color: var(--color-text-muted); margin-top: 4px; }
|
||||
|
||||
.section-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
:deep(.el-card) { background: var(--color-bg-card) !important; border-color: var(--color-border) !important; }
|
||||
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.el-card__body) { padding: 18px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
|
||||
.interact-bars { display: flex; flex-direction: column; gap: 12px; }
|
||||
.interact-item { display: flex; align-items: center; gap: 10px; }
|
||||
.interact-label { width: 32px; font-size: 12px; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.interact-bar { flex: 1; }
|
||||
.interact-count { width: 36px; text-align: right; font-size: 13px; font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
.sys-items { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.sys-item { display: flex; flex-direction: column; gap: 3px; }
|
||||
.sys-key { font-size: 11px; color: var(--color-text-muted); }
|
||||
.sys-val { font-size: 15px; font-weight: 600; color: var(--color-text); }
|
||||
.accent-green { color: var(--color-accent-green) !important; }
|
||||
.accent-blue { color: var(--color-accent) !important; }
|
||||
.accent-red { color: var(--color-accent-red) !important; }
|
||||
|
||||
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.chart-box { height: 220px; }
|
||||
</style>
|
||||
165
frontend/src/views/Interactions.vue
Normal file
165
frontend/src/views/Interactions.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">互动记录</span>
|
||||
<el-button size="small" @click="handleExport">
|
||||
<el-icon><Download /></el-icon> 导出
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="搜索用户/文章" clearable @change="load" style="width:200px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.interact_type" placeholder="互动类型" clearable @change="load" style="width:120px">
|
||||
<el-option v-for="(l,v) in typeMap" :key="v" :label="l" :value="v" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable @change="load" style="width:110px">
|
||||
<el-option label="执行中" :value="0"/><el-option label="成功" :value="1"/><el-option label="失败" :value="2"/>
|
||||
</el-select>
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" @change="onDateChange" style="width:240px" />
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" v-loading="loading" stripe class="data-table">
|
||||
<el-table-column label="用户" width="140">
|
||||
<template #default="{ row }">
|
||||
<div style="font-size:13px;font-weight:600">{{ row.user_nickname }}</div>
|
||||
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.user_account }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文章" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:13px">{{ row.article_title || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTagType[row.interact_type]" size="small">{{ row.interact_type_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.content || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token" width="75" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-accent)">{{ row.token_consumed || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行时间" width="145">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.executed_at ? new Date(row.executed_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 1 && row.interact_type !== 'forward' && row.interact_type !== 'read'"
|
||||
size="small" type="danger" plain
|
||||
:loading="cancellingId === row.id"
|
||||
@click="handleCancel(row)">取消</el-button>
|
||||
<el-button v-if="row.status === 2 && row.retry_count < 3" size="small" type="warning" @click="retry(row)">重试</el-button>
|
||||
<el-tooltip v-else-if="row.status === 2" content="已超过最大重试次数" placement="top">
|
||||
<el-button size="small" disabled>重试</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" :page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="load" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getInteractions, retryInteraction, exportInteractions, cancelInteraction } from '@/api'
|
||||
|
||||
const records = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const dateRange = ref(null)
|
||||
const filters = reactive({ keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||
|
||||
const typeMap = { comment: '评论', reply: '回复', like: '点赞', collect: '收藏', forward: '转发' }
|
||||
const typeTagType = { comment: '', reply: 'info', like: 'success', collect: 'warning', forward: 'danger' }
|
||||
const statusTagType = { 0: 'info', 1: 'success', 2: 'danger' }
|
||||
|
||||
function onDateChange(v) {
|
||||
filters.start_date = v?.[0] || ''; filters.end_date = v?.[1] || ''
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getInteractions({ page: page.value, page_size: pageSize.value, ...filters })
|
||||
records.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', interact_type: null, status: null, start_date: '', end_date: '' })
|
||||
dateRange.value = null; load()
|
||||
}
|
||||
|
||||
const cancellingId = ref(null)
|
||||
|
||||
async function handleCancel(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认取消「${row.interact_type === 'comment' ? '评论' : row.interact_type === 'like' ? '点赞' : '收藏'}」互动?`,
|
||||
'取消互动', { confirmButtonText: '确认取消', cancelButtonText: '再想想', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
cancellingId.value = row.id
|
||||
try {
|
||||
const res = await cancelInteraction(row.id)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
ElMessage.success('取消成功')
|
||||
await loadInteractions()
|
||||
} else {
|
||||
ElMessage.error(res.message || '取消失败')
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('取消失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
cancellingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function retry(row) {
|
||||
await retryInteraction(row.id)
|
||||
ElMessage.success('重试已触发')
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const res = await exportInteractions(filters)
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'interactions.xlsx'; a.click()
|
||||
}
|
||||
|
||||
onMounted(() => { load(); window.addEventListener('page-refresh', load) })
|
||||
onUnmounted(() => window.removeEventListener('page-refresh', load))
|
||||
</script>
|
||||
<style scoped>
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.data-table { border-radius: 8px; overflow: hidden; }
|
||||
</style>
|
||||
187
frontend/src/views/Logs.vue
Normal file
187
frontend/src/views/Logs.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">日志管理</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="log-tabs">
|
||||
<!-- Login logs -->
|
||||
<el-tab-pane label="登录日志" name="login">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="loginFilter.action" placeholder="操作类型" clearable @change="loadLoginLogs" style="width:130px">
|
||||
<el-option label="登录" value="login"/>
|
||||
<el-option label="登出" value="logout"/>
|
||||
<el-option label="刷新" value="refresh"/>
|
||||
<el-option label="失败" value="fail"/>
|
||||
</el-select>
|
||||
<el-button @click="loadLoginLogs"><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
<el-table :data="loginLogs" v-loading="loginLoading" stripe class="data-table">
|
||||
<el-table-column label="用户账号" prop="user_account" width="180" />
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="actionType[row.action] || 'info'" size="small">{{ actionLabel[row.action] || row.action }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会话ID" prop="session_id" show-overflow-tooltip />
|
||||
<el-table-column label="失败原因" prop="error_msg" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color:var(--color-accent-red);font-size:12px">{{ row.error_msg || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.created_at?.slice(0,16) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="loginPage" :page-size="50" :total="loginTotal"
|
||||
layout="total, prev, pager, next" @current-change="loadLoginLogs" style="margin-top:12px;display:flex;justify-content:flex-end" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Log files -->
|
||||
<el-tab-pane label="日志文件" name="files">
|
||||
<div style="display:flex;gap:16px;height:600px">
|
||||
<!-- File list -->
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">日志文件</div>
|
||||
<div
|
||||
v-for="f in logFiles" :key="f.name"
|
||||
class="file-item" :class="{ active: selectedFile === f.name }"
|
||||
@click="openFile(f.name)"
|
||||
>
|
||||
<el-icon style="flex-shrink:0"><Document /></el-icon>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ f.name }}</div>
|
||||
<div class="file-size">{{ f.size_kb }} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!logFiles.length" style="padding:20px;color:var(--color-text-muted);font-size:13px;text-align:center">暂无日志文件</div>
|
||||
</div>
|
||||
|
||||
<!-- File content -->
|
||||
<div class="file-content" v-if="selectedFile">
|
||||
<div class="file-toolbar">
|
||||
<span style="font-size:13px;font-weight:600">{{ selectedFile }}</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-select v-model="tailLines" style="width:100px" @change="loadFileContent">
|
||||
<el-option label="最后100行" :value="100"/>
|
||||
<el-option label="最后500行" :value="500"/>
|
||||
<el-option label="最后1000行" :value="1000"/>
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadFileContent"><el-icon><Refresh /></el-icon></el-button>
|
||||
<el-button size="small" type="primary" @click="downloadFile">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content" ref="logContentRef">
|
||||
<div v-for="(line, i) in fileLines" :key="i" class="log-line" :class="getLineClass(line)">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="file-empty">
|
||||
<el-empty description="选择左侧日志文件查看内容" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick, onMounted } from 'vue'
|
||||
import { getLoginLogs, getLogFiles, tailLogFile } from '@/api'
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loginLogs = ref([])
|
||||
const loginLoading = ref(false)
|
||||
const loginPage = ref(1)
|
||||
const loginTotal = ref(0)
|
||||
const loginFilter = reactive({ action: '' })
|
||||
|
||||
const logFiles = ref([])
|
||||
const selectedFile = ref('')
|
||||
const fileLines = ref([])
|
||||
const tailLines = ref(100)
|
||||
const logContentRef = ref()
|
||||
|
||||
const actionLabel = { login: '登录', logout: '登出', refresh: '刷新', fail: '失败' }
|
||||
const actionType = { login: 'success', logout: 'info', refresh: 'warning', fail: 'danger' }
|
||||
|
||||
function getLineClass(line) {
|
||||
if (line.includes('ERROR') || line.includes('error')) return 'line-error'
|
||||
if (line.includes('WARNING') || line.includes('WARN')) return 'line-warn'
|
||||
if (line.includes('INFO')) return 'line-info'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function loadLoginLogs() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const res = await getLoginLogs({ page: loginPage.value, page_size: 50, action: loginFilter.action || undefined })
|
||||
loginLogs.value = res.data?.items || []
|
||||
loginTotal.value = res.data?.total || 0
|
||||
} finally { loginLoading.value = false }
|
||||
}
|
||||
|
||||
async function loadLogFiles() {
|
||||
const res = await getLogFiles()
|
||||
logFiles.value = res.data || []
|
||||
}
|
||||
|
||||
async function openFile(name) {
|
||||
selectedFile.value = name
|
||||
await loadFileContent()
|
||||
}
|
||||
|
||||
async function loadFileContent() {
|
||||
if (!selectedFile.value) return
|
||||
const res = await tailLogFile(selectedFile.value, tailLines.value)
|
||||
fileLines.value = (res.data?.lines || []).map(l => l.replace(/\n$/, ''))
|
||||
await nextTick()
|
||||
if (logContentRef.value) logContentRef.value.scrollTop = logContentRef.value.scrollHeight
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
window.open(`/api/logs/files/${selectedFile.value}/download`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => { loadLoginLogs(); loadLogFiles() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.data-table { border-radius: 8px; }
|
||||
:deep(.log-tabs .el-tabs__header) { margin-bottom: 16px; }
|
||||
:deep(.el-tabs__item) { color: var(--color-text-muted) !important; }
|
||||
:deep(.el-tabs__item.is-active) { color: var(--color-accent) !important; }
|
||||
|
||||
.file-list {
|
||||
width: 220px; flex-shrink: 0;
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.file-list-header { padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); }
|
||||
.file-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px; cursor: pointer; transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
.file-item:hover { background: rgba(88,166,255,0.06); }
|
||||
.file-item.active { background: rgba(88,166,255,0.12); color: var(--color-accent); }
|
||||
.file-name { font-size: 12px; font-weight: 500; }
|
||||
.file-size { font-size: 11px; color: var(--color-text-muted); }
|
||||
|
||||
.file-content {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.file-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;
|
||||
}
|
||||
.log-content { flex: 1; overflow-y: auto; padding: 10px 14px; font-family: 'Consolas','Monaco',monospace; font-size: 12px; line-height: 1.6; }
|
||||
.log-line { padding: 1px 0; color: var(--color-text-muted); white-space: pre-wrap; word-break: break-all; }
|
||||
.line-error { color: var(--color-accent-red); }
|
||||
.line-warn { color: var(--color-accent-orange); }
|
||||
.line-info { color: var(--color-text); }
|
||||
.file-empty { flex: 1; display: flex; align-items: center; justify-content: center; background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
</style>
|
||||
244
frontend/src/views/Scheduler.vue
Normal file
244
frontend/src/views/Scheduler.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<span class="page-title">调度设置</span>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<el-tag :type="schedulerEnabled ? 'success' : 'warning'" size="default">
|
||||
{{ schedulerEnabled ? '调度器运行中' : '调度器已暂停' }}
|
||||
</el-tag>
|
||||
<el-button :type="schedulerEnabled ? 'warning' : 'success'" @click="toggleScheduler">
|
||||
{{ schedulerEnabled ? '暂停调度' : '启动调度' }}
|
||||
</el-button>
|
||||
<el-button type="warning" @click="runNow" :loading="runningNow">
|
||||
⚡ 立即执行一次互动
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="resetSessions">重置所有会话</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="configs" label-width="160px" v-loading="loading" class="settings-form">
|
||||
<!-- Time settings -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>互动时间配置</span></template>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="互动开始时间">
|
||||
<el-time-select v-model="configs.interact_time_start" :start="'00:00'" :end="'23:30'" :step="'00:30'" style="width:140px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="互动结束时间">
|
||||
<el-time-select v-model="configs.interact_time_end" :start="'00:30'" :end="'23:59'" :step="'00:30'" style="width:140px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最小互动间隔(秒)">
|
||||
<el-input-number v-model.number="configs.interact_interval_min" :min="60" :max="3600" :step="60" style="width:160px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大互动间隔(秒)">
|
||||
<el-input-number v-model.number="configs.interact_interval_max" :min="120" :max="7200" :step="60" style="width:160px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="并发登录用户上限">
|
||||
<el-input-number v-model.number="configs.max_concurrent_users" :min="0" :max="200" style="width:160px" />
|
||||
<span class="hint">同时进行互动的最大用户数,<b style="color:#58a6ff">0 = 无上限</b></span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Token quota -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>Token配额管控</span></template>
|
||||
<el-form-item label="每日Token上限">
|
||||
<el-input-number v-model.number="configs.daily_token_limit" :min="1000" :max="10000000" :step="10000" style="width:200px" />
|
||||
<span class="hint">超过上限自动暂停AI任务,次日零点重置</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前使用情况">
|
||||
<el-progress :percentage="tokenPercent" :color="tokenPercent > 80 ? '#f85149' : '#58a6ff'" style="width:300px" />
|
||||
<span class="hint">{{ todayUsed.toLocaleString() }} / {{ (configs.daily_token_limit || 0).toLocaleString() }}</span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Interaction probabilities -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header><span>互动概率配置(0-1之间)</span></template>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="8" v-for="item in probItems" :key="item.key">
|
||||
<el-form-item :label="item.label">
|
||||
<el-input-number
|
||||
v-model.number="configs[item.key]"
|
||||
:min="0" :max="1" :step="0.05" :precision="2"
|
||||
style="width:130px"
|
||||
/>
|
||||
<span class="hint">{{ (configs[item.key] * 100).toFixed(0) }}%</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- News platform config -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<span>目标平台接口配置</span>
|
||||
<el-tag type="warning" size="small" style="margin-left:8px">必须正确配置才能登录互动</el-tag>
|
||||
</template>
|
||||
<el-form-item label="业务接口地址">
|
||||
<el-input v-model="configs.news_platform_base_url" placeholder="http://192.168.1.200:63120" style="width:360px" />
|
||||
<span class="hint">新闻/评论/点赞等业务接口</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="认证服务地址">
|
||||
<el-input v-model="configs.auth_base_url" placeholder="http://192.168.1.200:60040" style="width:360px" />
|
||||
<span class="hint">登录接口所在服务地址</span>
|
||||
</el-form-item>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="平台 appId">
|
||||
<el-input v-model="configs.platform_app_id" placeholder="客户端appId标识" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="平台 accessId">
|
||||
<el-input v-model="configs.platform_access_id" placeholder="客户端accessId" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="签名密钥">
|
||||
<el-input v-model="configs.platform_access_secret" type="password" show-password placeholder="accessSecret(可为空)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="clientCode">
|
||||
<el-input v-model="configs.platform_client_code" placeholder="登录用clientCode" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="组织Id (orgId)">
|
||||
<el-input v-model="configs.platform_org_id" placeholder="留空则登录后自动获取" style="width:240px" />
|
||||
<span class="hint">可留空,系统登录后自动从用户信息接口获取并保存</span>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<div style="padding-left:160px;margin-top:8px">
|
||||
<el-button type="primary" @click="save" :loading="saving" size="large">
|
||||
<el-icon><Check /></el-icon> 保存所有配置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSystemConfigs, updateSystemConfigs, toggleScheduler as apiToggle, resetAllSessions, getDashboard } from '@/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const schedulerEnabled = ref(true)
|
||||
const todayUsed = ref(0)
|
||||
|
||||
const configs = reactive({
|
||||
interact_time_start: '08:00',
|
||||
interact_time_end: '22:00',
|
||||
interact_interval_min: 300,
|
||||
interact_interval_max: 1800,
|
||||
max_concurrent_users: 5,
|
||||
daily_token_limit: 100000,
|
||||
comment_probability: 0.4,
|
||||
reply_probability: 0.2,
|
||||
like_probability: 0.6,
|
||||
collect_probability: 0.3,
|
||||
forward_probability: 0.15,
|
||||
// 目标平台配置
|
||||
news_platform_base_url: 'http://192.168.1.200:63120',
|
||||
auth_base_url: 'http://192.168.1.200:60040',
|
||||
platform_app_id: '',
|
||||
platform_access_id: '',
|
||||
platform_access_secret: '',
|
||||
platform_client_code: '',
|
||||
platform_org_id: '',
|
||||
})
|
||||
|
||||
const probItems = [
|
||||
{ key: 'comment_probability', label: '评论概率' },
|
||||
{ key: 'reply_probability', label: '回复概率' },
|
||||
{ key: 'like_probability', label: '点赞概率' },
|
||||
{ key: 'collect_probability', label: '收藏概率' },
|
||||
{ key: 'forward_probability', label: '转发概率' },
|
||||
]
|
||||
|
||||
const tokenPercent = computed(() => {
|
||||
const limit = configs.daily_token_limit || 1
|
||||
return Math.min(Math.round((todayUsed.value / limit) * 100), 100)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [cfgRes, dashRes] = await Promise.all([getSystemConfigs(), getDashboard()])
|
||||
const cfg = cfgRes.data || {}
|
||||
Object.keys(configs).forEach(k => {
|
||||
if (cfg[k]) {
|
||||
const v = cfg[k].value
|
||||
const t = cfg[k].type
|
||||
configs[k] = t === 'int' ? parseInt(v) : (t === 'string' || !t) ? v : parseFloat(v)
|
||||
}
|
||||
})
|
||||
schedulerEnabled.value = cfg['scheduler_enabled']?.value === 'true'
|
||||
todayUsed.value = dashRes.data?.token_stats?.today_used || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {}
|
||||
Object.keys(configs).forEach(k => { payload[k] = String(configs[k]) })
|
||||
await updateSystemConfigs(payload)
|
||||
ElMessage.success('配置已保存')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
runningNow.value = true
|
||||
try {
|
||||
const res = await runInteractionNow()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`已触发互动:${d.triggered || 0} 个用户`)
|
||||
} catch(e) {
|
||||
ElMessage.error('触发失败:' + (e.message || '未知'))
|
||||
} finally {
|
||||
runningNow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleScheduler() {
|
||||
const newVal = !schedulerEnabled.value
|
||||
await apiToggle(newVal)
|
||||
schedulerEnabled.value = newVal
|
||||
ElMessage.success(newVal ? '调度器已启动' : '调度器已暂停')
|
||||
}
|
||||
|
||||
async function resetSessions() {
|
||||
await ElMessageBox.confirm('将重置所有用户的登录会话,用户需要重新登录才能互动,是否继续?', '警告', { type: 'warning' })
|
||||
await resetAllSessions()
|
||||
ElMessage.success('所有会话已重置')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-form { max-width: 900px; }
|
||||
.settings-card { margin-bottom: 16px; }
|
||||
:deep(.el-card__header) { border-bottom: 1px solid var(--color-border); padding: 14px 18px; font-size: 14px; font-weight: 600; color: var(--color-text); }
|
||||
:deep(.el-card__body) { padding: 20px 18px 12px; }
|
||||
:deep(.el-form-item) { margin-bottom: 16px; }
|
||||
.hint { font-size: 11px; color: var(--color-text-muted); margin-left: 8px; }
|
||||
</style>
|
||||
593
frontend/src/views/Users.vue
Normal file
593
frontend/src/views/Users.vue
Normal file
@@ -0,0 +1,593 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- Toolbar -->
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="page-title">虚拟用户管理</span>
|
||||
<el-tag type="info">共 {{ total }} 个用户</el-tag>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button @click="downloadTemplate" size="small">
|
||||
<el-icon><Download /></el-icon> 下载模板
|
||||
</el-button>
|
||||
<el-upload :show-file-list="false" accept=".xlsx,.xls" :before-upload="handleImport">
|
||||
<el-tooltip content="仅账号和密码为必填,昵称为空时自动生成" placement="top">
|
||||
<el-button size="small" type="warning">
|
||||
<el-icon><Upload /></el-icon> 批量导入
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="handleExport">
|
||||
<el-icon><Download /></el-icon> 导出
|
||||
</el-button>
|
||||
<el-popconfirm title="将删除重复账号,只保留最早导入的一条,确认?" @confirm="handleDeduplicate" confirm-button-text="确认去重" cancel-button-text="取消">
|
||||
<template #reference>
|
||||
<el-button size="small" type="warning">
|
||||
<el-icon><CopyDocument /></el-icon> 去重
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-popconfirm title="将对所有未登录/登录失效的用户执行登录,确认?" @confirm="handleLoginAll" confirm-button-text="确认" cancel-button-text="取消">
|
||||
<template #reference>
|
||||
<el-button size="small" type="success" :loading="loginAllLoading">
|
||||
<el-icon><Connection /></el-icon> 一键登录全部
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
<el-tooltip content="从目标平台同步所有已登录用户的昵称/真实姓名/性别/头像" placement="top">
|
||||
<el-button size="small" type="info" :loading="syncLoading" @click="handleSyncAll">
|
||||
<el-icon><Refresh /></el-icon> 同步用户信息
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button type="primary" size="small" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon> 新增用户
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="filters.keyword" placeholder="搜索昵称/账号" clearable @change="loadUsers" style="width:200px">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" placeholder="用户状态" clearable @change="loadUsers" style="width:130px">
|
||||
<el-option v-for="(label,val) in statusMap" :key="val" :label="label" :value="Number(val)" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.is_enabled" placeholder="启用状态" clearable @change="loadUsers" style="width:120px">
|
||||
<el-option label="已启用" :value="1" /><el-option label="已禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Batch actions -->
|
||||
<div v-if="selectedIds.length" class="batch-bar">
|
||||
<span class="batch-info">已选 {{ selectedIds.length }} 项</span>
|
||||
<el-button size="small" type="success" @click="batchAction('enable')">批量启用</el-button>
|
||||
<el-button size="small" type="warning" @click="batchAction('disable')">批量禁用</el-button>
|
||||
<el-button size="small" @click="batchAction('logout')">批量登出</el-button>
|
||||
<el-button size="small" type="danger" @click="batchAction('delete')">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<el-table
|
||||
:data="users" v-loading="loading" :row-class-name="rowClassName"
|
||||
@selection-change="selectedIds = $event.map(r => r.id)"
|
||||
class="data-table"
|
||||
>
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column label="用户" width="200">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<el-avatar :size="36" :src="row.avatar_url || ''" style="background:#30363d;flex-shrink:0">
|
||||
{{ row.nickname?.[0] }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:13px">{{ row.nickname }}</div>
|
||||
<div style="font-size:11px;color:var(--color-text-muted)">{{ row.account }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType[row.status]" size="small">{{ row.status_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活跃度" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="activityType[row.activity_level]" size="small" effect="plain">{{ row.activity_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="人格" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.personality" style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<el-tag size="small" effect="plain">{{ row.personality.character_type }}</el-tag>
|
||||
<el-tag size="small" effect="plain" type="warning">{{ row.personality.language_style }}</el-tag>
|
||||
<el-tag v-for="t in (row.personality.interest_tags||[]).slice(0,2)" :key="t" size="small" effect="plain" type="info">{{ t }}</el-tag>
|
||||
</div>
|
||||
<span v-else style="color:var(--color-text-muted);font-size:12px">未生成</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="今日评论" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="row.today_comment_count >= row.daily_comment_limit ? 'color:var(--color-accent-red)' : ''">
|
||||
{{ row.today_comment_count }}/{{ row.daily_comment_limit }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计互动" width="90" align="center" prop="total_interactions" />
|
||||
<el-table-column label="最后互动" width="145">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;color:var(--color-text-muted)">{{ row.last_interact_at ? new Date(row.last_interact_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}) : '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.is_enabled === 1" @change="toggleEnabled(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:nowrap;">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="success" v-if="row.status !== 2" @click="doLogin(row)">登录</el-button>
|
||||
<el-button size="small" type="warning" v-else @click="doLogout(row)">登出</el-button>
|
||||
<el-dropdown size="small" trigger="click">
|
||||
<el-button size="small">更多<el-icon style="margin-left:2px"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openPersonality(row)">🧠 人格设置</el-dropdown-item>
|
||||
<el-dropdown-item @click="doDelete(row)" style="color:#f85149">🗑️ 删除用户</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:total="total" :page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadUsers" style="margin-top:16px;justify-content:flex-end;display:flex"
|
||||
/>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editUser ? '编辑用户' : '新增虚拟用户'" width="560px">
|
||||
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" placeholder="选填,留空自动生成" />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台账号" prop="account">
|
||||
<el-input v-model="form.account" placeholder="新闻平台登录账号" :disabled="!!editUser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录密码" :prop="editUser ? '' : 'password'">
|
||||
<el-input v-model="form.password" type="password" :placeholder="editUser ? '不修改请留空' : '登录密码'" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start;width:100%">
|
||||
<el-avatar :size="50" :src="form.avatar_url" style="flex-shrink:0">
|
||||
<span>{{ (form.nickname||'?')[0] }}</span>
|
||||
</el-avatar>
|
||||
<div style="flex:1">
|
||||
<el-input v-model="form.avatar_url" placeholder="头像图片链接" style="margin-bottom:6px" />
|
||||
<el-upload v-if="editUser" :show-file-list="false" :before-upload="handleAvatarUpload" accept="image/*">
|
||||
<el-button size="small" type="primary" plain :loading="avatarUploading">📷 上传头像</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名">
|
||||
<el-input v-model="form.real_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台昵称">
|
||||
<el-input v-model="form.nickname" placeholder="同步到目标平台的昵称" />
|
||||
<div style="font-size:11px;color:var(--color-text-muted);margin-top:4px">昵称将同步到目标平台个人中心显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-radio-group v-model="form.sex">
|
||||
<el-radio :value="0">未知</el-radio>
|
||||
<el-radio :value="1">男</el-radio>
|
||||
<el-radio :value="2">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="个人简介(可同步到平台)" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="邮箱地址(可同步到平台)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="活跃度">
|
||||
<el-radio-group v-model="form.activity_level">
|
||||
<el-radio :value="0">低 (3-5次/天)</el-radio>
|
||||
<el-radio :value="1">中 (8-15次/天)</el-radio>
|
||||
<el-radio :value="2">高 (20-30次/天)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="评论上限">
|
||||
<el-input-number v-model="form.daily_comment_limit" :min="1" :max="100" />
|
||||
<span style="margin-left:8px;font-size:12px;color:var(--color-text-muted)">次/天</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||
{{ editUser ? '保存' : '创建并生成人格' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Personality Dialog -->
|
||||
<el-dialog v-model="personalityVisible" title="AI人格管理" width="560px">
|
||||
<div v-if="currentPersonality" class="personality-panel">
|
||||
<div class="p-header">
|
||||
<span class="p-user">{{ currentUser?.nickname }}</span>
|
||||
<el-button size="small" type="primary" @click="regenPersonality" :loading="regenLoading">
|
||||
<el-icon><RefreshRight /></el-icon> 重新生成
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="p-grid">
|
||||
<div class="p-item"><span class="p-key">性格类型</span>
|
||||
<el-select v-model="currentPersonality.character_type" size="small" style="width:120px">
|
||||
<el-option v-for="c in characters" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">语言风格</span>
|
||||
<el-select v-model="currentPersonality.language_style" size="small" style="width:120px">
|
||||
<el-option v-for="s in langStyles" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">互动倾向</span>
|
||||
<el-select v-model="currentPersonality.interact_tendency" size="small" style="width:120px">
|
||||
<el-option v-for="t in tendencies" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item"><span class="p-key">字数范围</span>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<el-input-number v-model="currentPersonality.word_count_min" :min="10" :max="200" size="small" style="width:90px" />
|
||||
<span>~</span>
|
||||
<el-input-number v-model="currentPersonality.word_count_max" :min="30" :max="500" size="small" style="width:90px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-item" style="margin-top:12px">
|
||||
<span class="p-key">兴趣偏好</span>
|
||||
<el-select v-model="currentPersonality.interest_tags" multiple size="small" style="width:100%">
|
||||
<el-option v-for="t in interestTags" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="p-item" style="margin-top:12px">
|
||||
<span class="p-key">人格描述</span>
|
||||
<el-input v-model="currentPersonality.personality_desc" type="textarea" :rows="3" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-empty">
|
||||
<el-empty description="尚未生成人格">
|
||||
<el-button type="primary" @click="regenPersonality">立即生成</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="personalityVisible = false">关闭</el-button>
|
||||
<el-button v-if="currentPersonality" type="primary" @click="savePersonality" :loading="savingPersonality">保存人格</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getUsers, createUser, updateUser, deleteUser, batchUserAction,
|
||||
loginUser, logoutUser, generatePersonality, updatePersonality,
|
||||
downloadTemplate as dlTemplate, importUsers, exportUsers, deduplicateUsers, loginAllUsers, syncAllProfiles,
|
||||
uploadAvatar
|
||||
} from '@/api'
|
||||
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedIds = ref([])
|
||||
const filters = reactive({ keyword: '', status: null, is_enabled: null })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editUser = ref(null)
|
||||
const formRef = ref()
|
||||
const form = reactive({ nickname: '', account: '', password: '', avatar_url: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||
const rules = {
|
||||
account: [{ required: true, message: '请输入平台账号(必填)' }],
|
||||
password: [{ required: true, min: 6, message: '密码至少6位(必填)' }],
|
||||
}
|
||||
|
||||
const personalityVisible = ref(false)
|
||||
const currentUser = ref(null)
|
||||
const currentPersonality = ref(null)
|
||||
const regenLoading = ref(false)
|
||||
const savingPersonality = ref(false)
|
||||
|
||||
const statusMap = { 0: '未登录', 1: '登录中', 2: '已登录', 3: '登录失效', 4: '封禁' }
|
||||
const statusType = { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'danger' }
|
||||
const activityType = { 0: 'info', 1: 'warning', 2: 'success' }
|
||||
const characters = ['开朗','内敛','毒舌','温和','理性','感性','幽默','严谨']
|
||||
const langStyles = ['严肃','幽默','文艺','吐槽','口语化','学术','简洁','丰富']
|
||||
const tendencies = ['爱评论','爱点赞','爱收藏','潜水','爱转发','爱回复']
|
||||
const interestTags = ['科技','财经','娱乐','体育','政治','文化','教育','医疗','汽车','房产','旅游','美食','军事','国际','环保','农业']
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers({ page: page.value, page_size: pageSize.value, ...filters })
|
||||
users.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', status: null, is_enabled: null })
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editUser.value = null
|
||||
Object.assign(form, { nickname: '', account: '', password: '', avatar_url: '', real_name: '', sex: 0, description: '', email: '', activity_level: 1, daily_comment_limit: 10, remark: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editUser.value = row
|
||||
Object.assign(form, { nickname: row.nickname, account: row.account, password: '', avatar_url: row.avatar_url || '', real_name: row.real_name || '', sex: row.sex ?? 0, description: row.description || '', email: row.email || '', activity_level: row.activity_level, daily_comment_limit: row.daily_comment_limit, remark: row.remark || '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editUser.value) {
|
||||
const data = { ...form }
|
||||
if (!data.password) delete data.password
|
||||
data.sync_to_platform = true // 自动同步到目标平台
|
||||
const res = await updateUser(editUser.value.id, data)
|
||||
if (res.code === 200 || res.code === 206) {
|
||||
// 局部刷新:只更新当前行数据,不刷新整个列表
|
||||
const idx = users.value.findIndex(u => u.id === editUser.value.id)
|
||||
if (idx !== -1) {
|
||||
const updated = { ...users.value[idx], ...{
|
||||
nickname: form.nickname || users.value[idx].nickname,
|
||||
avatar_url: form.avatar_url,
|
||||
real_name: form.real_name,
|
||||
sex: form.sex,
|
||||
activity_level: form.activity_level,
|
||||
daily_comment_limit: form.daily_comment_limit,
|
||||
remark: form.remark,
|
||||
}}
|
||||
users.value.splice(idx, 1, updated)
|
||||
}
|
||||
if (res.code === 206) {
|
||||
ElMessage.warning(res.message || '本地已保存,平台同步失败')
|
||||
} else {
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
await createUser(form)
|
||||
ElMessage.success('用户创建成功,AI人格已自动生成')
|
||||
loadUsers()
|
||||
}
|
||||
dialogVisible.value = false
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function doDelete(row) {
|
||||
await ElMessageBox.confirm(`确认删除用户 "${row.nickname}"?`, '确认', { type: 'warning' })
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function batchAction(action) {
|
||||
if (action === 'delete') {
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个用户?`, '危险操作', { type: 'warning' })
|
||||
}
|
||||
await batchUserAction({ user_ids: selectedIds.value, action })
|
||||
ElMessage.success('操作成功')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function toggleEnabled(row) {
|
||||
await updateUser(row.id, { is_enabled: row.is_enabled ? 0 : 1 })
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function doLogin(row) {
|
||||
const loading = ElMessage({ message: `正在登录 ${row.nickname}...`, duration: 0 })
|
||||
try {
|
||||
await loginUser(row.id)
|
||||
loading.close()
|
||||
ElMessage.success('登录成功')
|
||||
loadUsers()
|
||||
} catch { loading.close() }
|
||||
}
|
||||
|
||||
async function doLogout(row) {
|
||||
await logoutUser(row.id)
|
||||
ElMessage.success('已登出')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function openPersonality(row) {
|
||||
currentUser.value = row
|
||||
currentPersonality.value = row.personality ? { ...row.personality } : null
|
||||
personalityVisible.value = true
|
||||
}
|
||||
|
||||
async function regenPersonality() {
|
||||
regenLoading.value = true
|
||||
try {
|
||||
const res = await generatePersonality(currentUser.value.id)
|
||||
currentPersonality.value = res.data
|
||||
ElMessage.success('人格重新生成成功')
|
||||
loadUsers()
|
||||
} finally { regenLoading.value = false }
|
||||
}
|
||||
|
||||
async function savePersonality() {
|
||||
savingPersonality.value = true
|
||||
try {
|
||||
await updatePersonality(currentUser.value.id, currentPersonality.value)
|
||||
ElMessage.success('人格已保存')
|
||||
loadUsers()
|
||||
} finally { savingPersonality.value = false }
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
const res = await dlTemplate()
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'import_template.xlsx'; a.click()
|
||||
}
|
||||
|
||||
async function handleImport(file) {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
// 显示进度提示
|
||||
const loading = ElMessage({
|
||||
message: '正在导入中,请稍候...',
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
showClose: false,
|
||||
icon: 'Loading'
|
||||
})
|
||||
try {
|
||||
const res = await importUsers(fd)
|
||||
loading.close()
|
||||
const d = res.data || {}
|
||||
const success = d.success || 0
|
||||
const failed = d.failed || 0
|
||||
if (failed > 0 && success === 0) {
|
||||
ElMessage.error(`导入失败:${failed}条失败,请检查数据格式`)
|
||||
} else if (failed > 0) {
|
||||
ElMessage.warning(`导入完成:成功 ${success} 条,失败 ${failed} 条`)
|
||||
} else {
|
||||
ElMessage.success(`导入成功:共导入 ${success} 条用户`)
|
||||
}
|
||||
// 导入完成后自动刷新列表
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
loading.close()
|
||||
ElMessage.error('导入失败:' + (e.message || '未知错误'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const res = await exportUsers()
|
||||
const url = URL.createObjectURL(new Blob([res]))
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'users_export.xlsx'; a.click()
|
||||
}
|
||||
|
||||
const loginAllLoading = ref(false)
|
||||
|
||||
const syncLoading = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
async function handleAvatarUpload(file) {
|
||||
if (!editUser.value) return false
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('sync_to_platform', 'true') // 始终同步到平台
|
||||
const res = await uploadAvatar(editUser.value.id, formData)
|
||||
if (res.code === 200 || res.code === 0) {
|
||||
form.avatar_url = res.data?.avatar_url || ''
|
||||
ElMessage.success('头像上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.message || '头像上传失败')
|
||||
}
|
||||
} catch(e) {
|
||||
ElMessage.error('上传失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
return false // 阻止 el-upload 默认上传
|
||||
}
|
||||
|
||||
async function handleSyncAll() {
|
||||
syncLoading.value = true
|
||||
try {
|
||||
const res = await syncAllProfiles()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(res.message || `同步完成:${d.synced} 个用户`)
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
ElMessage.error('同步失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
syncLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginAll() {
|
||||
loginAllLoading.value = true
|
||||
try {
|
||||
const res = await loginAllUsers()
|
||||
const d = res.data || {}
|
||||
ElMessage.success(res.message || `登录完成:成功 ${d.success} 个`)
|
||||
await loadUsers()
|
||||
} catch(e) {
|
||||
ElMessage.error('一键登录失败:' + (e.message || '未知错误'))
|
||||
} finally {
|
||||
loginAllLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeduplicate() {
|
||||
const res = await deduplicateUsers()
|
||||
ElMessage.success(res.message || '去重完成')
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
// 暗色主题行样式:深浅交替,避免白底
|
||||
function rowClassName({ rowIndex }) {
|
||||
return rowIndex % 2 === 0 ? 'row-dark' : 'row-darker'
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); window.addEventListener('page-refresh', loadUsers) })
|
||||
onUnmounted(() => window.removeEventListener('page-refresh', loadUsers))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.batch-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(88,166,255,0.08); border: 1px solid rgba(88,166,255,0.3); border-radius: 8px; margin-bottom: 10px; }
|
||||
.batch-info { font-size: 13px; color: var(--color-accent); margin-right: 4px; }
|
||||
.data-table { border-radius: 8px; overflow: hidden; }
|
||||
.personality-panel { }
|
||||
.p-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.p-user { font-size: 15px; font-weight: 600; }
|
||||
.p-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.p-item { display: flex; align-items: center; gap: 10px; }
|
||||
.p-key { font-size: 12px; color: var(--color-text-muted); white-space: nowrap; width: 60px; flex-shrink: 0; }
|
||||
.p-empty { padding: 20px 0; }
|
||||
|
||||
/* 暗色主题表格行样式 */
|
||||
:deep(.row-dark td) { background: var(--color-bg-card) !important; }
|
||||
:deep(.row-darker td) { background: var(--color-bg-secondary) !important; }
|
||||
:deep(.el-table__row:hover td) { background: rgba(88,166,255,0.08) !important; }
|
||||
:deep(.el-table__header-wrapper th) {
|
||||
background: var(--color-bg) !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
border-bottom: 2px solid var(--color-border) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
:deep(.el-table__body-wrapper) { background: transparent !important; }
|
||||
:deep(.el-table) { background: transparent !important; }
|
||||
</style>
|
||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') }
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user