feat(admin): migrate admin UI to Vue3

This commit is contained in:
2025-12-13 20:51:44 +08:00
parent 3c31f30ee4
commit 235ba28cc8
46 changed files with 9355 additions and 3513 deletions

24
admin-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
admin-frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
admin-frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>后台管理 - 知识管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1834
admin-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "admin-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2",
"element-plus": "^2.11.3",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function updateAdminUsername(newUsername) {
const { data } = await api.put('/admin/username', { new_username: newUsername })
return data
}
export async function updateAdminPassword(newPassword) {
const { data } = await api.put('/admin/password', { new_password: newPassword })
return data
}
export async function logout() {
const { data } = await api.post('/logout')
return data
}

View File

@@ -0,0 +1,27 @@
import { api } from './client'
export async function fetchAnnouncements() {
const { data } = await api.get('/announcements')
return data
}
export async function createAnnouncement(payload) {
const { data } = await api.post('/announcements', payload)
return data
}
export async function activateAnnouncement(id) {
const { data } = await api.post(`/announcements/${id}/activate`)
return data
}
export async function deactivateAnnouncement(id) {
const { data } = await api.post(`/announcements/${id}/deactivate`)
return data
}
export async function deleteAnnouncement(id) {
const { data } = await api.delete(`/announcements/${id}`)
return data
}

View File

@@ -0,0 +1,30 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
export const api = axios.create({
baseURL: '/yuyx/api',
timeout: 30_000,
withCredentials: true,
})
api.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status
const payload = error?.response?.data
const message = payload?.error || payload?.message || error?.message || '请求失败'
if (status === 403) {
ElMessage.error(message || '需要管理员权限')
} else if (status) {
ElMessage.error(message)
} else if (error?.code === 'ECONNABORTED') {
ElMessage.error('请求超时')
} else {
ElMessage.error(message)
}
return Promise.reject(error)
},
)

View File

@@ -0,0 +1,27 @@
import { api } from './client'
export async function fetchEmailSettings() {
const { data } = await api.get('/email/settings')
return data
}
export async function updateEmailSettings(payload) {
const { data } = await api.post('/email/settings', payload)
return data
}
export async function fetchEmailStats() {
const { data } = await api.get('/email/stats')
return data
}
export async function fetchEmailLogs(params) {
const { data } = await api.get('/email/logs', { params })
return data
}
export async function cleanupEmailLogs(days) {
const { data } = await api.post('/email/logs/cleanup', { days })
return data
}

View File

@@ -0,0 +1,22 @@
import { api } from './client'
export async function fetchFeedbacks(status = '') {
const { data } = await api.get('/feedbacks', { params: status ? { status } : {} })
return data
}
export async function replyFeedback(feedbackId, reply) {
const { data } = await api.post(`/feedbacks/${feedbackId}/reply`, { reply })
return data
}
export async function closeFeedback(feedbackId) {
const { data } = await api.post(`/feedbacks/${feedbackId}/close`)
return data
}
export async function deleteFeedback(feedbackId) {
const { data } = await api.delete(`/feedbacks/${feedbackId}`)
return data
}

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchPasswordResets() {
const { data } = await api.get('/password_resets')
return data
}
export async function approvePasswordReset(requestId) {
const { data } = await api.post(`/password_resets/${requestId}/approve`)
return data
}
export async function rejectPasswordReset(requestId) {
const { data } = await api.post(`/password_resets/${requestId}/reject`)
return data
}

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchProxyConfig() {
const { data } = await api.get('/proxy/config')
return data
}
export async function updateProxyConfig(payload) {
const { data } = await api.post('/proxy/config', payload)
return data
}
export async function testProxy(payload) {
const { data } = await api.post('/proxy/test', payload)
return data
}

View File

@@ -0,0 +1,32 @@
import { api } from './client'
export async function fetchSmtpConfigs() {
const { data } = await api.get('/smtp/configs')
return data
}
export async function createSmtpConfig(payload) {
const { data } = await api.post('/smtp/configs', payload)
return data
}
export async function updateSmtpConfig(configId, payload) {
const { data } = await api.put(`/smtp/configs/${configId}`, payload)
return data
}
export async function deleteSmtpConfig(configId) {
const { data } = await api.delete(`/smtp/configs/${configId}`)
return data
}
export async function testSmtpConfig(configId, email) {
const { data } = await api.post(`/smtp/configs/${configId}/test`, { email })
return data
}
export async function setPrimarySmtpConfig(configId) {
const { data } = await api.post(`/smtp/configs/${configId}/primary`)
return data
}

View File

@@ -0,0 +1,7 @@
import { api } from './client'
export async function fetchSystemStats() {
const { data } = await api.get('/stats')
return data
}

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchSystemConfig() {
const { data } = await api.get('/system/config')
return data
}
export async function updateSystemConfig(payload) {
const { data } = await api.post('/system/config', payload)
return data
}
export async function executeScheduleNow() {
const { data } = await api.post('/schedule/execute', {})
return data
}

View File

@@ -0,0 +1,32 @@
import { api } from './client'
export async function fetchServerInfo() {
const { data } = await api.get('/server/info')
return data
}
export async function fetchDockerStats() {
const { data } = await api.get('/docker_stats')
return data
}
export async function fetchTaskStats() {
const { data } = await api.get('/task/stats')
return data
}
export async function fetchRunningTasks() {
const { data } = await api.get('/task/running')
return data
}
export async function fetchTaskLogs(params) {
const { data } = await api.get('/task/logs', { params })
return data
}
export async function clearOldTaskLogs(days) {
const { data } = await api.post('/task/logs/clear', { days })
return data
}

View File

@@ -0,0 +1,42 @@
import { api } from './client'
export async function fetchAllUsers() {
const { data } = await api.get('/users')
return data
}
export async function fetchPendingUsers() {
const { data } = await api.get('/users/pending')
return data
}
export async function approveUser(userId) {
const { data } = await api.post(`/users/${userId}/approve`)
return data
}
export async function rejectUser(userId) {
const { data } = await api.post(`/users/${userId}/reject`)
return data
}
export async function deleteUser(userId) {
const { data } = await api.delete(`/users/${userId}`)
return data
}
export async function setUserVip(userId, days) {
const { data } = await api.post(`/users/${userId}/vip`, { days })
return data
}
export async function removeUserVip(userId) {
const { data } = await api.delete(`/users/${userId}/vip`)
return data
}
export async function adminResetUserPassword(userId, newPassword) {
const { data } = await api.post(`/users/${userId}/reset_password`, { new_password: newPassword })
return data
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
stats: { type: Object, required: true },
loading: { type: Boolean, default: false },
})
const items = computed(() => [
{ key: 'total_users', label: '总用户数' },
{ key: 'approved_users', label: '已审核' },
{ key: 'pending_users', label: '待审核' },
{ key: 'total_accounts', label: '总账号数' },
{ key: 'vip_users', label: 'VIP用户' },
])
</script>
<template>
<el-row :gutter="12" class="stats-row">
<el-col v-for="it in items" :key="it.key" :xs="12" :sm="8" :md="6" :lg="4" :xl="4">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">
<el-skeleton v-if="loading" :rows="1" animated />
<template v-else>{{ stats?.[it.key] ?? 0 }}</template>
</div>
<div class="stat-label">{{ it.label }}</div>
</el-card>
</el-col>
</el-row>
</template>
<style scoped>
.stats-row {
margin-bottom: 14px;
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.stat-value {
font-size: 22px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
Bell,
ChatLineSquare,
DataAnalysis,
Document,
List,
Message,
Setting,
Tools,
User,
} from '@element-plus/icons-vue'
import { api } from '../api/client'
import { fetchSystemStats } from '../api/stats'
import StatsCards from '../components/StatsCards.vue'
const route = useRoute()
const router = useRouter()
const stats = ref({})
const loadingStats = ref(false)
const adminUsername = computed(() => stats.value?.admin_username || '')
async function refreshStats() {
loadingStats.value = true
try {
stats.value = await fetchSystemStats()
} finally {
loadingStats.value = false
}
}
provide('refreshStats', refreshStats)
provide('adminStats', stats)
const isMobile = ref(false)
const drawerOpen = ref(false)
let mediaQuery
function syncIsMobile() {
isMobile.value = Boolean(mediaQuery?.matches)
if (!isMobile.value) drawerOpen.value = false
}
onMounted(async () => {
mediaQuery = window.matchMedia('(max-width: 768px)')
mediaQuery.addEventListener?.('change', syncIsMobile)
syncIsMobile()
await refreshStats()
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', syncIsMobile)
})
const menuItems = [
{ path: '/pending', label: '待审核', icon: Document },
{ path: '/users', label: '用户', icon: User },
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare },
{ path: '/stats', label: '统计', icon: DataAnalysis },
{ path: '/logs', label: '任务日志', icon: List },
{ path: '/announcements', label: '公告', icon: Bell },
{ path: '/email', label: '邮件', icon: Message },
{ path: '/system', label: '系统配置', icon: Tools },
{ path: '/settings', label: '设置', icon: Setting },
]
const activeMenu = computed(() => route.path)
async function logout() {
try {
await ElMessageBox.confirm('确定退出管理员登录吗?', '退出登录', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await api.post('/logout')
} finally {
window.location.href = '/yuyx'
}
}
async function go(path) {
await router.push(path)
drawerOpen.value = false
}
</script>
<template>
<el-container class="layout-root">
<el-aside v-if="!isMobile" width="220px" class="layout-aside">
<div class="brand">
<div class="brand-title">后台管理</div>
<div class="brand-sub app-muted">知识管理平台</div>
</div>
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="layout-header">
<div class="header-left">
<el-button v-if="isMobile" text class="header-menu-btn" @click="drawerOpen = true">
菜单
</el-button>
<div class="header-title">后台管理系统</div>
</div>
<div class="header-right">
<div class="admin-name">
<span class="app-muted">管理员</span>
<strong>{{ adminUsername || '-' }}</strong>
</div>
<el-button type="primary" plain @click="logout">退出</el-button>
</div>
</el-header>
<el-main class="layout-main">
<StatsCards :stats="stats" :loading="loadingStats" />
<RouterView />
</el-main>
</el-container>
<el-drawer v-model="drawerOpen" size="240px" :with-header="false">
<div class="drawer-brand">
<div class="brand-title">后台管理</div>
<div class="brand-sub app-muted">知识管理平台</div>
</div>
<el-menu :default-active="activeMenu" class="aside-menu" router @select="go">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
</el-drawer>
</el-container>
</template>
<style scoped>
.layout-root {
height: 100%;
}
.layout-aside {
background: #ffffff;
border-right: 1px solid var(--app-border);
}
.brand {
padding: 18px 16px 10px;
}
.drawer-brand {
padding: 18px 16px 10px;
}
.brand-title {
font-size: 15px;
font-weight: 800;
letter-spacing: 0.2px;
}
.brand-sub {
margin-top: 2px;
font-size: 12px;
}
.aside-menu {
border-right: none;
}
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgba(246, 247, 251, 0.6);
backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--app-border);
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.header-title {
font-size: 14px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-menu-btn {
padding-left: 0;
padding-right: 0;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.admin-name {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
}
.layout-main {
padding: 16px;
}
@media (max-width: 768px) {
.layout-main {
padding: 12px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import './style.css'
createApp(App).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')

View File

@@ -0,0 +1,255 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
activateAnnouncement,
createAnnouncement,
deactivateAnnouncement,
deleteAnnouncement,
fetchAnnouncements,
} from '../api/announcements'
const formTitle = ref('')
const formContent = ref('')
const loading = ref(false)
const list = ref([])
async function load() {
loading.value = true
try {
list.value = await fetchAnnouncements()
} catch {
list.value = []
} finally {
loading.value = false
}
}
function clearForm() {
formTitle.value = ''
formContent.value = ''
}
async function submit(isActive) {
const title = formTitle.value.trim()
const content = formContent.value.trim()
if (!title || !content) {
ElMessage.error('标题和内容不能为空')
return
}
try {
const res = await createAnnouncement({ title, content, is_active: Boolean(isActive) })
if (!res?.success) {
ElMessage.error(res?.error || '保存失败')
return
}
ElMessage.success('保存成功')
clearForm()
await load()
} catch {
// handled by interceptor
}
}
async function view(row) {
await ElMessageBox.alert(row.content || '', row.title || '公告', {
confirmButtonText: '关闭',
dangerouslyUseHTMLString: false,
})
}
async function onActivate(row) {
try {
await ElMessageBox.confirm('确定启用该公告吗?启用后将自动停用其他公告。', '启用公告', {
confirmButtonText: '启用',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await activateAnnouncement(row.id)
if (!res?.success) {
ElMessage.error(res?.error || '启用失败')
return
}
ElMessage.success('已启用')
await load()
} catch {
// handled by interceptor
}
}
async function onDeactivate(row) {
try {
await ElMessageBox.confirm('确定停用该公告吗?', '停用公告', {
confirmButtonText: '停用',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await deactivateAnnouncement(row.id)
if (!res?.success) {
ElMessage.error(res?.error || '停用失败')
return
}
ElMessage.success('已停用')
await load()
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm('确定删除该公告吗?删除后无法恢复。', '删除公告', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'error',
})
} catch {
return
}
try {
const res = await deleteAnnouncement(row.id)
if (!res?.success) {
ElMessage.error(res?.error || '删除失败')
return
}
ElMessage.success('已删除')
await load()
} catch {
// handled by interceptor
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>公告管理</h2>
<div>
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">创建公告</h3>
<el-form label-width="90px">
<el-form-item label="公告标题">
<el-input v-model="formTitle" placeholder="请输入公告标题" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="公告内容">
<el-input
v-model="formContent"
type="textarea"
:rows="5"
placeholder="请输入公告内容(将以弹窗形式展示)"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="primary" @click="submit(true)">发布并启用</el-button>
<el-button @click="submit(false)">保存但不启用</el-button>
<el-button @click="clearForm">清空</el-button>
</div>
<div class="help">
说明启用公告后用户登录进入系统将弹窗提示用户可选择当次关闭永久关闭本次公告
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">公告列表</h3>
<div class="table-wrap">
<el-table :data="list" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="标题" min-width="240">
<template #default="{ row }">
<span class="ellipsis" :title="row.title">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light">
{{ row.is_active ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<div class="actions">
<el-button size="small" @click="view(row)">查看</el-button>
<el-button v-if="row.is_active" size="small" @click="onDeactivate(row)">停用</el-button>
<el-button v-else type="success" size="small" @click="onActivate(row)">启用</el-button>
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 10px;
font-size: 12px;
color: var(--app-muted);
}
.table-wrap {
overflow-x: auto;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,760 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { cleanupEmailLogs, fetchEmailLogs, fetchEmailSettings, fetchEmailStats, updateEmailSettings } from '../api/email'
import {
createSmtpConfig,
deleteSmtpConfig,
fetchSmtpConfigs,
setPrimarySmtpConfig,
testSmtpConfig,
updateSmtpConfig,
} from '../api/smtp'
// ========== 全局设置 ==========
const emailSettingsLoading = ref(false)
const emailSettingsSaving = ref(false)
const settings = reactive({
enabled: false,
failover_enabled: true,
register_verify_enabled: false,
task_notify_enabled: false,
base_url: '',
updated_at: null,
})
let saveTimer = null
async function loadEmailSettings() {
emailSettingsLoading.value = true
try {
const data = await fetchEmailSettings()
settings.enabled = Boolean(data.enabled)
settings.failover_enabled = Boolean(data.failover_enabled)
settings.register_verify_enabled = Boolean(data.register_verify_enabled)
settings.task_notify_enabled = Boolean(data.task_notify_enabled)
settings.base_url = data.base_url || ''
settings.updated_at = data.updated_at || null
} catch {
// handled by interceptor
} finally {
emailSettingsLoading.value = false
}
}
async function saveEmailSettings() {
if (emailSettingsLoading.value) return
emailSettingsSaving.value = true
try {
const res = await updateEmailSettings({
enabled: settings.enabled,
failover_enabled: settings.failover_enabled,
register_verify_enabled: settings.register_verify_enabled,
task_notify_enabled: settings.task_notify_enabled,
base_url: (settings.base_url || '').trim(),
})
if (!res?.success) {
ElMessage.error(res?.error || '更新失败')
return
}
ElMessage.success('邮件设置已更新')
await loadEmailSettings()
} catch {
// handled by interceptor
} finally {
emailSettingsSaving.value = false
}
}
function scheduleSaveEmailSettings() {
if (saveTimer) window.clearTimeout(saveTimer)
saveTimer = window.setTimeout(saveEmailSettings, 300)
}
// ========== SMTP 配置 ==========
const smtpLoading = ref(false)
const smtpConfigs = ref([])
const smtpDialogOpen = ref(false)
const smtpEditMode = ref(false)
const smtpHasPassword = ref(false)
const smtpForm = reactive({
id: null,
name: '默认配置',
enabled: true,
host: '',
port: 465,
username: '',
password: '',
use_ssl: true,
use_tls: false,
sender_name: '自动化学习',
sender_email: '',
daily_limit: 0,
priority: 0,
})
const smtpPasswordPlaceholder = computed(() =>
smtpEditMode.value && smtpHasPassword.value ? '留空保持不变' : 'SMTP密码或授权码',
)
function resetSmtpForm() {
smtpForm.id = null
smtpForm.name = '默认配置'
smtpForm.enabled = true
smtpForm.host = ''
smtpForm.port = 465
smtpForm.username = ''
smtpForm.password = ''
smtpForm.use_ssl = true
smtpForm.use_tls = false
smtpForm.sender_name = '自动化学习'
smtpForm.sender_email = ''
smtpForm.daily_limit = 0
smtpForm.priority = 0
smtpHasPassword.value = false
}
async function loadSmtpConfigs() {
smtpLoading.value = true
try {
smtpConfigs.value = await fetchSmtpConfigs()
} catch {
smtpConfigs.value = []
} finally {
smtpLoading.value = false
}
}
function openCreateSmtp() {
smtpEditMode.value = false
resetSmtpForm()
smtpDialogOpen.value = true
}
function openEditSmtp(row) {
smtpEditMode.value = true
resetSmtpForm()
smtpForm.id = row.id
smtpForm.name = row.name || '默认配置'
smtpForm.enabled = Boolean(row.enabled)
smtpForm.host = row.host || ''
smtpForm.port = row.port || 465
smtpForm.username = row.username || ''
smtpForm.password = ''
smtpForm.use_ssl = Boolean(row.use_ssl)
smtpForm.use_tls = Boolean(row.use_tls)
smtpForm.sender_name = row.sender_name || '自动化学习'
smtpForm.sender_email = row.sender_email || ''
smtpForm.daily_limit = row.daily_limit ?? 0
smtpForm.priority = row.priority ?? 0
smtpHasPassword.value = Boolean(row.has_password)
smtpDialogOpen.value = true
}
function smtpStatusMeta(row) {
if (row.is_primary) return { label: '主', type: 'warning' }
if (row.enabled) return { label: '备用', type: 'success' }
return { label: '禁用', type: 'info' }
}
function smtpDailyText(row) {
if (row.daily_limit && row.daily_limit > 0) return `${row.daily_sent}/${row.daily_limit}`
return `${row.daily_sent}/∞`
}
async function saveSmtp() {
if (!smtpForm.host.trim()) {
ElMessage.error('SMTP服务器地址不能为空')
return
}
if (!smtpForm.username.trim()) {
ElMessage.error('SMTP用户名不能为空')
return
}
const basePayload = {
name: smtpForm.name.trim() || '默认配置',
enabled: Boolean(smtpForm.enabled),
priority: Number(smtpForm.priority) || 0,
host: smtpForm.host.trim(),
port: Number(smtpForm.port) || 465,
username: smtpForm.username.trim(),
use_ssl: Boolean(smtpForm.use_ssl),
use_tls: Boolean(smtpForm.use_tls),
sender_name: (smtpForm.sender_name || '').trim(),
sender_email: (smtpForm.sender_email || '').trim(),
daily_limit: Number(smtpForm.daily_limit) || 0,
}
try {
if (smtpEditMode.value) {
const payload = { ...basePayload }
if (smtpForm.password) payload.password = smtpForm.password
const res = await updateSmtpConfig(smtpForm.id, payload)
if (!res?.success) {
ElMessage.error(res?.error || '更新失败')
return
}
ElMessage.success('保存成功')
} else {
const payload = { ...basePayload }
if (smtpForm.password) payload.password = smtpForm.password
const res = await createSmtpConfig(payload)
if (!res?.success) {
ElMessage.error(res?.error || '创建失败')
return
}
ElMessage.success('创建成功')
}
smtpDialogOpen.value = false
await loadSmtpConfigs()
} catch {
// handled by interceptor
}
}
async function doTestSmtp() {
if (!smtpEditMode.value || !smtpForm.id) {
ElMessage.error('请先保存配置后再测试')
return
}
let email
try {
const res = await ElMessageBox.prompt('请输入测试收件邮箱', '测试连接', {
inputPlaceholder: 'name@example.com',
confirmButtonText: '发送测试邮件',
cancelButtonText: '取消',
inputValidator: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v || '').trim()),
inputErrorMessage: '邮箱格式不正确',
})
email = String(res.value || '').trim()
} catch {
return
}
try {
const res = await testSmtpConfig(smtpForm.id, email)
if (res?.success) {
ElMessage.success('测试成功,邮件已发送')
await loadSmtpConfigs()
} else {
await ElMessageBox.alert(res?.error || '测试失败', '测试失败', { confirmButtonText: '知道了' })
}
} catch {
// handled by interceptor
}
}
async function doSetPrimary() {
if (!smtpEditMode.value || !smtpForm.id) return
try {
await ElMessageBox.confirm('确定将该配置设为主配置吗?', '设为主配置', {
confirmButtonText: '设为主配置',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await setPrimarySmtpConfig(smtpForm.id)
if (!res?.success) {
ElMessage.error(res?.error || '设置失败')
return
}
ElMessage.success('已设为主配置')
smtpDialogOpen.value = false
await loadSmtpConfigs()
} catch {
// handled by interceptor
}
}
async function doDeleteSmtp() {
if (!smtpEditMode.value || !smtpForm.id) return
try {
await ElMessageBox.confirm('确定删除该SMTP配置吗此操作不可恢复。', '删除配置', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'error',
})
} catch {
return
}
try {
const res = await deleteSmtpConfig(smtpForm.id)
if (!res?.success) {
ElMessage.error(res?.error || '删除失败')
return
}
ElMessage.success('已删除')
smtpDialogOpen.value = false
await loadSmtpConfigs()
} catch {
// handled by interceptor
}
}
// ========== 邮件统计 / 日志 ==========
const emailStatsLoading = ref(false)
const emailStats = ref({})
const emailLogsLoading = ref(false)
const emailLogTypeFilter = ref('')
const emailLogStatusFilter = ref('')
const emailLogPage = ref(1)
const emailLogPageSize = 15
const emailLogs = ref([])
const emailLogTotal = ref(0)
const emailLogTotalPages = ref(1)
function emailTypeLabel(type) {
const map = {
register: '注册验证',
reset: '密码重置',
bind: '邮箱绑定',
task_complete: '任务完成',
}
return map[type] || type
}
async function loadEmailStats() {
emailStatsLoading.value = true
try {
emailStats.value = await fetchEmailStats()
} catch {
emailStats.value = {}
} finally {
emailStatsLoading.value = false
}
}
async function loadEmailLogs(page = 1) {
emailLogsLoading.value = true
try {
const params = {
page,
page_size: emailLogPageSize,
}
if (emailLogTypeFilter.value) params.type = emailLogTypeFilter.value
if (emailLogStatusFilter.value) params.status = emailLogStatusFilter.value
const data = await fetchEmailLogs(params)
emailLogs.value = data?.logs || []
emailLogTotal.value = data?.total || 0
emailLogPage.value = data?.page || page
emailLogTotalPages.value = data?.total_pages || 1
} catch {
emailLogs.value = []
emailLogTotal.value = 0
emailLogTotalPages.value = 1
} finally {
emailLogsLoading.value = false
}
}
async function onCleanupEmailLogs() {
let days
try {
const res = await ElMessageBox.prompt('请输入保留天数(将删除该天数之前的日志)', '清理日志', {
inputValue: '30',
confirmButtonText: '清理',
cancelButtonText: '取消',
inputValidator: (v) => {
const n = parseInt(String(v), 10)
return Number.isFinite(n) && n >= 7
},
inputErrorMessage: '天数必须大于等于7',
})
days = parseInt(String(res.value), 10)
} catch {
return
}
try {
await ElMessageBox.confirm(`确定删除 ${days} 天之前的邮件日志吗?`, '二次确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await cleanupEmailLogs(days)
if (!res?.success) {
ElMessage.error(res?.error || '清理失败')
return
}
ElMessage.success(`已清理 ${res.deleted} 条日志`)
await loadEmailLogs(1)
} catch {
// handled by interceptor
}
}
async function refreshAll() {
await Promise.all([loadEmailSettings(), loadSmtpConfigs(), loadEmailStats(), loadEmailLogs(1)])
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>邮件配置</h2>
<div class="toolbar">
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailSettingsLoading">
<h3 class="section-title">全局设置</h3>
<el-form label-width="140px">
<el-form-item label="启用邮件功能">
<el-switch v-model="settings.enabled" :disabled="emailSettingsSaving" @change="scheduleSaveEmailSettings" />
</el-form-item>
<el-form-item label="启用故障转移">
<el-switch
v-model="settings.failover_enabled"
:disabled="emailSettingsSaving"
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-form-item label="启用注册邮箱验证">
<el-switch
v-model="settings.register_verify_enabled"
:disabled="emailSettingsSaving"
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-form-item label="启用任务完成通知">
<el-switch
v-model="settings.task_notify_enabled"
:disabled="emailSettingsSaving"
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-form-item label="网站基础URL">
<el-input
v-model="settings.base_url"
placeholder="例如: https://example.com"
:disabled="emailSettingsSaving"
@blur="scheduleSaveEmailSettings"
/>
<div class="help">用于生成邮件中的验证链接留空则使用默认配置</div>
</el-form-item>
</el-form>
<div class="help app-muted">最近更新时间{{ settings.updated_at || '-' }}</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="section-head">
<h3 class="section-title">SMTP配置列表</h3>
<el-button type="primary" @click="openCreateSmtp">+ 添加配置</el-button>
</div>
<div class="table-wrap">
<el-table :data="smtpConfigs" v-loading="smtpLoading" style="width: 100%">
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="smtpStatusMeta(row).type" effect="light">
{{ smtpStatusMeta(row).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column label="服务器" min-width="200">
<template #default="{ row }">{{ row.host }}:{{ row.port }}</template>
</el-table-column>
<el-table-column label="今日/限额" width="110">
<template #default="{ row }">{{ smtpDailyText(row) }}</template>
</el-table-column>
<el-table-column label="成功率" width="100">
<template #default="{ row }">{{ row.success_rate }}%</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditSmtp(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="emailStatsLoading">
<h3 class="section-title">邮件发送统计</h3>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.total_sent || 0 }}</div>
<div class="stat-label">总发送</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ emailStats.total_success || 0 }}</div>
<div class="stat-label">成功</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value err">{{ emailStats.total_failed || 0 }}</div>
<div class="stat-label">失败</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ emailStats.success_rate || 0 }}%</div>
<div class="stat-label">成功率</div>
</el-card>
</el-col>
</el-row>
<div class="sub-stats">
<el-tag effect="light">注册验证 {{ emailStats.register_sent || 0 }}</el-tag>
<el-tag effect="light">密码重置 {{ emailStats.reset_sent || 0 }}</el-tag>
<el-tag effect="light">邮箱绑定 {{ emailStats.bind_sent || 0 }}</el-tag>
<el-tag effect="light">任务完成 {{ emailStats.task_complete_sent || 0 }}</el-tag>
</div>
<div class="help app-muted">最后更新{{ emailStats.last_updated || '-' }}</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="section-head">
<h3 class="section-title">邮件发送日志</h3>
<div class="toolbar">
<el-select v-model="emailLogTypeFilter" style="width: 140px" @change="loadEmailLogs(1)">
<el-option label="全部类型" value="" />
<el-option label="注册验证" value="register" />
<el-option label="密码重置" value="reset" />
<el-option label="邮箱绑定" value="bind" />
<el-option label="任务完成" value="task_complete" />
</el-select>
<el-select v-model="emailLogStatusFilter" style="width: 120px" @change="loadEmailLogs(1)">
<el-option label="全部状态" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-button type="danger" plain @click="onCleanupEmailLogs">清理日志</el-button>
</div>
</div>
<div class="table-wrap">
<el-table :data="emailLogs" v-loading="emailLogsLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column prop="email_to" label="收件人" min-width="180" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ emailTypeLabel(row.email_type) }}</template>
</el-table-column>
<el-table-column label="主题" min-width="220">
<template #default="{ row }">
<span class="ellipsis" :title="row.subject">{{ row.subject }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" effect="light">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="错误" min-width="200">
<template #default="{ row }">
<span class="ellipsis" :title="row.error_message || ''">{{ row.error_message || '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="emailLogPage"
:page-size="emailLogPageSize"
:total="emailLogTotal"
layout="prev, pager, next, ->, total"
@current-change="loadEmailLogs"
/>
<div class="page-hint app-muted"> {{ emailLogPage }} / {{ emailLogTotalPages }} </div>
</div>
</el-card>
<el-dialog v-model="smtpDialogOpen" :title="smtpEditMode ? '编辑SMTP配置' : '添加SMTP配置'" width="560px">
<el-form label-width="120px">
<el-form-item label="名称">
<el-input v-model="smtpForm.name" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="smtpForm.enabled" />
</el-form-item>
<el-form-item label="服务器">
<el-input v-model="smtpForm.host" placeholder="smtp.example.com" />
</el-form-item>
<el-form-item label="端口">
<el-input-number v-model="smtpForm.port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="smtpForm.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="smtpForm.password" type="password" show-password :placeholder="smtpPasswordPlaceholder" />
</el-form-item>
<el-form-item label="SSL">
<el-switch v-model="smtpForm.use_ssl" />
</el-form-item>
<el-form-item label="TLS">
<el-switch v-model="smtpForm.use_tls" />
</el-form-item>
<el-form-item label="发件人名称">
<el-input v-model="smtpForm.sender_name" />
</el-form-item>
<el-form-item label="发件人邮箱">
<el-input v-model="smtpForm.sender_email" placeholder="可选" />
</el-form-item>
<el-form-item label="每日限额">
<el-input-number v-model="smtpForm.daily_limit" :min="0" :max="1000000" />
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="smtpForm.priority" :min="0" :max="1000" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-actions">
<el-button @click="doTestSmtp">测试连接</el-button>
<el-button v-if="smtpEditMode" @click="doSetPrimary">设为主配置</el-button>
<el-button v-if="smtpEditMode" type="danger" plain @click="doDeleteSmtp">删除配置</el-button>
<div class="spacer"></div>
<el-button @click="smtpDialogOpen = false">取消</el-button>
<el-button type="primary" @click="saveSmtp">保存</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 8px;
font-size: 12px;
color: var(--app-muted);
}
.table-wrap {
overflow-x: auto;
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-value {
font-size: 20px;
font-weight: 900;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.ok {
color: #047857;
}
.err {
color: #b91c1c;
}
.sub-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.spacer {
flex: 1;
}
</style>

View File

@@ -0,0 +1,256 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { closeFeedback, deleteFeedback, fetchFeedbacks, replyFeedback } from '../api/feedbacks'
const loading = ref(false)
const statusFilter = ref('')
const stats = ref({ total: 0, pending: 0, replied: 0, closed: 0 })
const list = ref([])
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '待处理', value: 'pending' },
{ label: '已回复', value: 'replied' },
{ label: '已关闭', value: 'closed' },
]
function statusMeta(status) {
if (status === 'pending') return { label: '待处理', type: 'warning' }
if (status === 'replied') return { label: '已回复', type: 'success' }
if (status === 'closed') return { label: '已关闭', type: 'info' }
return { label: status || '-', type: 'info' }
}
async function load() {
loading.value = true
try {
const data = await fetchFeedbacks(statusFilter.value)
list.value = data?.feedbacks || []
stats.value = data?.stats || { total: 0, pending: 0, replied: 0, closed: 0 }
} catch {
list.value = []
stats.value = { total: 0, pending: 0, replied: 0, closed: 0 }
} finally {
loading.value = false
}
}
async function onReply(row) {
let text
try {
const res = await ElMessageBox.prompt('请输入回复内容', '回复反馈', {
inputType: 'textarea',
inputPlaceholder: '回复内容',
confirmButtonText: '提交',
cancelButtonText: '取消',
inputValidator: (v) => Boolean(String(v || '').trim()),
inputErrorMessage: '回复内容不能为空',
})
text = res.value
} catch {
return
}
try {
const res = await replyFeedback(row.id, String(text || '').trim())
ElMessage.success(res?.message || '回复成功')
await load()
} catch {
// handled by interceptor
}
}
async function onClose(row) {
try {
await ElMessageBox.confirm('确定要关闭这个反馈吗?', '关闭反馈', {
confirmButtonText: '关闭',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await closeFeedback(row.id)
ElMessage.success(res?.message || '反馈已关闭')
await load()
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm('确定要删除这个反馈吗?此操作不可恢复!', '删除反馈', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'error',
})
} catch {
return
}
try {
const res = await deleteFeedback(row.id)
ElMessage.success(res?.message || '反馈已删除')
await load()
} catch {
// handled by interceptor
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>反馈管理</h2>
<div class="toolbar">
<el-select v-model="statusFilter" style="width: 160px" @change="load">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.total || 0 }}</div>
<div class="stat-label">总计</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value warn">{{ stats.pending || 0 }}</div>
<div class="stat-label">待处理</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value ok">{{ stats.replied || 0 }}</div>
<div class="stat-label">已回复</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">{{ stats.closed || 0 }}</div>
<div class="stat-label">已关闭</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="table-wrap">
<el-table :data="list" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户" width="140" />
<el-table-column label="标题" min-width="180">
<template #default="{ row }">
<el-tooltip :content="row.title" placement="top" :show-after="300">
<span class="ellipsis">{{ row.title }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="描述" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.description" placement="top" :show-after="300">
<span class="ellipsis">{{ row.description }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="contact" label="联系方式" min-width="160">
<template #default="{ row }">{{ row.contact || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" width="180" />
<el-table-column label="回复" min-width="180">
<template #default="{ row }">
<el-tooltip :content="row.admin_reply || ''" placement="top" :show-after="300">
<span class="ellipsis">{{ row.admin_reply || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div class="actions">
<template v-if="row.status !== 'closed'">
<el-button type="primary" size="small" @click="onReply(row)">回复</el-button>
<el-button size="small" @click="onClose(row)">关闭</el-button>
</template>
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
}
.card,
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-value {
font-size: 20px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.warn {
color: #b45309;
}
.ok {
color: #047857;
}
.table-wrap {
overflow-x: auto;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,285 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAllUsers } from '../api/users'
import { clearOldTaskLogs, fetchTaskLogs } from '../api/tasks'
const pageSize = 20
const loading = ref(false)
const logs = ref([])
const total = ref(0)
const currentPage = ref(1)
const usersLoading = ref(false)
const userOptions = ref([])
const dateFilter = ref('')
const statusFilter = ref('')
const sourceFilter = ref('')
const userIdFilter = ref('')
const accountFilter = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
function formatDuration(seconds) {
if (seconds === null || seconds === undefined) return '-'
const n = Number(seconds)
if (!Number.isFinite(n)) return '-'
if (n < 60) return `${n}`
return `${Math.floor(n / 60)}${n % 60}`
}
function sourceMeta(source) {
const map = {
manual: { label: '手动', type: 'success' },
scheduled: { label: '定时', type: 'primary' },
immediate: { label: '即时', type: 'warning' },
resumed: { label: '恢复', type: 'info' },
}
return map[source] || { label: source || '手动', type: 'info' }
}
function statusMeta(status) {
if (status === 'success') return { label: '成功', type: 'success' }
if (status === 'failed') return { label: '失败', type: 'danger' }
return { label: status || '-', type: 'info' }
}
async function loadUsers() {
usersLoading.value = true
try {
const users = await fetchAllUsers()
userOptions.value = (users || []).map((u) => ({ id: u.id, username: u.username }))
} catch {
userOptions.value = []
} finally {
usersLoading.value = false
}
}
async function load() {
loading.value = true
try {
const offset = (currentPage.value - 1) * pageSize
const params = {
limit: pageSize,
offset,
}
if (dateFilter.value) params.date = dateFilter.value
if (statusFilter.value) params.status = statusFilter.value
if (sourceFilter.value) params.source = sourceFilter.value
if (userIdFilter.value) params.user_id = userIdFilter.value
if (accountFilter.value) params.account = accountFilter.value
const data = await fetchTaskLogs(params)
logs.value = data?.logs || []
total.value = data?.total || 0
} catch {
logs.value = []
total.value = 0
} finally {
loading.value = false
}
}
function onFilter() {
currentPage.value = 1
load()
}
function onReset() {
dateFilter.value = ''
statusFilter.value = ''
sourceFilter.value = ''
userIdFilter.value = ''
accountFilter.value = ''
currentPage.value = 1
load()
}
async function onClearOld() {
let days
try {
const res = await ElMessageBox.prompt('请输入要清理多少天前的日志默认30天', '清理旧日志', {
inputValue: '30',
confirmButtonText: '下一步',
cancelButtonText: '取消',
inputValidator: (v) => {
const n = parseInt(String(v), 10)
return Number.isFinite(n) && n >= 1
},
inputErrorMessage: '请输入有效的天数大于0的整数',
})
days = parseInt(String(res.value), 10)
} catch {
return
}
try {
await ElMessageBox.confirm(`确定要删除 ${days} 天前的所有日志吗?此操作不可恢复!`, '二次确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await clearOldTaskLogs(days)
ElMessage.success(res?.message || '清理成功')
currentPage.value = 1
await load()
} catch {
// handled by interceptor
}
}
onMounted(async () => {
await loadUsers()
await load()
})
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>任务日志</h2>
<div class="toolbar">
<el-button @click="load">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="filters">
<el-date-picker
v-model="dateFilter"
type="date"
value-format="YYYY-MM-DD"
placeholder="日期"
style="width: 150px"
/>
<el-select v-model="statusFilter" placeholder="状态" style="width: 120px">
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="sourceFilter" placeholder="来源" style="width: 120px">
<el-option label="全部" value="" />
<el-option label="手动" value="manual" />
<el-option label="定时" value="scheduled" />
<el-option label="即时" value="immediate" />
<el-option label="恢复" value="resumed" />
</el-select>
<el-select
v-model="userIdFilter"
placeholder="用户"
style="width: 140px"
:loading="usersLoading"
filterable
clearable
>
<el-option label="全部" value="" />
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
</el-select>
<el-input v-model="accountFilter" placeholder="账号关键字" style="width: 170px" clearable />
<el-button type="primary" @click="onFilter">筛选</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="danger" plain @click="onClearOld">清理旧日志</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="table-wrap">
<el-table :data="logs" v-loading="loading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="来源" width="90">
<template #default="{ row }">
<el-tag :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="user_username" label="用户" width="140" />
<el-table-column prop="username" label="账号" width="160" />
<el-table-column prop="browse_type" label="浏览类型" width="120" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="内容/附件" width="110">
<template #default="{ row }">{{ row.total_items }} / {{ row.total_attachments }}</template>
</el-table-column>
<el-table-column label="用时" width="90">
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
</el-table-column>
<el-table-column label="失败原因" min-width="220">
<template #default="{ row }">
<el-tooltip :content="row.error_message || ''" placement="top" :show-after="300">
<span class="ellipsis">{{ row.error_message || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next, jumper, ->, total"
@current-change="load"
/>
<div class="page-hint app-muted"> {{ currentPage }} / {{ totalPages }} </div>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.table-wrap {
overflow-x: auto;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,224 @@
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchPendingUsers, approveUser, rejectUser } from '../api/users'
import { approvePasswordReset, fetchPasswordResets, rejectPasswordReset } from '../api/passwordResets'
import { parseSqliteDateTime } from '../utils/datetime'
const refreshStats = inject('refreshStats', null)
const pendingUsers = ref([])
const passwordResets = ref([])
const loadingPending = ref(false)
const loadingResets = ref(false)
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
if (String(expire).startsWith('2099-12-31')) return true
const dt = parseSqliteDateTime(expire)
return dt ? dt.getTime() > Date.now() : false
}
async function loadPending() {
loadingPending.value = true
try {
pendingUsers.value = await fetchPendingUsers()
} catch {
pendingUsers.value = []
} finally {
loadingPending.value = false
}
}
async function loadResets() {
loadingResets.value = true
try {
passwordResets.value = await fetchPasswordResets()
} catch {
passwordResets.value = []
} finally {
loadingResets.value = false
}
}
async function refreshAll() {
await Promise.all([loadPending(), loadResets()])
}
async function onApproveUser(row) {
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}」的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onRejectUser(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}」的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onApproveReset(row) {
try {
await ElMessageBox.confirm(`确定批准「${row.username}」的密码重置申请吗?`, '批准重置', {
confirmButtonText: '批准',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
const res = await approvePasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已批准')
await loadResets()
} catch {
// handled by interceptor
}
}
async function onRejectReset(row) {
try {
await ElMessageBox.confirm(`确定拒绝「${row.username}」的密码重置申请吗?`, '拒绝重置', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await rejectPasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已拒绝')
await loadResets()
} catch {
// handled by interceptor
}
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>待审核</h2>
<div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">用户注册审核</h3>
<div class="table-wrap">
<el-table :data="pendingUsers" v-loading="loadingPending" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户名" min-width="200">
<template #default="{ row }">
<div class="user-cell">
<strong>{{ row.username }}</strong>
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveUser(row)">通过</el-button>
<el-button type="danger" size="small" @click="onRejectUser(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">密码重置审核</h3>
<div class="table-wrap">
<el-table :data="passwordResets" v-loading="loadingResets" style="width: 100%">
<el-table-column prop="id" label="申请ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="200" />
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveReset(row)">批准</el-button>
<el-button type="danger" size="small" @click="onRejectReset(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.table-wrap {
overflow-x: auto;
}
.user-cell {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { logout, updateAdminPassword, updateAdminUsername } from '../api/admin'
const username = ref('')
const password = ref('')
const submitting = ref(false)
async function relogin() {
try {
await logout()
} catch {
// ignore
} finally {
window.location.href = '/yuyx'
}
}
async function saveUsername() {
const value = username.value.trim()
if (!value) {
ElMessage.error('请输入新用户名')
return
}
try {
await ElMessageBox.confirm(`确定将管理员用户名修改为「${value}」吗?修改后需要重新登录。`, '修改用户名', {
confirmButtonText: '确认修改',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
try {
await updateAdminUsername(value)
ElMessage.success('用户名修改成功,请重新登录')
username.value = ''
setTimeout(relogin, 1200)
} catch {
// handled by interceptor
} finally {
submitting.value = false
}
}
async function savePassword() {
const value = password.value
if (!value) {
ElMessage.error('请输入新密码')
return
}
if (value.length < 6) {
ElMessage.error('密码至少6个字符')
return
}
try {
await ElMessageBox.confirm('确定修改管理员密码吗?修改后需要重新登录。', '修改密码', {
confirmButtonText: '确认修改',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
submitting.value = true
try {
await updateAdminPassword(value)
ElMessage.success('密码修改成功,请重新登录')
password.value = ''
setTimeout(relogin, 1200)
} catch {
// handled by interceptor
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>设置</h2>
<span class="app-muted">管理员账号设置</span>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">修改管理员用户名</h3>
<el-form label-width="120px">
<el-form-item label="新用户名">
<el-input v-model="username" placeholder="输入新用户名" :disabled="submitting" />
</el-form-item>
</el-form>
<el-button type="primary" :loading="submitting" @click="saveUsername">保存用户名</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">修改管理员密码</h3>
<el-form label-width="120px">
<el-form-item label="新密码">
<el-input
v-model="password"
type="password"
show-password
placeholder="输入新密码"
:disabled="submitting"
/>
</el-form-item>
</el-form>
<el-button type="primary" :loading="submitting" @click="savePassword">保存密码</el-button>
<div class="help">建议使用更强密码至少8位且包含字母与数字</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 10px;
font-size: 12px;
color: var(--app-muted);
}
</style>

View File

@@ -0,0 +1,467 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
const loading = ref(false)
const server = ref({
cpu_percent: '-',
memory_used: '-',
memory_total: '-',
disk_used: '-',
disk_total: '-',
uptime: '-',
})
const docker = ref({
status: 'Unknown',
memory_usage: 'N/A',
memory_limit: 'N/A',
memory_percent: 'N/A',
uptime: 'N/A',
})
const taskStats = ref({
today: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
total: { success_tasks: 0, failed_tasks: 0, total_items: 0, total_attachments: 0 },
})
const monitor = ref({
running_count: 0,
queuing_count: 0,
max_concurrent: 0,
running: [],
queuing: [],
})
const sourceMap = {
manual: { label: '手动', type: 'success' },
scheduled: { label: '定时', type: 'primary' },
immediate: { label: '即时', type: 'warning' },
resumed: { label: '恢复', type: 'info' },
}
const statusColorMap = {
初始化: '#6b7280',
正在登录: '#f59e0b',
正在浏览: '#10b981',
浏览完成: '#3b82f6',
正在截图: '#06b6d4',
}
function statusColor(text) {
return statusColorMap[text] || '#6b7280'
}
const serverMemoryDisplay = computed(() => `${server.value.memory_used} / ${server.value.memory_total}`)
const serverDiskDisplay = computed(() => `${server.value.disk_used} / ${server.value.disk_total}`)
let stop = false
let timer = null
async function loadOnce() {
loading.value = true
try {
const [serverInfo, dockerInfo, taskStat, running] = await Promise.all([
fetchServerInfo(),
fetchDockerStats(),
fetchTaskStats(),
fetchRunningTasks(),
])
server.value = serverInfo || server.value
docker.value = dockerInfo || docker.value
taskStats.value = taskStat || taskStats.value
monitor.value = running || monitor.value
} catch {
// handled by interceptor
} finally {
loading.value = false
}
}
async function loop() {
if (stop) return
const start = Date.now()
await loadOnce()
if (stop) return
const elapsed = Date.now() - start
// server/info 正常会阻塞约 1s如果异常很快失败避免疯狂重试
timer = window.setTimeout(loop, elapsed < 900 ? 1000 : 0)
}
onMounted(() => {
stop = false
loop()
})
onBeforeUnmount(() => {
stop = true
if (timer) window.clearTimeout(timer)
})
</script>
<template>
<div class="page-stack" v-loading="loading">
<div class="app-page-title">
<h2>统计</h2>
<span class="app-muted">实时更新</span>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">CPU</div>
<div class="metric-value">{{ server.cpu_percent }}%</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">内存</div>
<div class="metric-value">{{ serverMemoryDisplay }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">磁盘</div>
<div class="metric-value">{{ serverDiskDisplay }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="8" :md="6">
<el-card shadow="never" class="metric-card" :body-style="{ padding: '14px' }">
<div class="metric-label">容器内存</div>
<div class="metric-value">{{ docker.memory_limit !== 'N/A' ? `${docker.memory_usage} / ${docker.memory_limit}` : docker.memory_usage }}</div>
<div v-if="docker.memory_percent !== 'N/A'" class="metric-sub app-muted">{{ docker.memory_percent }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :md="14">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">实时监控</h3>
<span class="app-muted">最大并发{{ monitor.max_concurrent }}</span>
</div>
<el-row :gutter="12" class="count-row">
<el-col :span="8">
<el-card shadow="never" class="count-card ok" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.running_count }}</div>
<div class="count-label">运行中</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="count-card warn" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.queuing_count }}</div>
<div class="count-label">排队中</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="count-card" :body-style="{ padding: '12px' }">
<div class="count-value">{{ monitor.max_concurrent }}</div>
<div class="count-label">并发上限</div>
</el-card>
</el-col>
</el-row>
<div class="sub-title">运行中任务</div>
<div v-if="monitor.running.length === 0" class="empty app-muted">暂无运行中的任务</div>
<div v-else class="task-list">
<div v-for="t in monitor.running" :key="`r-${t.account_id}`" class="task-item">
<div class="task-left">
<div class="task-line">
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
{{ (sourceMap[t.source] || sourceMap.manual).label }}
</el-tag>
<span class="task-user">{{ t.user_username }}</span>
<span class="app-muted"></span>
<span class="task-account">{{ t.username }}</span>
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
</div>
<div class="task-line2">
<span class="dot" :style="{ background: statusColor(t.detail_status) }"></span>
<span class="task-status" :style="{ color: statusColor(t.detail_status) }">{{ t.detail_status }}</span>
<span v-if="t.progress_items || t.progress_attachments" class="app-muted"
>内容/附件{{ t.progress_items }} / {{ t.progress_attachments }}</span
>
</div>
</div>
<div class="task-right">{{ t.elapsed_display }}</div>
</div>
</div>
<div class="sub-title">排队中任务</div>
<div v-if="monitor.queuing.length === 0" class="empty app-muted">暂无排队中的任务</div>
<div v-else class="task-list">
<div v-for="t in monitor.queuing" :key="`q-${t.account_id}`" class="task-item queue">
<div class="task-left">
<div class="task-line">
<el-tag :type="(sourceMap[t.source] || sourceMap.manual).type" effect="light" size="small">
{{ (sourceMap[t.source] || sourceMap.manual).label }}
</el-tag>
<span class="task-user">{{ t.user_username }}</span>
<span class="app-muted"></span>
<span class="task-account">{{ t.username }}</span>
<el-tag effect="plain" size="small">{{ t.browse_type }}</el-tag>
</div>
<div class="task-line2">
<span class="dot" style="background: #f59e0b"></span>
<span class="task-status" style="color: #f59e0b">{{ t.detail_status || '等待资源' }}</span>
</div>
</div>
<div class="task-right warn">{{ t.elapsed_display }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">任务统计</h3>
<span class="app-muted">运行{{ server.uptime }}</span>
</div>
<div class="stat-grid">
<div class="stat-box ok">
<div class="stat-name">成功任务</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.success_tasks }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.success_tasks }}</div>
</div>
<div class="stat-box err">
<div class="stat-name">失败任务</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.failed_tasks }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.failed_tasks }}</div>
</div>
<div class="stat-box info">
<div class="stat-name">浏览内容</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.total_items }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.total_items }}</div>
</div>
<div class="stat-box info2">
<div class="stat-name">查看附件</div>
<div class="stat-row">
<span class="stat-big">{{ taskStats.today.total_attachments }}</span>
<span class="app-muted">今日</span>
</div>
<div class="stat-row2 app-muted">累计{{ taskStats.total.total_attachments }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-card,
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.metric-label {
font-size: 12px;
color: var(--app-muted);
}
.metric-value {
margin-top: 6px;
font-size: 18px;
font-weight: 800;
}
.metric-sub {
margin-top: 4px;
font-size: 12px;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.count-row {
margin-bottom: 10px;
}
.count-card {
border-radius: 10px;
border: 1px solid var(--app-border);
}
.count-card.ok {
background: rgba(16, 185, 129, 0.08);
}
.count-card.warn {
background: rgba(245, 158, 11, 0.08);
}
.count-value {
font-size: 22px;
font-weight: 900;
line-height: 1.1;
}
.count-label {
margin-top: 4px;
font-size: 12px;
color: var(--app-muted);
}
.sub-title {
margin-top: 14px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 800;
}
.empty {
padding: 10px 0;
}
.task-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.task-item.queue {
background: rgba(245, 158, 11, 0.06);
}
.task-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.task-line2 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
font-size: 12px;
}
.task-user {
font-weight: 600;
}
.task-account {
font-weight: 700;
color: #2563eb;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.task-status {
font-weight: 700;
}
.task-right {
font-size: 12px;
font-weight: 700;
color: #10b981;
white-space: nowrap;
}
.task-right.warn {
color: #f59e0b;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.stat-box {
border-radius: 12px;
border: 1px solid var(--app-border);
padding: 12px;
}
.stat-box.ok {
background: rgba(16, 185, 129, 0.08);
}
.stat-box.err {
background: rgba(239, 68, 68, 0.08);
}
.stat-box.info {
background: rgba(59, 130, 246, 0.08);
}
.stat-box.info2 {
background: rgba(6, 182, 212, 0.08);
}
.stat-name {
font-size: 12px;
font-weight: 800;
margin-bottom: 6px;
}
.stat-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-big {
font-size: 20px;
font-weight: 900;
}
.stat-row2 {
margin-top: 6px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,378 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchSystemConfig, updateSystemConfig, executeScheduleNow } from '../api/system'
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
const loading = ref(false)
// 并发
const maxConcurrentGlobal = ref(2)
const maxConcurrentPerAccount = ref(1)
const maxScreenshotConcurrent = ref(3)
// 定时
const scheduleEnabled = ref(false)
const scheduleTime = ref('02:00')
const scheduleBrowseType = ref('应读')
const scheduleWeekdays = ref(['1', '2', '3', '4', '5', '6', '7'])
// 代理
const proxyEnabled = ref(false)
const proxyApiUrl = ref('')
const proxyExpireMinutes = ref(3)
// 自动审核
const autoApproveEnabled = ref(false)
const autoApproveHourlyLimit = ref(10)
const autoApproveVipDays = ref(7)
const weekdaysOptions = [
{ label: '周一', value: '1' },
{ label: '周二', value: '2' },
{ label: '周三', value: '3' },
{ label: '周四', value: '4' },
{ label: '周五', value: '5' },
{ label: '周六', value: '6' },
{ label: '周日', value: '7' },
]
const weekdayNames = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
}
const scheduleWeekdayDisplay = computed(() =>
(scheduleWeekdays.value || [])
.map((d) => weekdayNames[Number(d)] || d)
.join('、'),
)
async function loadAll() {
loading.value = true
try {
const [system, proxy] = await Promise.all([fetchSystemConfig(), fetchProxyConfig()])
maxConcurrentGlobal.value = system.max_concurrent_global ?? 2
maxConcurrentPerAccount.value = system.max_concurrent_per_account ?? 1
maxScreenshotConcurrent.value = system.max_screenshot_concurrent ?? 3
scheduleEnabled.value = (system.schedule_enabled ?? 0) === 1
scheduleTime.value = system.schedule_time || '02:00'
scheduleBrowseType.value = system.schedule_browse_type || '应读'
const weekdays = String(system.schedule_weekdays || '1,2,3,4,5,6,7')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
scheduleWeekdays.value = weekdays.length ? weekdays : ['1', '2', '3', '4', '5', '6', '7']
autoApproveEnabled.value = (system.auto_approve_enabled ?? 0) === 1
autoApproveHourlyLimit.value = system.auto_approve_hourly_limit ?? 10
autoApproveVipDays.value = system.auto_approve_vip_days ?? 7
proxyEnabled.value = (proxy.proxy_enabled ?? 0) === 1
proxyApiUrl.value = proxy.proxy_api_url || ''
proxyExpireMinutes.value = proxy.proxy_expire_minutes ?? 3
} catch {
// handled by interceptor
} finally {
loading.value = false
}
}
async function saveConcurrency() {
const payload = {
max_concurrent_global: Number(maxConcurrentGlobal.value),
max_concurrent_per_account: Number(maxConcurrentPerAccount.value),
max_screenshot_concurrent: Number(maxScreenshotConcurrent.value),
}
try {
await ElMessageBox.confirm(
`确定更新并发配置吗?\n\n全局并发数: ${payload.max_concurrent_global}\n单账号并发数: ${payload.max_concurrent_per_account}\n截图并发数: ${payload.max_screenshot_concurrent}`,
'保存并发配置',
{ confirmButtonText: '保存', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '并发配置已更新')
} catch {
// handled by interceptor
}
}
async function saveSchedule() {
if (scheduleEnabled.value && (!scheduleWeekdays.value || scheduleWeekdays.value.length === 0)) {
ElMessage.error('请至少选择一个执行日期')
return
}
const payload = {
schedule_enabled: scheduleEnabled.value ? 1 : 0,
schedule_time: scheduleTime.value,
schedule_browse_type: scheduleBrowseType.value,
schedule_weekdays: (scheduleWeekdays.value || []).join(','),
}
const message = scheduleEnabled.value
? `确定启用定时任务吗?\n\n执行时间: 每天 ${payload.schedule_time}\n执行日期: ${scheduleWeekdayDisplay.value}\n浏览类型: ${payload.schedule_browse_type}\n\n系统将自动执行所有账号的浏览任务(不包含截图)`
: '确定关闭定时任务吗?'
try {
await ElMessageBox.confirm(message, '保存定时任务', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || (scheduleEnabled.value ? '定时任务已启用' : '定时任务已关闭'))
} catch {
// handled by interceptor
}
}
async function runScheduleNow() {
const msg = `确定要立即执行定时任务吗?\n\n这将执行所有账号的浏览任务\n浏览类型: ${scheduleBrowseType.value}\n\n注意无视定时时间和执行日期配置立即开始执行`
try {
await ElMessageBox.confirm(msg, '立即执行', {
confirmButtonText: '立即执行',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await executeScheduleNow()
ElMessage.success(res?.message || '定时任务已开始执行')
} catch {
// handled by interceptor
}
}
async function saveProxy() {
if (proxyEnabled.value && !proxyApiUrl.value.trim()) {
ElMessage.error('启用代理时API地址不能为空')
return
}
const payload = {
proxy_enabled: proxyEnabled.value ? 1 : 0,
proxy_api_url: proxyApiUrl.value.trim(),
proxy_expire_minutes: Number(proxyExpireMinutes.value) || 3,
}
try {
const res = await updateProxyConfig(payload)
ElMessage.success(res?.message || '代理配置已更新')
} catch {
// handled by interceptor
}
}
async function onTestProxy() {
if (!proxyApiUrl.value.trim()) {
ElMessage.error('请先输入代理API地址')
return
}
try {
const res = await testProxy({ api_url: proxyApiUrl.value.trim() })
await ElMessageBox.alert(res?.message || '测试完成', '代理测试', { confirmButtonText: '知道了' })
} catch {
// handled by interceptor
}
}
async function saveAutoApprove() {
const hourly = Number(autoApproveHourlyLimit.value)
const vipDays = Number(autoApproveVipDays.value)
if (!Number.isFinite(hourly) || hourly < 1) {
ElMessage.error('每小时注册限制必须大于0')
return
}
if (!Number.isFinite(vipDays) || vipDays < 0) {
ElMessage.error('VIP天数不能为负数')
return
}
const payload = {
auto_approve_enabled: autoApproveEnabled.value ? 1 : 0,
auto_approve_hourly_limit: hourly,
auto_approve_vip_days: vipDays,
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '自动审核配置已保存')
} catch {
// handled by interceptor
}
}
onMounted(loadAll)
</script>
<template>
<div class="page-stack" v-loading="loading">
<div class="app-page-title">
<h2>系统配置</h2>
<div>
<el-button @click="loadAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">系统并发配置</h3>
<el-form label-width="130px">
<el-form-item label="全局最大并发数">
<el-input-number v-model="maxConcurrentGlobal" :min="1" :max="200" />
<div class="help">同时最多运行的账号数量浏览任务使用 API 方式资源占用较低</div>
</el-form-item>
<el-form-item label="单账号最大并发数">
<el-input-number v-model="maxConcurrentPerAccount" :min="1" :max="50" />
<div class="help">单个账号同时最多运行的任务数量建议设为 1</div>
</el-form-item>
<el-form-item label="截图最大并发数">
<el-input-number v-model="maxScreenshotConcurrent" :min="1" :max="50" />
<div class="help">同时进行截图的最大数量每个浏览器约占用 200MB 内存</div>
</el-form-item>
</el-form>
<el-button type="primary" @click="saveConcurrency">保存并发配置</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">定时任务配置</h3>
<el-form label-width="130px">
<el-form-item label="启用定时任务">
<el-switch v-model="scheduleEnabled" />
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="执行时间">
<el-time-picker v-model="scheduleTime" value-format="HH:mm" format="HH:mm" />
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="浏览类型">
<el-select v-model="scheduleBrowseType" style="width: 220px">
<el-option label="注册前未读" value="注册前未读" />
<el-option label="应读" value="应读" />
<el-option label="未读" value="未读" />
</el-select>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="执行日期">
<el-checkbox-group v-model="scheduleWeekdays">
<el-checkbox v-for="w in weekdaysOptions" :key="w.value" :label="w.value">
{{ w.label }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveSchedule">保存定时任务配置</el-button>
<el-button type="success" plain @click="runScheduleNow">立即执行</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">代理设置</h3>
<el-form label-width="130px">
<el-form-item label="启用IP代理">
<el-switch v-model="proxyEnabled" />
<div class="help">开启后所有浏览任务将通过代理IP访问失败自动重试3次</div>
</el-form-item>
<el-form-item label="代理API地址">
<el-input v-model="proxyApiUrl" placeholder="http://api.xxx/Tools/IP.ashx?..." />
<div class="help">API 应返回IP:PORT例如 123.45.67.89:8888</div>
</el-form-item>
<el-form-item label="代理有效期(分钟)">
<el-input-number v-model="proxyExpireMinutes" :min="1" :max="60" />
</el-form-item>
</el-form>
<div class="row-actions">
<el-button type="primary" @click="saveProxy">保存代理配置</el-button>
<el-button @click="onTestProxy">测试代理</el-button>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">注册自动审核</h3>
<el-form label-width="130px">
<el-form-item label="启用自动审核">
<el-switch v-model="autoApproveEnabled" />
<div class="help">开启后新用户注册将自动通过审核无需管理员手动审批</div>
</el-form-item>
<el-form-item label="每小时注册限制">
<el-input-number v-model="autoApproveHourlyLimit" :min="1" :max="10000" />
</el-form-item>
<el-form-item label="注册赠送VIP天数">
<el-input-number v-model="autoApproveVipDays" :min="0" :max="999999" />
</el-form-item>
</el-form>
<el-button type="primary" @click="saveAutoApprove">保存自动审核配置</el-button>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,321 @@
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminResetUserPassword,
approveUser,
deleteUser,
fetchAllUsers,
rejectUser,
removeUserVip,
setUserVip,
} from '../api/users'
import { parseSqliteDateTime } from '../utils/datetime'
import { validatePasswordStrength } from '../utils/password'
const refreshStats = inject('refreshStats', null)
const loading = ref(false)
const users = ref([])
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
if (String(expire).startsWith('2099-12-31')) return true
const dt = parseSqliteDateTime(expire)
return dt ? dt.getTime() > Date.now() : false
}
function vipLabel(user) {
const expire = user?.vip_expire_time
if (!expire || !isVip(user)) return ''
if (String(expire).startsWith('2099-12-31')) return '永久VIP'
const dt = parseSqliteDateTime(expire)
if (!dt) return `到期: ${expire}`
const daysLeft = Math.ceil((dt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return `到期: ${expire}(剩${daysLeft}天)`
}
function statusMeta(status) {
if (status === 'approved') return { label: '已通过', type: 'success' }
if (status === 'rejected') return { label: '已拒绝', type: 'danger' }
return { label: '待审核', type: 'warning' }
}
async function loadUsers() {
loading.value = true
try {
users.value = await fetchAllUsers()
} catch {
users.value = []
} finally {
loading.value = false
}
}
async function onApprove(row) {
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}」的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onReject(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}」的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除用户「${row.username}」吗?此操作将删除该用户的所有数据,不可恢复!`,
'删除用户',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
)
} catch {
return
}
try {
await deleteUser(row.id)
ElMessage.success('用户已删除')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onSetVip(row, days) {
const label = { 7: '一周', 30: '一个月', 365: '一年', 999999: '永久' }[days] || `${days}`
try {
await ElMessageBox.confirm(`确定为用户「${row.username}」开通 ${label} VIP 吗?`, '设置VIP', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await setUserVip(row.id, days)
ElMessage.success(res?.message || 'VIP设置成功')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onRemoveVip(row) {
try {
await ElMessageBox.confirm(`确定移除用户「${row.username}」的 VIP 吗?`, '移除VIP', {
confirmButtonText: '移除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await removeUserVip(row.id)
ElMessage.success(res?.message || 'VIP已移除')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onResetPassword(row) {
let value
try {
const result = await ElMessageBox.prompt('请输入新密码至少8位且包含字母和数字', '重置密码', {
confirmButtonText: '提交',
cancelButtonText: '取消',
inputType: 'password',
inputPlaceholder: '新密码',
inputValidator: (v) => validatePasswordStrength(v).ok,
inputErrorMessage: '密码至少8位且包含字母和数字',
})
value = result.value
} catch {
return
}
const check = validatePasswordStrength(value)
if (!check.ok) {
ElMessage.error(check.message)
return
}
try {
await ElMessageBox.confirm(`确定将用户「${row.username}」的密码重置为该新密码吗?`, '二次确认', {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await adminResetUserPassword(row.id, value)
ElMessage.success(res?.message || '密码重置成功')
} catch {
// handled by interceptor
}
}
onMounted(loadUsers)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>用户</h2>
<div>
<el-button @click="loadUsers">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="table-wrap">
<el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="240">
<template #default="{ row }">
<div class="user-block">
<div class="user-main">
<strong>{{ row.username }}</strong>
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
</div>
<div v-if="row.email" class="app-muted user-sub">{{ row.email }}</div>
<div v-if="vipLabel(row)" class="vip-sub">{{ vipLabel(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
<div>{{ row.created_at }}</div>
<div v-if="row.approved_at" class="app-muted">审核: {{ row.approved_at }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div class="actions">
<template v-if="row.status === 'pending'">
<el-button type="success" size="small" @click="onApprove(row)">通过</el-button>
<el-button type="warning" size="small" @click="onReject(row)">拒绝</el-button>
</template>
<el-dropdown trigger="click">
<el-button size="small">VIP</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 7)">开通一周</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 30)">开通一月</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 365)">开通一年</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 999999)">永久VIP</el-dropdown-item>
<el-dropdown-item v-if="isVip(row)" @click="onRemoveVip(row)">移除VIP</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" @click="onResetPassword(row)">重置密码</el-button>
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.table-wrap {
overflow-x: auto;
}
.user-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-main {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-sub {
font-size: 12px;
}
.vip-sub {
font-size: 12px;
color: #7c3aed;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,40 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import AdminLayout from '../layouts/AdminLayout.vue'
import PendingPage from '../pages/PendingPage.vue'
import UsersPage from '../pages/UsersPage.vue'
import FeedbacksPage from '../pages/FeedbacksPage.vue'
import StatsPage from '../pages/StatsPage.vue'
import LogsPage from '../pages/LogsPage.vue'
import AnnouncementsPage from '../pages/AnnouncementsPage.vue'
import EmailPage from '../pages/EmailPage.vue'
import SystemPage from '../pages/SystemPage.vue'
import SettingsPage from '../pages/SettingsPage.vue'
const routes = [
{
path: '/',
component: AdminLayout,
children: [
{ path: '', redirect: '/pending' },
{ path: '/pending', name: 'pending', component: PendingPage },
{ path: '/users', name: 'users', component: UsersPage },
{ path: '/feedbacks', name: 'feedbacks', component: FeedbacksPage },
{ path: '/stats', name: 'stats', component: StatsPage },
{ path: '/logs', name: 'logs', component: LogsPage },
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
{ path: '/email', name: 'email', component: EmailPage },
{ path: '/system', name: 'system', component: SystemPage },
{ path: '/settings', name: 'settings', component: SettingsPage },
],
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
export default router

View File

@@ -0,0 +1,51 @@
:root {
--app-bg: #f6f7fb;
--app-text: #111827;
--app-muted: #6b7280;
--app-border: rgba(17, 24, 39, 0.08);
--app-radius: 12px;
--app-shadow: 0 8px 24px rgba(17, 24, 39, 0.06);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--app-text);
}
a {
color: inherit;
text-decoration: none;
}
.app-page-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 12px;
}
.app-page-title h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.2px;
}
.app-muted {
color: var(--app-muted);
}

View File

@@ -0,0 +1,17 @@
export function parseSqliteDateTime(value) {
if (!value) return null
if (value instanceof Date) return value
const str = String(value)
// "YYYY-MM-DD HH:mm:ss" -> "YYYY-MM-DDTHH:mm:ss"
const iso = str.includes('T') ? str : str.replace(' ', 'T')
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return date
}
export function formatDateTime(value) {
if (!value) return '-'
return String(value)
}

View File

@@ -0,0 +1,13 @@
export function validatePasswordStrength(password) {
const value = String(password || '')
if (!value) return { ok: false, message: '密码不能为空' }
if (value.length < 8) return { ok: false, message: '密码长度不能少于8个字符' }
if (value.length > 128) return { ok: false, message: '密码长度不能超过128个字符' }
const hasLetter = /[a-zA-Z]/.test(value)
const hasDigit = /\d/.test(value)
if (!hasLetter || !hasDigit) return { ok: false, message: '密码必须包含字母和数字' }
return { ok: true, message: '' }
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: './',
build: {
outDir: '../static/admin',
emptyOutDir: true,
manifest: true,
},
})

30
app.py
View File

@@ -1217,18 +1217,30 @@ def admin_login_page():
@admin_required @admin_required
def admin_page(): def admin_page():
"""后台管理页面""" """后台管理页面"""
return render_template('admin.html') manifest_path = os.path.join(app.root_path, 'static', 'admin', '.vite', 'manifest.json')
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
entry = manifest.get('index.html') or {}
js_file = entry.get('file')
css_files = entry.get('css') or []
if not js_file:
logger.warning(f"[admin_spa] manifest缺少入口文件: {manifest_path}")
return render_template('admin_legacy.html')
return render_template(
@app.route('/yuyx/vip') 'admin.html',
@admin_required admin_spa_js_file=f'admin/{js_file}',
def vip_admin_page(): admin_spa_css_files=[f'admin/{p}' for p in css_files],
"""VIP管理页面""" )
return render_template('vip_admin.html') except FileNotFoundError:
logger.warning(f"[admin_spa] 未找到manifest: {manifest_path},回退旧版后台模板")
return render_template('admin_legacy.html')
except Exception as e:
logger.error(f"[admin_spa] 加载manifest失败: {e}")
return render_template('admin_legacy.html')
# ==================== 用户认证API ==================== # ==================== 用户认证API ====================
@app.route('/api/register', methods=['POST']) @app.route('/api/register', methods=['POST'])

View File

@@ -0,0 +1,11 @@
{
"index.html": {
"file": "assets/index-D-MDwNCD.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"css": [
"assets/index-C2CkOw_I.css"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
static/admin/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>后台管理 - 知识管理平台</title>
<script type="module" crossorigin src="./assets/index-D-MDwNCD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C2CkOw_I.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

1
static/admin/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

3468
templates/admin_legacy.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,35 @@
} }
body { body {
font-family: 'Microsoft YaHei', Arial, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
background: linear-gradient(135deg, #eef2ff 0%, #f6f7fb 45%, #ecfeff 100%);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(800px 500px at 15% 20%, rgba(59,130,246,.18), transparent 60%),
radial-gradient(700px 420px at 85% 70%, rgba(124,58,237,.16), transparent 55%);
pointer-events: none;
} }
.login-container { .login-container {
background: white; background: white;
border-radius: 10px; border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2); box-shadow: 0 18px 60px rgba(17,24,39,0.15);
width: 400px; width: 420px;
padding: 40px; padding: 38px 34px;
border: 1px solid rgba(17,24,39,0.08);
position: relative;
} }
.login-header { .login-header {
@@ -34,23 +49,25 @@
} }
.login-header h1 { .login-header h1 {
font-size: 28px; font-size: 24px;
color: #333; color: #111827;
margin-bottom: 10px; margin-bottom: 10px;
letter-spacing: 0.2px;
} }
.login-header p { .login-header p {
color: #666; color: #6b7280;
font-size: 14px; font-size: 14px;
} }
.admin-badge { .admin-badge {
display: inline-block; display: inline-block;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); background: rgba(59,130,246,0.10);
color: white; color: #1d4ed8;
padding: 5px 15px; padding: 6px 14px;
border-radius: 20px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 700;
margin-bottom: 15px; margin-bottom: 15px;
} }
@@ -61,39 +78,43 @@
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
color: #333; color: #111827;
font-weight: bold; font-weight: 700;
font-size: 13px;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 1px solid #ddd; border: 1px solid rgba(17,24,39,0.14);
border-radius: 5px; border-radius: 10px;
font-size: 14px; font-size: 14px;
transition: border-color 0.3s; transition: border-color 0.2s, box-shadow 0.2s;
background: rgba(255,255,255,0.9);
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: #f5576c; border-color: rgba(59,130,246,0.7);
box-shadow: 0 0 0 4px rgba(59,130,246,0.16);
} }
.btn-login { .btn-login {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
color: white; color: white;
border: none; border: none;
border-radius: 5px; border-radius: 10px;
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: 800;
cursor: pointer; cursor: pointer;
transition: transform 0.2s; transition: transform 0.15s, filter 0.15s;
} }
.btn-login:hover { .btn-login:hover {
transform: translateY(-2px); transform: translateY(-2px);
filter: brightness(1.02);
} }
.btn-login:active { .btn-login:active {
@@ -103,13 +124,13 @@
.back-link { .back-link {
text-align: center; text-align: center;
margin-top: 20px; margin-top: 20px;
color: #666; color: #6b7280;
} }
.back-link a { .back-link a {
color: #f5576c; color: #2563eb;
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: 700;
} }
.back-link a:hover { .back-link a:hover {
@@ -117,37 +138,39 @@
} }
.error-message { .error-message {
background: #ffe6e6; background: rgba(239,68,68,0.10);
color: #d63031; color: #b91c1c;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 10px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
border: 1px solid rgba(239,68,68,0.18);
} }
.success-message { .success-message {
background: #e6ffe6; background: rgba(16,185,129,0.10);
color: #27ae60; color: #047857;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 10px;
margin-bottom: 20px; margin-bottom: 20px;
display: none; display: none;
border: 1px solid rgba(16,185,129,0.18);
} }
.warning-box { .warning-box {
background: #fff3cd; background: rgba(245,158,11,0.10);
border: 1px solid #ffc107; border: 1px solid rgba(245,158,11,0.18);
color: #856404; color: #92400e;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 10px;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 13px; font-size: 13px;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
body { padding: 12px; align-items: flex-start; padding-top: 20px; } body { padding: 12px; align-items: flex-start; padding-top: 20px; }
.login-container { width: 100%; max-width: 100%; padding: 28px 20px; } .login-container { width: 100%; max-width: 100%; padding: 28px 20px; border-radius: 14px; }
.login-header h1 { font-size: 24px; } .login-header h1 { font-size: 22px; }
.login-header p { font-size: 13px; } .login-header p { font-size: 13px; }
.admin-badge { font-size: 11px; padding: 4px 12px; } .admin-badge { font-size: 11px; padding: 4px 12px; }
.form-group { margin-bottom: 18px; } .form-group { margin-bottom: 18px; }