Compare commits

..

3 Commits

Author SHA1 Message Date
49bc8b83b1 perf(admin): lazy routes and nav badges 2025-12-13 21:13:57 +08:00
235ba28cc8 feat(admin): migrate admin UI to Vue3 2025-12-13 20:51:44 +08:00
3c31f30ee4 docs: admin UI refactor plan 2025-12-13 20:51:08 +08:00
68 changed files with 10045 additions and 3513 deletions

430
UI_REFACTOR_ADMIN.md Normal file
View File

@@ -0,0 +1,430 @@
# 后台管理 UI 重构方案Vue3 + Vite保持功能不变
> 目标:仅重构“后台管理界面”(优先 `/yuyx/admin`)的视觉与交互呈现,使其更现代、美观、易用、可维护;后端接口与业务流程保持不变。
>
> 实现方式:后台前端改为 **Vue3 单页应用**Vite 构建产物部署到 `static/admin/`),所有接口继续使用现有 `/yuyx/api/...`,不改后端功能。
---
## 1. 项目概览(基于当前仓库代码)
### 1.1 技术栈与形态
- 后端Python / Flask`app.py`,不变)
- 当前后台前端Jinja2 模板 + 原生 HTML/CSS/JS现状
- 目标后台前端Vue3 + Vite构建+ Element Plus组件库默认方案+ Vue Router + Axios仅后台
- 静态资源:前端构建产物输出到 `static/admin/`(新增;运行时不依赖 CDN
### 1.2 后台入口与页面
- 后台登录页:`GET /yuyx``templates/admin_login.html`(第一阶段可先不改,后续可再 Vue 化/美化)
- 后台管理页:`GET /yuyx/admin` → Vue3 SPA构建后由 Flask 返回 `static/admin/index.html`
- 回滚策略:保留旧版 `templates/admin.html`(计划改名为 `templates/admin_legacy.html` 备份,便于一键回滚)
### 1.3 现状结论(为什么“臃肿”)
- `templates/admin.html` 是一个超大单文件(包含大量内联 `<style>` + 大量内联 JS + 大量内联 `style="..."`),维护成本高、改动风险高。
- “页面结构 / 样式 / 逻辑”混在一起,导致:
- 视觉风格不统一(按钮、卡片、表格、间距、字号混用)。
- 组件复用困难(很多 UI 是拷贝粘贴 + 内联样式)。
- 小改动容易引发回归JS 依赖大量 DOM id/结构)。
---
## 2. 后台功能清单(用于“功能不变”验收)
后台管理页 `templates/admin.html` 目前是一个“单页 + 多 Tab”结构Tab 与能力如下:
### 2.1 顶部统计卡片(页面顶部)
- 展示总用户数、已审核、待审核、总账号数、VIP 用户数
- 数据来源:`GET /yuyx/api/stats`
- 额外:显示管理员用户名(同接口返回 `admin_username` 时更新 UI
### 2.2 Tab待审核pending
1) 用户注册审核
- 列表ID、用户名、邮箱、注册时间、操作
- 操作:通过、拒绝
- 接口:
- `GET /yuyx/api/users/pending`
- `POST /yuyx/api/users/<user_id>/approve`
- `POST /yuyx/api/users/<user_id>/reject`
2) 密码重置审核
- 列表申请ID、用户名、邮箱、申请时间、操作
- 操作:批准、拒绝
- 接口:
- `GET /yuyx/api/password_resets`
- `POST /yuyx/api/password_resets/<request_id>/approve`
- `POST /yuyx/api/password_resets/<request_id>/reject`
### 2.3 Tab所有用户all
- 列表ID、用户名含邮箱/ VIP 标识/到期)、状态、注册/审核时间、操作
- 操作:
- 待审核用户:通过 / 拒绝
- 删除用户
- VIP开通一周/一月/一年/永久)或移除
- 管理员直接重置用户密码(输入新密码)
- 接口:
- `GET /yuyx/api/users`
- `POST /yuyx/api/users/<user_id>/approve`
- `POST /yuyx/api/users/<user_id>/reject`
- `DELETE /yuyx/api/users/<user_id>`
- `POST /yuyx/api/users/<user_id>/vip`body: `{days}`
- `DELETE /yuyx/api/users/<user_id>/vip`
- `POST /yuyx/api/users/<user_id>/reset_password`body: `{new_password}`
### 2.4 Tab反馈管理feedbacks
- 筛选:状态(全部/待处理/已回复/已关闭)
- 统计:总计、待处理、已回复、已关闭(并显示待处理徽章)
- 列表字段ID、用户、标题、描述、联系方式、状态、提交时间、回复、操作
- 操作回复prompt、关闭、删除
- 接口:
- `GET /yuyx/api/feedbacks?status=...`
- `POST /yuyx/api/feedbacks/<feedback_id>/reply`body: `{reply}`
- `POST /yuyx/api/feedbacks/<feedback_id>/close`
- `DELETE /yuyx/api/feedbacks/<feedback_id>`
### 2.5 Tab统计stats
1) 系统资源概览
- CPU / 内存 / 磁盘 / 容器内存 / 运行时长
- 接口:
- `GET /yuyx/api/server/info`
- `GET /yuyx/api/docker_stats`
2) 实时任务监控(每 1 秒刷新)
- 运行中数量、排队中数量、最大并发
- 运行中/排队中任务列表(来源、用户、账号、浏览类型、详细状态、耗时等)
- 接口:
- `GET /yuyx/api/task/running`
3) 任务统计
- 今日/累计:成功任务、失败任务、浏览内容、查看附件
- 接口:
- `GET /yuyx/api/task/stats`
### 2.6 Tab任务日志logs
- 筛选:日期、状态(成功/失败)、来源(手动/定时/即时/恢复)、用户、账号关键字
- 列表字段:时间、来源、用户、账号、浏览类型、状态、内容/附件、用时、失败原因
- 分页limit/offset每页 20、首页/上一页/下一页/末页
- 操作:清理旧日志(输入天数)
- 接口:
- `GET /yuyx/api/task/logs?limit=&offset=&date=&status=&source=&user_id=&account=`
- `POST /yuyx/api/task/logs/clear`body: `{days}`
### 2.7 Tab公告管理announcements
- 创建公告:标题、内容
- 操作:发布并启用 / 保存但不启用 / 清空
- 列表字段ID、标题、状态、创建时间、操作
- 操作:查看、启用、停用、删除
- 接口:
- `GET /yuyx/api/announcements`
- `POST /yuyx/api/announcements`body: `{title, content, is_active}`
- `POST /yuyx/api/announcements/<id>/activate`
- `POST /yuyx/api/announcements/<id>/deactivate`
- `DELETE /yuyx/api/announcements/<id>`
### 2.8 Tab邮件配置email
1) 全局开关与基础 URL
- 启用邮件、故障转移、注册邮箱验证、任务完成通知、网站基础 URL
- 接口:
- `GET /yuyx/api/email/settings`
- `POST /yuyx/api/email/settings`
2) SMTP 配置
- 列表:主/备用/禁用、名称、服务器、今日/限额、成功率、编辑
- 弹窗:新增/编辑 SMTP含密码显隐、测试连接、保存、设为主配置、删除
- 接口:
- `GET /yuyx/api/smtp/configs`
- `POST /yuyx/api/smtp/configs`
- `GET /yuyx/api/smtp/configs/<config_id>`
- `PUT /yuyx/api/smtp/configs/<config_id>`
- `DELETE /yuyx/api/smtp/configs/<config_id>`
- `POST /yuyx/api/smtp/configs/<config_id>/test`
- `POST /yuyx/api/smtp/configs/<config_id>/primary`
3) 邮件统计 & 邮件日志
- 统计:总发送、成功、失败、各类型
- 日志:按类型/状态筛选、分页、清理
- 接口:
- `GET /yuyx/api/email/stats`
- `GET /yuyx/api/email/logs?page=&page_size=&type=&status=`
- `POST /yuyx/api/email/logs/cleanup`body: `{days}`
### 2.9 Tab系统配置system
1) 并发配置
- 全局最大并发、单账号最大并发、截图最大并发
- 接口:`POST /yuyx/api/system/config`
2) 定时任务配置
- 启用定时任务、执行时间、执行日期(周一~周日)、浏览类型、保存、立即执行
- 接口:
- `GET /yuyx/api/system/config`
- `POST /yuyx/api/system/config`
- `POST /yuyx/api/schedule/execute`
3) 代理设置
- 启用代理、代理 API 地址、代理有效期、保存、测试代理
- 接口:
- `GET /yuyx/api/proxy/config`
- `POST /yuyx/api/proxy/config`
- `POST /yuyx/api/proxy/test`
4) 注册自动审核
- 启用自动审核、每小时注册限制、注册赠送 VIP 天数
- 接口:`POST /yuyx/api/system/config`
### 2.10 Tab设置settings
- 修改管理员用户名(成功后提示重新登录并触发退出)
- 修改管理员密码(成功后提示重新登录并触发退出)
- 退出登录
- 接口:
- `PUT /yuyx/api/admin/username`
- `PUT /yuyx/api/admin/password`
- `POST /yuyx/api/logout`
---
## 3. UI 重构目标与范围
### 3.1 明确目标
- 视觉:更现代、克制、统一(字体/字号/间距/颜色/阴影/圆角/表格/表单)
- 交互:信息层级清晰、导航不拥挤、在桌面/手机都可用
- 工程:可维护(减少内联样式、抽离 CSS/JS、结构更清晰
- 兼容:引入 Vue3 + Vite 构建(你已确认部署可拉依赖);运行时仍是 Flask 提供静态文件,不需要 Node 常驻运行
### 3.2 不做的事(避免范围失控)
- 不改后端接口、不改数据库、不改业务流程
- 不依赖运行时外网/CDN所有前端依赖在构建期打包进静态文件
- 不额外新增“业务功能”(最多做纯 UI/UX 级别优化:排版、布局、可读性、响应式)
---
## 4. 推荐的界面改造方向(保持功能不变前提下)
### 4.1 信息架构(解决“标签太多、拥挤”)
建议把当前顶栏 Tabs 升级为“左侧导航 + 右侧内容”的后台经典布局:
- 左侧:导航(待审核/用户/反馈/统计/日志/公告/邮件/系统/设置)
- 右侧:内容区(每个导航项对应一个 Vue 页面/模块;不改变现有数据与按钮)
- 顶部:保留 header系统名 + 管理员用户名 + 退出)
- 移动端左侧导航折叠为抽屉hamburger
这样可以显著减轻横向 Tabs 的拥挤感同时保持“单页切换”的实现方式Vue Router
> 如果你希望“仍然保持 Tabs”也可以我们会把 Tabs 做成可滚动 + 分组/二级菜单的样式;该点需要你确认(见第 8 节)。
### 4.2 视觉体系(统一设计语言)
在使用 Element Plus 的前提下,仍建议补一层轻量的 design tokensCSS 变量用于统一页面背景、间距、圆角、阴影与品牌色Element Plus 负责组件能力,我们负责整体风格统一):
- 颜色:主色、成功/警告/危险、边框色、背景色、文本色
- 间距4/8/12/16/24/32
- 圆角8/12
- 阴影:轻/中
- 字体:系统字体栈(优先苹方/微软雅黑/Segoe UI
并把按钮、卡片、表格、表单、徽章、弹窗、通知统一到同一套组件外观。
### 4.3 表格与表单体验
- 表格:表头 sticky、行 hover、状态徽章统一、操作按钮收纳避免一行按钮过多
- 表单label 与 help text 统一样式;输入框 focus 样式;危险操作(删除/清理)更醒目
### 4.4 反馈与通知
现有通知是简单的 1 秒浮层提示。建议升级为更现代的 toast可保留 1 秒自动消失,但样式更好、支持多行、更清晰的成功/失败状态)。
---
## 5. 技术实施方案Vue3 + Vite 落地,保持功能不变)
### 5.1 总体落地方案(你已确认采用“推荐方式”)
- 新增后台前端工程:`admin-frontend/`Vue3 + Vite
- 构建输出:`admin-frontend/dist/` → 复制/输出到 `static/admin/`
- Flask 对接:`GET /yuyx/admin` 返回 `static/admin/index.html`
- 路由策略Vue Router 使用 **hash 模式**(避免后端再做 history fallback 配置)
- 接口策略:所有请求继续调用现有 `/yuyx/api/...`URL/method/body 完全不变)
### 5.2 组件库选择(我来判断:默认 Element Plus
默认采用:
- `element-plus` + `@element-plus/icons-vue`
原因:
- 后台大量场景是“表格/表单/弹窗/分页/通知”Element Plus 开箱即用且样式现代
- 可通过主题变量与少量 CSS 统一品牌风格,达到“更美观”目标
- 能把大量 `alert/confirm/prompt` 升级为更现代的 Dialog/MessageBox功能不变体验更好
> 如果你明确不想引入组件库,我也能改为“纯自研 UI”但开发周期会明显拉长。
### 5.3 前端目录结构建议(便于可维护)
建议结构(示意):
```
admin-frontend/
package.json
vite.config.(ts|js)
src/
main.(ts|js)
App.vue
router/
index.(ts|js)
api/
client.(ts|js) # axios 实例baseURL=/yuyx/api
users.(ts|js)
feedbacks.(ts|js)
announcements.(ts|js)
system.(ts|js)
email.(ts|js)
taskLogs.(ts|js)
stats.(ts|js)
layouts/
AdminLayout.vue # Header + Sidebar + Content
pages/
Pending.vue
Users.vue
Feedbacks.vue
Stats.vue
Logs.vue
Announcements.vue
Email.vue
System.vue
Settings.vue
components/
... # 可复用的表格/弹窗/筛选条等
```
### 5.4 与后端/静态资源的集成细节(避免踩坑)
- Vite `base`:建议配置为 `/static/admin/`,确保打包后的资源路径正确
- API`axios.create({ baseURL: '/yuyx/api', withCredentials: true })`
- 403/未登录体验API 返回 403 时,前端可提示“需要管理员权限”,并跳转到 `/yuyx`(此行为是否接受见第 8 节问题)
### 5.5 构建与部署(支持 Docker多阶段构建运行时无 Node
你已确认“部署时可以拉依赖”,因此可用以下思路:
- 本地/服务器构建:
- `cd admin-frontend && npm ci && npm run build`
-`admin-frontend/dist` 部署到后端容器的 `static/admin/`
- Docker 推荐:多阶段构建
- Stage 1Node安装依赖并 `npm run build`
- Stage 2现有 Python/Playwright 镜像):复制 `dist/``/app/static/admin/`
> 这样最终运行容器里不需要 Node仍是“Flask + 静态文件”的部署模式。
### 5.6 分阶段交付(按功能模块逐步落地)
阶段 1工程搭建 + 新布局
- 建好 Vue3 工程、Element Plus、路由与基础布局Header/Sidebar
- 先把 9 个模块页面壳子跑通(不要求全部功能完成,但可导航切换)
阶段 2核心模块功能迁移先保证可用
- 待审核 / 所有用户 / 系统配置 / 设置:完成列表+表单+操作全流程
阶段 3复杂模块迁移数据量/交互多)
- 统计(含 1 秒刷新)/ 任务日志(筛选分页)/ 邮件配置SMTP 弹窗 + 日志分页)/ 公告 / 反馈
阶段 4统一细节 + 回归验收
- 全量走一遍第 7 节验收清单
- 补齐移动端适配与细节视觉统一
- 保留旧版后台模板备份,便于回滚
---
## 6. 风险点与注意事项(提前说明)
- Vue SPA 静态资源路径需要正确配置Vite `base`);否则会出现“页面白屏/资源 404”。
- Vue Router 建议使用 hash 模式,避免出现刷新子路由 404 的历史路由问题。
- 管理员鉴权依赖 session cookie前端请求必须同源并携带 cookieaxios `withCredentials`)。
- 统计页每 1 秒刷新一次接口server/docker/task/runningUI 改造不应额外增加请求频率。
- 列表项里存在长文本(反馈描述/失败原因/邮件错误等),需要做更合理的折行/省略/查看方式,避免布局被撑爆。
- `/yuyx/vip` 为历史废弃入口(旧模板缺失),已按确认删除该路由,避免误访问报错。
---
## 7. 验收清单(你确认后我按此对齐开发)
建议你在重构完成后按以下清单点验收(确保“功能不变”):
- 登录/退出:
- `GET /yuyx` 登录成功跳转后台
- 退出后回到后台登录页
- 顶部统计:
- 统计数字正常刷新,管理员用户名正常显示
- 待审核:
- 注册审核列表加载/通过/拒绝正常
- 密码重置列表加载/批准/拒绝正常
- 所有用户:
- 列表加载正常
- 删除用户、VIP 开通/移除、重置密码正常
- 反馈管理:
- 状态筛选正常;徽章数字正确
- 回复/关闭/删除正常
- 公告管理:
- 创建(启用/不启用)正常
- 启用/停用/删除/查看正常
- 系统配置:
- 并发保存正常
- 定时任务保存与“立即执行”正常
- 代理保存与测试正常
- 自动审核保存正常
- 统计:
- CPU/内存/磁盘/容器信息正常
- 运行中/排队中列表正常刷新
- 今日/累计统计正常显示
- 任务日志:
- 筛选、分页、清理旧日志正常
- 邮件配置:
- 全局开关/基础 URL 更新正常
- SMTP 新增/编辑/测试/设为主/删除正常
- 邮件统计/日志筛选/分页/清理正常
- 设置:
- 修改用户名/密码后提示并强制重新登录
---
## 8. 需要你确认的问题(确认后再开始开发)
已确认(来自你的回复):
- 范围:先改后台(`/yuyx/admin`),并同步美化后台登录页(`/yuyx`),仅 UI 不改登录逻辑
- 技术Vue3 + Vite 构建产物部署到 `static/admin/`;部署时允许拉依赖并构建
- 布局:左侧导航 + 右侧内容Admin Layout
- 风格:简洁克制(浅色背景 + 卡片 + 少量主色点缀)
- 暗色模式:不需要
- 适配PC 优先;同时保证手机端不变形(必要时对表格做响应式处理/横向滚动)
- 交互:允许把原生 `alert/confirm/prompt` 替换为 Element Plus 弹窗/对话框
- 403 行为:保持现状(接口 403 时前端提示错误,不强制跳转登录页)
- `/yuyx/vip`为历史废弃入口VIP 已整合在后台,已删除该路由
仍需你确认(只剩 1 点,影响 Vite `base` 与反代配置):
1) 部署路径/前缀:
- A. 你的站点是根路径部署(例如 `https://zsglpt.workyai.cn/` 直接访问就是本系统)
- B. 你的站点是二级目录部署(例如 `https://example.com/zsglpt/`,需要告诉我前缀 `/zsglpt`
> 说明:域名本身没有影响,关键在“是否挂在二级目录”。若是根路径部署,我们默认使用 `/static/admin/` 与 `/yuyx/api/...` 即可。
---
## 9. 下一步
你确认第 8 节后,我会按“分阶段交付”开始开发,并在每个阶段完成后给你可验收的版本点。

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,26 @@
import { api } from './client'
export async function fetchFeedbacks(status = '') {
const { data } = await api.get('/feedbacks', { params: status ? { status } : {} })
return data
}
export async function fetchFeedbackStats() {
const { data } = await api.get('/feedbacks', { params: { limit: 1, offset: 0 } })
return data?.stats
}
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,325 @@
<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 { fetchFeedbackStats } from '../api/feedbacks'
import { fetchPasswordResets } from '../api/passwordResets'
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
}
}
const loadingBadges = ref(false)
const pendingResetsCount = ref(0)
const pendingFeedbackCount = ref(0)
let badgeTimer
async function refreshNavBadges(partial = null) {
if (partial && typeof partial === 'object') {
if (Object.prototype.hasOwnProperty.call(partial, 'pendingResets')) {
pendingResetsCount.value = Number(partial.pendingResets || 0)
}
if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) {
pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0)
}
return
}
if (loadingBadges.value) return
loadingBadges.value = true
try {
const [resetsResult, feedbackResult] = await Promise.allSettled([
fetchPasswordResets(),
fetchFeedbackStats(),
])
if (resetsResult.status === 'fulfilled') {
pendingResetsCount.value = Array.isArray(resetsResult.value) ? resetsResult.value.length : 0
}
if (feedbackResult.status === 'fulfilled') {
pendingFeedbackCount.value = Number(feedbackResult.value?.pending || 0)
}
} finally {
loadingBadges.value = false
}
}
provide('refreshStats', refreshStats)
provide('adminStats', stats)
provide('refreshNavBadges', refreshNavBadges)
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()
await refreshNavBadges()
badgeTimer = window.setInterval(refreshNavBadges, 60_000)
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener?.('change', syncIsMobile)
window.clearInterval(badgeTimer)
})
const menuItems = [
{ path: '/pending', label: '待审核', icon: Document, badgeKey: 'pending' },
{ path: '/users', label: '用户', icon: User },
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
{ 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)
function badgeFor(item) {
if (!item?.badgeKey) return 0
if (item.badgeKey === 'pending') {
return Number(stats.value?.pending_users || 0) + Number(pendingResetsCount.value || 0)
}
if (item.badgeKey === 'feedbacks') {
return Number(pendingFeedbackCount.value || 0)
}
return 0
}
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>
<el-badge v-if="badgeFor(item) > 0" :value="badgeFor(item)" :max="99" class="menu-badge">
<span class="menu-label">{{ item.label }}</span>
</el-badge>
<span v-else class="menu-label">{{ 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" />
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="fallback-card">
<el-skeleton :rows="5" animated />
</el-card>
</template>
</Suspense>
</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>
<el-badge v-if="badgeFor(item) > 0" :value="badgeFor(item)" :max="99" class="menu-badge">
<span class="menu-label">{{ item.label }}</span>
</el-badge>
<span v-else class="menu-label">{{ 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;
}
.menu-label {
display: inline-flex;
align-items: center;
min-width: 0;
}
.menu-badge {
display: inline-flex;
align-items: center;
}
.fallback-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.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,259 @@
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { closeFeedback, deleteFeedback, fetchFeedbacks, replyFeedback } from '../api/feedbacks'
const refreshNavBadges = inject('refreshNavBadges', null)
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
}
await refreshNavBadges?.({ pendingFeedbacks: stats.value.pending || 0 })
}
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,228 @@
<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 refreshNavBadges = inject('refreshNavBadges', 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()])
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
}
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()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} 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()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} 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,39 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import AdminLayout from '../layouts/AdminLayout.vue'
const PendingPage = () => import('../pages/PendingPage.vue')
const UsersPage = () => import('../pages/UsersPage.vue')
const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
const StatsPage = () => import('../pages/StatsPage.vue')
const LogsPage = () => import('../pages/LogsPage.vue')
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
const EmailPage = () => import('../pages/EmailPage.vue')
const SystemPage = () => import('../pages/SystemPage.vue')
const SettingsPage = () => import('../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,155 @@
{
"_datetime-CpkTDmvr.js": {
"file": "assets/datetime-CpkTDmvr.js",
"name": "datetime"
},
"_tasks-BUxA_MMn.js": {
"file": "assets/tasks-BUxA_MMn.js",
"name": "tasks",
"imports": [
"index.html"
]
},
"_users-DVl5a2To.js": {
"file": "assets/users-DVl5a2To.js",
"name": "users",
"imports": [
"index.html"
]
},
"index.html": {
"file": "assets/index-CCJGmygT.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"dynamicImports": [
"src/pages/PendingPage.vue",
"src/pages/UsersPage.vue",
"src/pages/FeedbacksPage.vue",
"src/pages/StatsPage.vue",
"src/pages/LogsPage.vue",
"src/pages/AnnouncementsPage.vue",
"src/pages/EmailPage.vue",
"src/pages/SystemPage.vue",
"src/pages/SettingsPage.vue"
],
"css": [
"assets/index-lm5BCraY.css"
]
},
"src/pages/AnnouncementsPage.vue": {
"file": "assets/AnnouncementsPage-CXFfpdyD.js",
"name": "AnnouncementsPage",
"src": "src/pages/AnnouncementsPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/AnnouncementsPage-CjcC-aWD.css"
]
},
"src/pages/EmailPage.vue": {
"file": "assets/EmailPage-D5rz9N2M.js",
"name": "EmailPage",
"src": "src/pages/EmailPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/EmailPage-Dk6eRUoe.css"
]
},
"src/pages/FeedbacksPage.vue": {
"file": "assets/FeedbacksPage-zx0MksLD.js",
"name": "FeedbacksPage",
"src": "src/pages/FeedbacksPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/FeedbacksPage-BKNQYWPz.css"
]
},
"src/pages/LogsPage.vue": {
"file": "assets/LogsPage-DnqHdnu7.js",
"name": "LogsPage",
"src": "src/pages/LogsPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-DVl5a2To.js",
"_tasks-BUxA_MMn.js",
"index.html"
],
"css": [
"assets/LogsPage-R-XyhzDW.css"
]
},
"src/pages/PendingPage.vue": {
"file": "assets/PendingPage-DDGug1ac.js",
"name": "PendingPage",
"src": "src/pages/PendingPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-DVl5a2To.js",
"index.html",
"_datetime-CpkTDmvr.js"
],
"css": [
"assets/PendingPage-C_mZDlzP.css"
]
},
"src/pages/SettingsPage.vue": {
"file": "assets/SettingsPage-BNOqaz0O.js",
"name": "SettingsPage",
"src": "src/pages/SettingsPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/SettingsPage-DGdwb4W2.css"
]
},
"src/pages/StatsPage.vue": {
"file": "assets/StatsPage-CfWiD1Ty.js",
"name": "StatsPage",
"src": "src/pages/StatsPage.vue",
"isDynamicEntry": true,
"imports": [
"_tasks-BUxA_MMn.js",
"index.html"
],
"css": [
"assets/StatsPage-kYXPdoa5.css"
]
},
"src/pages/SystemPage.vue": {
"file": "assets/SystemPage-Di4QNzPH.js",
"name": "SystemPage",
"src": "src/pages/SystemPage.vue",
"isDynamicEntry": true,
"imports": [
"index.html"
],
"css": [
"assets/SystemPage-DC1VKbLw.css"
]
},
"src/pages/UsersPage.vue": {
"file": "assets/UsersPage-zxqUvIyG.js",
"name": "UsersPage",
"src": "src/pages/UsersPage.vue",
"isDynamicEntry": true,
"imports": [
"_users-DVl5a2To.js",
"_datetime-CpkTDmvr.js",
"index.html"
],
"css": [
"assets/UsersPage-D2Xg1a62.css"
]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-a7b3418e]{display:flex;flex-direction:column;gap:12px}.card[data-v-a7b3418e]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-a7b3418e]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-a7b3418e]{margin-top:10px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-a7b3418e]{overflow-x:auto}.ellipsis[data-v-a7b3418e]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions[data-v-a7b3418e]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-3d6e76c6]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-3d6e76c6]{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.card[data-v-3d6e76c6]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-head[data-v-3d6e76c6]{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:12px;flex-wrap:wrap}.section-title[data-v-3d6e76c6]{margin:0;font-size:14px;font-weight:800}.help[data-v-3d6e76c6]{margin-top:8px;font-size:12px;color:var(--app-muted)}.table-wrap[data-v-3d6e76c6]{overflow-x:auto}.stat-card[data-v-3d6e76c6]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-3d6e76c6]{font-size:20px;font-weight:900;line-height:1.1}.stat-label[data-v-3d6e76c6]{margin-top:6px;font-size:12px;color:var(--app-muted)}.ok[data-v-3d6e76c6]{color:#047857}.err[data-v-3d6e76c6]{color:#b91c1c}.sub-stats[data-v-3d6e76c6]{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.ellipsis[data-v-3d6e76c6]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-3d6e76c6]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-3d6e76c6]{font-size:12px}.dialog-actions[data-v-3d6e76c6]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.spacer[data-v-3d6e76c6]{flex:1}

View File

@@ -0,0 +1 @@
.page-stack[data-v-97c1e509]{display:flex;flex-direction:column;gap:12px}.toolbar[data-v-97c1e509]{display:flex;gap:10px;align-items:center}.card[data-v-97c1e509],.stat-card[data-v-97c1e509]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.stat-value[data-v-97c1e509]{font-size:20px;font-weight:800;line-height:1.1}.stat-label[data-v-97c1e509]{margin-top:6px;font-size:12px;color:var(--app-muted)}.warn[data-v-97c1e509]{color:#b45309}.ok[data-v-97c1e509]{color:#047857}.table-wrap[data-v-97c1e509]{overflow-x:auto}.ellipsis[data-v-97c1e509]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions[data-v-97c1e509]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-a7a68d16]{display:flex;flex-direction:column;gap:12px}.card[data-v-a7a68d16]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.filters[data-v-a7a68d16]{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.table-wrap[data-v-a7a68d16]{overflow-x:auto}.ellipsis[data-v-a7a68d16]{display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pagination[data-v-a7a68d16]{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap}.page-hint[data-v-a7a68d16]{font-size:12px}

View File

@@ -0,0 +1 @@
.page-stack[data-v-f2aa6820]{display:flex;flex-direction:column;gap:12px}.card[data-v-f2aa6820]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-f2aa6820]{margin:0 0 12px;font-size:14px;font-weight:800}.table-wrap[data-v-f2aa6820]{overflow-x:auto}.user-cell[data-v-f2aa6820]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}

View File

@@ -0,0 +1 @@
import{f as E,a as I,r as A}from"./users-DVl5a2To.js";import{_ as M,r as p,o as q,c as W,a as i,b as t,w as a,d,i as T,f as F,e as G,g as f,h as r,j as $,k as x,l as H,t as k,E as m,m as g,n as J,p as K}from"./index-CCJGmygT.js";import{p as L}from"./datetime-CpkTDmvr.js";const O={class:"page-stack"},Q={class:"app-page-title"},X={class:"table-wrap"},Y={class:"user-cell"},Z={class:"table-wrap"},ee={__name:"PendingPage",setup(te){const B=T("refreshStats",null),_=T("refreshNavBadges",null),v=p([]),c=p([]),w=p(!1),y=p(!1);function D(s){const e=s?.vip_expire_time;if(!e)return!1;if(String(e).startsWith("2099-12-31"))return!0;const o=L(e);return o?o.getTime()>Date.now():!1}async function j(){w.value=!0;try{v.value=await E()}catch{v.value=[]}finally{w.value=!1}}async function h(){y.value=!0;try{c.value=await F()}catch{c.value=[]}finally{y.value=!1}}async function u(){await Promise.all([j(),h()]),await _?.({pendingResets:c.value.length})}async function N(s){try{await m.confirm(`确定通过用户「${s.username}」的注册申请吗?`,"审核通过",{confirmButtonText:"通过",cancelButtonText:"取消",type:"success"})}catch{return}try{await I(s.id),g.success("用户审核通过"),await u(),await B?.()}catch{}}async function U(s){try{await m.confirm(`确定拒绝用户「${s.username}」的注册申请吗?`,"拒绝申请",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{await A(s.id),g.success("已拒绝用户"),await u(),await B?.()}catch{}}async function V(s){try{await m.confirm(`确定批准「${s.username}」的密码重置申请吗?`,"批准重置",{confirmButtonText:"批准",cancelButtonText:"取消",type:"success"})}catch{return}try{const e=await J(s.id);g.success(e?.message||"密码重置申请已批准"),await h(),await _?.({pendingResets:c.value.length})}catch{}}async function z(s){try{await m.confirm(`确定拒绝「${s.username}」的密码重置申请吗?`,"拒绝重置",{confirmButtonText:"拒绝",cancelButtonText:"取消",type:"warning"})}catch{return}try{const e=await K(s.id);g.success(e?.message||"密码重置申请已拒绝"),await h(),await _?.({pendingResets:c.value.length})}catch{}}return q(u),(s,e)=>{const o=d("el-button"),l=d("el-table-column"),S=d("el-tag"),R=d("el-table"),C=d("el-card"),P=G("loading");return f(),W("div",O,[i("div",Q,[e[1]||(e[1]=i("h2",null,"待审核",-1)),i("div",null,[t(o,{onClick:u},{default:a(()=>[...e[0]||(e[0]=[r("刷新",-1)])]),_:1})])]),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[5]||(e[5]=i("h3",{class:"section-title"},"用户注册审核",-1)),i("div",X,[$((f(),x(R,{data:v.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"ID",width:"80"}),t(l,{label:"用户名","min-width":"200"},{default:a(({row:n})=>[i("div",Y,[i("strong",null,k(n.username),1),D(n)?(f(),x(S,{key:0,type:"warning",effect:"light",size:"small"},{default:a(()=>[...e[2]||(e[2]=[r("VIP",-1)])]),_:1})):H("",!0)])]),_:1}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"注册时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>N(n)},{default:a(()=>[...e[3]||(e[3]=[r("通过",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>U(n)},{default:a(()=>[...e[4]||(e[4]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,w.value]])])]),_:1}),t(C,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:a(()=>[e[8]||(e[8]=i("h3",{class:"section-title"},"密码重置审核",-1)),i("div",Z,[$((f(),x(R,{data:c.value,style:{width:"100%"}},{default:a(()=>[t(l,{prop:"id",label:"申请ID",width:"90"}),t(l,{prop:"username",label:"用户名","min-width":"200"}),t(l,{prop:"email",label:"邮箱","min-width":"220"},{default:a(({row:n})=>[r(k(n.email||"-"),1)]),_:1}),t(l,{prop:"created_at",label:"申请时间","min-width":"180"}),t(l,{label:"操作",width:"180",fixed:"right"},{default:a(({row:n})=>[t(o,{type:"success",size:"small",onClick:b=>V(n)},{default:a(()=>[...e[6]||(e[6]=[r("批准",-1)])]),_:1},8,["onClick"]),t(o,{type:"danger",size:"small",onClick:b=>z(n)},{default:a(()=>[...e[7]||(e[7]=[r("拒绝",-1)])]),_:1},8,["onClick"])]),_:1})]),_:1},8,["data"])),[[P,y.value]])])]),_:1})])}}},le=M(ee,[["__scopeId","data-v-f2aa6820"]]);export{le as default};

View File

@@ -0,0 +1 @@
import{B as m,_ as T,r as p,c as h,a as r,b as a,w as s,d as u,g as k,h as b,m as d,E as x}from"./index-CCJGmygT.js";async function C(o){const{data:t}=await m.put("/admin/username",{new_username:o});return t}async function E(o){const{data:t}=await m.put("/admin/password",{new_password:o});return t}async function P(){const{data:o}=await m.post("/logout");return o}const U={class:"page-stack"},N={__name:"SettingsPage",setup(o){const t=p(""),i=p(""),n=p(!1);async function f(){try{await P()}catch{}finally{window.location.href="/yuyx"}}async function B(){const l=t.value.trim();if(!l){d.error("请输入新用户名");return}try{await x.confirm(`确定将管理员用户名修改为「${l}」吗?修改后需要重新登录。`,"修改用户名",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await C(l),d.success("用户名修改成功,请重新登录"),t.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}async function V(){const l=i.value;if(!l){d.error("请输入新密码");return}if(l.length<6){d.error("密码至少6个字符");return}try{await x.confirm("确定修改管理员密码吗?修改后需要重新登录。","修改密码",{confirmButtonText:"确认修改",cancelButtonText:"取消",type:"warning"})}catch{return}n.value=!0;try{await E(l),d.success("密码修改成功,请重新登录"),i.value="",setTimeout(f,1200)}catch{}finally{n.value=!1}}return(l,e)=>{const w=u("el-input"),v=u("el-form-item"),y=u("el-form"),_=u("el-button"),g=u("el-card");return k(),h("div",U,[e[7]||(e[7]=r("div",{class:"app-page-title"},[r("h2",null,"设置"),r("span",{class:"app-muted"},"管理员账号设置")],-1)),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[3]||(e[3]=r("h3",{class:"section-title"},"修改管理员用户名",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新用户名"},{default:s(()=>[a(w,{modelValue:t.value,"onUpdate:modelValue":e[0]||(e[0]=c=>t.value=c),placeholder:"输入新用户名",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:B},{default:s(()=>[...e[2]||(e[2]=[b("保存用户名",-1)])]),_:1},8,["loading"])]),_:1}),a(g,{shadow:"never","body-style":{padding:"16px"},class:"card"},{default:s(()=>[e[5]||(e[5]=r("h3",{class:"section-title"},"修改管理员密码",-1)),a(y,{"label-width":"120px"},{default:s(()=>[a(v,{label:"新密码"},{default:s(()=>[a(w,{modelValue:i.value,"onUpdate:modelValue":e[1]||(e[1]=c=>i.value=c),type:"password","show-password":"",placeholder:"输入新密码",disabled:n.value},null,8,["modelValue","disabled"])]),_:1})]),_:1}),a(_,{type:"primary",loading:n.value,onClick:V},{default:s(()=>[...e[4]||(e[4]=[b("保存密码",-1)])]),_:1},8,["loading"]),e[6]||(e[6]=r("div",{class:"help"},"建议使用更强密码至少8位且包含字母与数字。",-1))]),_:1})])}}},M=T(N,[["__scopeId","data-v-2f4b840f"]]);export{M as default};

View File

@@ -0,0 +1 @@
.page-stack[data-v-2f4b840f]{display:flex;flex-direction:column;gap:12px}.card[data-v-2f4b840f]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-2f4b840f]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-2f4b840f]{margin-top:10px;font-size:12px;color:var(--app-muted)}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-cdfd4595]{display:flex;flex-direction:column;gap:12px}.metric-card[data-v-cdfd4595],.card[data-v-cdfd4595]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.metric-label[data-v-cdfd4595]{font-size:12px;color:var(--app-muted)}.metric-value[data-v-cdfd4595]{margin-top:6px;font-size:18px;font-weight:800}.metric-sub[data-v-cdfd4595]{margin-top:4px;font-size:12px}.section-head[data-v-cdfd4595]{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:12px}.section-title[data-v-cdfd4595]{margin:0;font-size:14px;font-weight:800}.count-row[data-v-cdfd4595]{margin-bottom:10px}.count-card[data-v-cdfd4595]{border-radius:10px;border:1px solid var(--app-border)}.count-card.ok[data-v-cdfd4595]{background:#10b98114}.count-card.warn[data-v-cdfd4595]{background:#f59e0b14}.count-value[data-v-cdfd4595]{font-size:22px;font-weight:900;line-height:1.1}.count-label[data-v-cdfd4595]{margin-top:4px;font-size:12px;color:var(--app-muted)}.sub-title[data-v-cdfd4595]{margin-top:14px;margin-bottom:8px;font-size:13px;font-weight:800}.empty[data-v-cdfd4595]{padding:10px 0}.task-list[data-v-cdfd4595]{display:flex;flex-direction:column;gap:8px}.task-item[data-v-cdfd4595]{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[data-v-cdfd4595]{background:#f59e0b0f}.task-line[data-v-cdfd4595]{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.task-line2[data-v-cdfd4595]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:12px}.task-user[data-v-cdfd4595]{font-weight:600}.task-account[data-v-cdfd4595]{font-weight:700;color:#2563eb}.dot[data-v-cdfd4595]{width:8px;height:8px;border-radius:999px;display:inline-block}.task-status[data-v-cdfd4595]{font-weight:700}.task-right[data-v-cdfd4595]{font-size:12px;font-weight:700;color:#10b981;white-space:nowrap}.task-right.warn[data-v-cdfd4595]{color:#f59e0b}.stat-grid[data-v-cdfd4595]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.stat-box[data-v-cdfd4595]{border-radius:12px;border:1px solid var(--app-border);padding:12px}.stat-box.ok[data-v-cdfd4595]{background:#10b98114}.stat-box.err[data-v-cdfd4595]{background:#ef444414}.stat-box.info[data-v-cdfd4595]{background:#3b82f614}.stat-box.info2[data-v-cdfd4595]{background:#06b6d414}.stat-name[data-v-cdfd4595]{font-size:12px;font-weight:800;margin-bottom:6px}.stat-row[data-v-cdfd4595]{display:flex;align-items:baseline;gap:8px}.stat-big[data-v-cdfd4595]{font-size:20px;font-weight:900}.stat-row2[data-v-cdfd4595]{margin-top:6px;font-size:12px}

View File

@@ -0,0 +1 @@
.page-stack[data-v-6af756b3]{display:flex;flex-direction:column;gap:12px}.card[data-v-6af756b3]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.section-title[data-v-6af756b3]{margin:0 0 12px;font-size:14px;font-weight:800}.help[data-v-6af756b3]{margin-top:6px;font-size:12px;color:var(--app-muted)}.row-actions[data-v-6af756b3]{display:flex;flex-wrap:wrap;gap:10px}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.page-stack[data-v-e62c5723]{display:flex;flex-direction:column;gap:12px}.card[data-v-e62c5723]{border-radius:var(--app-radius);border:1px solid var(--app-border)}.table-wrap[data-v-e62c5723]{overflow-x:auto}.user-block[data-v-e62c5723]{display:flex;flex-direction:column;gap:2px}.user-main[data-v-e62c5723]{display:inline-flex;align-items:center;gap:8px;flex-wrap:wrap}.user-sub[data-v-e62c5723]{font-size:12px}.vip-sub[data-v-e62c5723]{font-size:12px;color:#7c3aed}.actions[data-v-e62c5723]{display:flex;flex-wrap:wrap;gap:8px}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function i(t){if(!t)return null;if(t instanceof Date)return t;const e=String(t),r=e.includes("T")?e:e.replace(" ","T"),n=new Date(r);return Number.isNaN(n.getTime())?null:n}export{i as p};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{B as a}from"./index-CCJGmygT.js";async function c(){const{data:t}=await a.get("/server/info");return t}async function e(){const{data:t}=await a.get("/docker_stats");return t}async function o(){const{data:t}=await a.get("/task/stats");return t}async function r(){const{data:t}=await a.get("/task/running");return t}async function i(t){const{data:s}=await a.get("/task/logs",{params:t});return s}async function f(t){const{data:s}=await a.post("/task/logs/clear",{days:t});return s}export{e as a,o as b,r as c,i as d,f as e,c as f};

View File

@@ -0,0 +1 @@
import{B as a}from"./index-CCJGmygT.js";async function r(){const{data:s}=await a.get("/users");return s}async function c(){const{data:s}=await a.get("/users/pending");return s}async function o(s){const{data:t}=await a.post(`/users/${s}/approve`);return t}async function i(s){const{data:t}=await a.post(`/users/${s}/reject`);return t}async function u(s){const{data:t}=await a.delete(`/users/${s}`);return t}async function d(s,t){const{data:e}=await a.post(`/users/${s}/vip`,{days:t});return e}async function p(s){const{data:t}=await a.delete(`/users/${s}/vip`);return t}async function f(s,t){const{data:e}=await a.post(`/users/${s}/reset_password`,{new_password:t});return e}export{o as a,r as b,p as c,f as d,u as e,c as f,i as r,d as s};

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-CCJGmygT.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-lm5BCraY.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; }