feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交

- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台)
- AI互动调度(点赞/收藏/评论/转发)
- 日志时间改为北京时间
- 评论达上限后继续执行点赞收藏转发
- 一键登出全部功能
- 浅色主题UI
This commit is contained in:
stefanfeng
2026-03-31 10:20:57 +08:00
commit 0cfc9bf9c8
53 changed files with 8457 additions and 0 deletions

0
frontend/-H Normal file
View File

0
frontend/-d Normal file
View File

12
frontend/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

76
frontend/src/api/index.js Normal file
View 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" } })

View 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
View 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')

View 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
})

View 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; }

View 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 &nbsp;·&nbsp; 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>

View 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>

View 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
View 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>

View 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>

View 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
View 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
}
}
}
})