Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3179150596 | ||
|
|
b5d3620273 | ||
|
|
e38adee8ff | ||
| 1c7b750cda | |||
|
|
301bb63ef0 | ||
| 4b437c34c6 | |||
| cf965bcfee | |||
| dfcdddabad | |||
| 4b9bcd7a96 | |||
| a12fec4d82 | |||
| 1c0c024b9a | |||
| 359e15a82d | |||
| b7702d0285 | |||
| 37aa05b1e1 | |||
| 329256bd33 | |||
| 58caaae37a |
172
README.md
172
README.md
@@ -1,32 +1,170 @@
|
||||
# CloudSearch — 网盘搜索 + 视频解析一站式平台
|
||||
# CloudSearch — 网盘内容搜索与智能管理平台
|
||||
|
||||
## 快速部署
|
||||
CloudSearch 是一款集**跨网盘搜索引擎、网盘资源管理、存储空间清理、转存自动化**于一体的全栈 Web 应用。支持夸克网盘、百度网盘、阿里云盘等主流云盘,提供统一的管理后台和用户友好的搜索首页。
|
||||
|
||||
---
|
||||
|
||||
## 功能全景
|
||||
|
||||
### 🔍 搜索引擎
|
||||
- **多数据源聚合**:集成 PanSou 搜索引擎 + 多个 TG 频道资源,一站式查找网盘资源
|
||||
- **多盘直链转存**:支持夸克网盘、百度网盘、阿里云盘、115 网盘等主流平台的转存操作
|
||||
- **分享链接复用**:相同原始链接自动复用已有资源,60 秒内重复请求直接返回缓存
|
||||
- **Cookie 验证**:自动检测各网盘 Cookie 有效性,过期自动标记
|
||||
|
||||
### 📊 管理后台
|
||||
- **仪表盘**:实时展示网盘数量、总空间、转存统计、活跃配置等核心指标
|
||||
- **网盘配置**:添加/管理各网盘账号(夸克、百度、阿里云、115 等),支持主账号设置、转存启用控制
|
||||
- **存储清理**:
|
||||
- 自动清理策略设置(保留天数、回收站清空、空间阈值自动清理)
|
||||
- 白名单目录保护(排除不清理的目录)
|
||||
- 一键清理、一键清空回收站
|
||||
- 空间校准与 Cookie 检测间隔配置
|
||||
- **系统配置**:管理员密码修改、Logo 自定义、搜索策略配置
|
||||
- **转存日志**:查看所有转存操作记录,支持搜索与详情展开
|
||||
|
||||
### 🧹 智能存储管理
|
||||
- **存储空间校准**:快速获取网盘实际使用空间(支持夸克 `/member` 接口,单次 API 调用)
|
||||
- **空间阈值自动清理**:超出设定使用率阈值时按比例自动删除最旧文件
|
||||
- **回收站清空**:一键清空所有已启用驱动的网盘回收站
|
||||
|
||||
### 🌐 用户前端(首页 + H5)
|
||||
- 响应式搜索界面,支持桌面端与移动端
|
||||
- 搜索结果即时展示
|
||||
- 推广内容展示
|
||||
|
||||
### 🔐 安全特性
|
||||
- Cookie 加密存储(AES-256-GCM)
|
||||
- JWT 登录认证
|
||||
- 暗色/亮色主题切换
|
||||
- 非 root 容器运行
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 用户浏览器 │
|
||||
│ Vue 3 SPA(Element Plus UI) │
|
||||
└──────────────┬──────────────────┬────────────────┘
|
||||
│ http/9527 │
|
||||
┌──────────────▼──────────────────▼────────────────┐
|
||||
│ Express.js 后端 API │
|
||||
│ TypeScript + better-sqlite3 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 📦 网盘驱动 │
|
||||
│ ┌──────┐ ┌──────┐ ┌────────┐ ┌──────┐ │
|
||||
│ │ 夸克 │ │ 百度 │ │ 阿里云 │ │ 115 │ … │
|
||||
│ └──────┘ └──────┘ └────────┘ └──────┘ │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 🗃️ 数据层 │
|
||||
│ SQLite(系统配置 + 转存记录 + Cookie缓存) │
|
||||
│ Redis 缓存(可选自动降级) │
|
||||
└──────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────▼────────────────────────────────────┐
|
||||
│ PanSou 搜索引擎容器 │
|
||||
│ 80+ 数据源(网盘/资源站/TG 频道/磁力) │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 组件栈
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| **前端** | Vue 3 (Composition API) + TypeScript + Element Plus + Vite |
|
||||
| **后端** | Node.js + Express + TypeScript + better-sqlite3 |
|
||||
| **搜索引擎** | PanSou(独立容器,80+ 数据源插件) |
|
||||
| **缓存** | Redis 7(可选,自动降级内存缓存) |
|
||||
| **容器化** | Docker + Docker Compose(3 服务:app + pansou + redis) |
|
||||
|
||||
---
|
||||
|
||||
## 一键部署
|
||||
|
||||
### 前置条件
|
||||
- 服务器:**Linux x86_64**(推荐 2C4G 以上)
|
||||
- Docker Engine 24+ & Docker Compose v2
|
||||
|
||||
### 部署命令
|
||||
|
||||
```bash
|
||||
# 1. 解压
|
||||
# 1. 下载部署包
|
||||
wget https://gitea.timxx.cn/admin/CloudSearch/releases/download/v0.1.6/cloudsearch-deploy.tar.gz
|
||||
# 或直接从仓库克隆
|
||||
# git clone https://gitea.timxx.cn/admin/CloudSearch.git && cd CloudSearch
|
||||
|
||||
# 2. 解压 & 进入目录
|
||||
tar xzf cloudsearch-deploy.tar.gz
|
||||
cd cloudsearch
|
||||
|
||||
# 2. 配置环境变量
|
||||
# 3. 一键配置 + 启动
|
||||
cp .env.example .env
|
||||
# 自动生成 JWT Secret
|
||||
sed -i "s/change-me-to-a-random-64-char-hex-string/$(openssl rand -hex 32)/" .env
|
||||
# 自动生成 Cookie 加密密钥
|
||||
sed -i "s/change-me-to-a-random-key/$(openssl rand -hex 32)/" .env
|
||||
# ⚠️ 务必手动修改 ADMIN_PASSWORD 和 CORS_ORIGIN
|
||||
echo "⚠️ 请编辑 .env 文件,修改 ADMIN_PASSWORD 和 CORS_ORIGIN"
|
||||
|
||||
# 3. 一键启动
|
||||
docker compose up -d
|
||||
|
||||
# 4. 访问
|
||||
# 首页: http://服务器IP
|
||||
# 管理后台: http://服务器IP/admin/login
|
||||
# 4. 检查服务状态
|
||||
docker compose ps
|
||||
# 预期输出:CloudSearch_App、CloudSearch_PanSou 均为 Up
|
||||
|
||||
# 5. 访问
|
||||
# 搜索页面: http://your-server-ip:9527
|
||||
# 管理后台: http://your-server-ip:9527/admin/login
|
||||
```
|
||||
|
||||
## 首次使用
|
||||
### 环境变量说明
|
||||
|
||||
1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的)
|
||||
2. 在「网盘配置」中添加夸克网盘 Cookie
|
||||
3. 在「推广管理」中添加首页推广内容(可选)
|
||||
4. 返回首页即可开始搜索
|
||||
| 变量 | 必需 | 说明 |
|
||||
|---|---|---|
|
||||
| `ADMIN_USERNAME` | ✅ | 管理员用户名(默认 admin) |
|
||||
| `ADMIN_PASSWORD` | ✅ | **务必修改默认密码** |
|
||||
| `JWT_SECRET` | ✅ | 运行 `openssl rand -hex 32` 生成 |
|
||||
| `CORS_ORIGIN` | ✅ | 你的站点域名(如 `https://search.example.com`) |
|
||||
| `COOKIE_ENCRYPTION_KEY` | ⚠️ 推荐 | Cookie 加密密钥,不设置则重启后旧数据不可解密 |
|
||||
| `REDIS_URL` | ❌ 可选 | Redis 连接(不设则自动降级) |
|
||||
|
||||
### 首次使用
|
||||
|
||||
1. 登录管理后台 `/admin/login`
|
||||
2. 在「网盘配置」→「添加配置」中填入网盘 Cookie
|
||||
3. 启用对应网盘配置
|
||||
4. 返回首页即可搜索并转存资源
|
||||
|
||||
---
|
||||
|
||||
## 开发环境
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd packages/backend
|
||||
npm install
|
||||
cp .env.example .env # 编辑数据库路径等
|
||||
npm run dev
|
||||
|
||||
# 前端(新终端)
|
||||
cd packages/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更新升级
|
||||
|
||||
```bash
|
||||
cd /opt/CloudSearch
|
||||
git pull origin master
|
||||
docker compose down
|
||||
docker compose build --no-cache app
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可
|
||||
|
||||
本项目基于 Gitea 私有仓库维护,仅供授权用户使用。
|
||||
|
||||
> **注意**:本系统仅用于合法用途,使用者需遵守当地法律法规。网盘 Cookie 等凭证由用户自行保管,系统以加密形式存储。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "2.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
|
||||
77
packages/backend/package-lock.json
generated
77
packages/backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "2.0.9",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "2.0.9",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"playwright": "^1.52.0",
|
||||
"sharp": "^0.33.0",
|
||||
"socks-proxy-agent": "^9.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2396,6 +2397,14 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -3306,6 +3315,70 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz",
|
||||
"integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==",
|
||||
"dependencies": {
|
||||
"agent-base": "8.0.0",
|
||||
"debug": "^4.3.4",
|
||||
"socks": "^2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/agent-base": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz",
|
||||
"integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socks-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "0.0.2",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
|
||||
1191
packages/backend/src/cloud/baidu.driver.ts
Normal file
1191
packages/backend/src/cloud/baidu.driver.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { getSystemConfig, updateSystemConfig } from '../admin/system-config.serv
|
||||
import { formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { notifyEvent } from './notification.service';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CloudCleanupDriver — contract that each cloud driver must fulfill
|
||||
@@ -19,7 +20,7 @@ interface CleanupOpResult { trashed: number; errors: string[] }
|
||||
|
||||
interface CloudCleanupDriver {
|
||||
/** Trash date folders (YYYY-MM-DD) older than `days`. */
|
||||
cleanupOldDateFolders(days: number): Promise<CleanupOpResult>;
|
||||
cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise<CleanupOpResult>;
|
||||
/**
|
||||
* If used space exceeds thresholdPercent% of TOTAL capacity,
|
||||
* delete oldest date folders until totalBytes * deletePercent/100
|
||||
@@ -27,7 +28,7 @@ interface CloudCleanupDriver {
|
||||
* @param thresholdPercent — trigger when usage >= this % of total
|
||||
* @param deletePercent — free this % of total capacity
|
||||
*/
|
||||
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<CleanupOpResult>;
|
||||
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise<CleanupOpResult>;
|
||||
/** Permanently empty the recycle bin. */
|
||||
emptyTrash(): Promise<boolean>;
|
||||
}
|
||||
@@ -62,6 +63,18 @@ interface CleanupStats {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Read whitelist dirs from system_configs (cleanup_whitelist_dirs). */
|
||||
export function getWhitelistDirs(): string[] {
|
||||
const raw = getSystemConfig('cleanup_whitelist_dirs');
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((d: any) => typeof d === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all active cloud configs (any type). Used by the orchestrator. */
|
||||
function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }> {
|
||||
const db = getDb();
|
||||
@@ -77,6 +90,7 @@ function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cook
|
||||
*/
|
||||
async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
|
||||
const configs = getActiveCleanupConfigs();
|
||||
const whitelistDirs = getWhitelistDirs();
|
||||
const errors: string[] = [];
|
||||
let totalTrashed = 0;
|
||||
|
||||
@@ -87,7 +101,7 @@ async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await driver.cleanupOldDateFolders(days);
|
||||
const result = await driver.cleanupOldDateFolders(days, whitelistDirs);
|
||||
totalTrashed += result.trashed;
|
||||
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||
} catch (err: any) {
|
||||
@@ -108,6 +122,7 @@ async function cleanupAllBySpaceThreshold(
|
||||
deletePercent: number,
|
||||
): Promise<CleanupOpResult> {
|
||||
const configs = getActiveCleanupConfigs();
|
||||
const whitelistDirs = getWhitelistDirs();
|
||||
const errors: string[] = [];
|
||||
let totalTrashed = 0;
|
||||
|
||||
@@ -118,7 +133,7 @@ async function cleanupAllBySpaceThreshold(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent);
|
||||
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent, whitelistDirs);
|
||||
totalTrashed += result.trashed;
|
||||
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||
} catch (err: any) {
|
||||
@@ -251,4 +266,14 @@ export async function checkAndRunScheduledCleanup(): Promise<void> {
|
||||
console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`);
|
||||
const stats = await runFullCleanup();
|
||||
console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`);
|
||||
|
||||
// ── Notify ──
|
||||
const lines: string[] = [];
|
||||
if (stats.filesTrashed > 0) lines.push(`移入回收站 ${stats.filesTrashed} 个文件夹`);
|
||||
if (stats.logsDeleted > 0) lines.push(`删除 ${stats.logsDeleted} 条日志`);
|
||||
if (stats.trashEmptied) lines.push('已清空回收站');
|
||||
if (stats.errors.length > 0) lines.push(`⚠️ ${stats.errors.length} 个错误(${stats.errors.slice(0, 3).join('; ')}${stats.errors.length > 3 ? `...` : ''})`);
|
||||
if (lines.length > 0) {
|
||||
notifyEvent('cleanup', `🧹 清理完成`, lines.join('\n'), stats.errors.length > 0 ? 'warn' : 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getDb } from '../database/database';
|
||||
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent, notifyConfigEvent } from './notification.service';
|
||||
|
||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||
@@ -70,14 +72,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
if (alreadySaved && recentRecord.share_url) {
|
||||
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||
recentRecord.share_url, recentRecord.share_pwd || null,
|
||||
null, 0, 0, 0, 'reused', null,
|
||||
recentRecord.folder_name || null, recentRecord.original_folder_name || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
ipAddress || null, ipLocation, localTimestamp(), null,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
@@ -105,7 +107,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
if (existing?.share_url) {
|
||||
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||
const validator = new LinkValidator();
|
||||
const validation = await validator.validate(existing.share_url, 'quark');
|
||||
const validation = await validator.validateWithLocalFallback(existing.share_url, 'quark');
|
||||
if (validation.status === 'valid') {
|
||||
const isFirstReuse = dedupCutoff ? !db.prepare(
|
||||
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
||||
@@ -155,16 +157,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; fileSize?: number; originalFolderName?: string };
|
||||
|
||||
switch (cloudType) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
const driver = new QuarkDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
case 'baidu': {
|
||||
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||
const driver = new BaiduDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
|
||||
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||
break;
|
||||
}
|
||||
@@ -174,31 +176,78 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||
}
|
||||
|
||||
// ── If save failed, get actual error reason from PanSou validation ──
|
||||
let actualError: string | null = null;
|
||||
if (!driverResult.success) {
|
||||
try {
|
||||
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||
const validator = new LinkValidator();
|
||||
const validation = await validator.validate(shareUrl, cloudType);
|
||||
if (validation.message) {
|
||||
actualError = validation.message;
|
||||
}
|
||||
} catch {
|
||||
// PanSou unreachable
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (driverResult.success) {
|
||||
const nickname = config.nickname || cloudType;
|
||||
notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`,
|
||||
`**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
||||
'info', {
|
||||
file_name: driverResult.folderName || sourceTitle || shareUrl,
|
||||
file_size: '',
|
||||
cloud_type: cloudType,
|
||||
nickname: nickname || '',
|
||||
duration: ((Date.now() - startTime) / 1000).toFixed(1),
|
||||
share_url: shareUrl,
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||
).run(config.id);
|
||||
} else if ((driverResult as any).cookieExpired) {
|
||||
// Cookie expired — don't count as failure, user needs to re-login
|
||||
notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`,
|
||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
|
||||
'error', {
|
||||
cloud_type: cloudType,
|
||||
nickname: config.nickname || '',
|
||||
share_url: shareUrl,
|
||||
});
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
|
||||
if (failCount >= 3) {
|
||||
notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount} 次`,
|
||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
|
||||
'warn', {
|
||||
file_name: sourceTitle || shareUrl,
|
||||
fail_count: String(failCount),
|
||||
cloud_type: cloudType,
|
||||
nickname: config.nickname || '',
|
||||
error: driverResult.message || '',
|
||||
share_url: shareUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
|
||||
driverResult.shareUrl || null, driverResult.sharePwd || null,
|
||||
null, driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
driverResult.fileSize == null ? null : String(driverResult.fileSize), driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||
durationMs, driverResult.success ? 'success' : 'failed',
|
||||
driverResult.success ? null : driverResult.message,
|
||||
driverResult.success ? null : (actualError ? `${driverResult.message} | ${actualError}` : driverResult.message),
|
||||
driverResult.folderName || null, driverResult.originalFolderName || null,
|
||||
ipAddress || null, ipLocation, localTimestamp(),
|
||||
ipAddress || null, ipLocation, localTimestamp(), config.id
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -221,9 +270,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
).run(config.id);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at)
|
||||
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at, config_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp());
|
||||
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null);
|
||||
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
@@ -270,15 +319,19 @@ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDat
|
||||
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
|
||||
}
|
||||
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
|
||||
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
|
||||
const srWhere = conditions.length > 0 ? 'WHERE sr.' + conditions.join(' AND sr.') : '';
|
||||
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${srWhere.replace(/sr\./g, '')}`).get(...params) as any).count;
|
||||
const records = db.prepare(
|
||||
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, offset) as SaveRecord[];
|
||||
`SELECT sr.*, cc.nickname as config_nickname
|
||||
FROM save_records sr
|
||||
LEFT JOIN cloud_configs cc ON sr.config_id = cc.id
|
||||
${srWhere}
|
||||
ORDER BY sr.created_at DESC LIMIT ? OFFSET ?`
|
||||
).all(...params, pageSize, offset) as any[];
|
||||
|
||||
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : '';
|
||||
const summaryWhere = summaryConditions.length > 0 ? 'WHERE sr.' + summaryConditions.join(' AND sr.') : '';
|
||||
const summaryRows = db.prepare(
|
||||
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
|
||||
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere.replace(/sr\./g, '')} GROUP BY status`
|
||||
).all(...summaryParams) as { status: string; cnt: number }[];
|
||||
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
|
||||
for (const r of summaryRows) {
|
||||
@@ -302,22 +355,86 @@ export function cleanupOldSaveRecords(): void {
|
||||
// ── Storage Refresh ───────────────────────────────────────────────
|
||||
|
||||
export async function refreshAllStorageInfo(): Promise<void> {
|
||||
const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie);
|
||||
const configs = getActiveCloudConfigs().filter(c => c.cookie);
|
||||
if (configs.length === 0) return;
|
||||
|
||||
const verifyCookies = getSystemConfig('cleanup_verify_enabled') === 'true';
|
||||
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||
const driver = new QuarkDriver({ cookie: cfg.cookie, nickname: cfg.nickname });
|
||||
const storage = await driver.getStorageInfo();
|
||||
if (storage.totalBytes > 0 || storage.usedBytes > 0) {
|
||||
const db = getDb();
|
||||
const decryptedCookie = decrypt(cfg.cookie!);
|
||||
|
||||
switch (cfg.cloud_type) {
|
||||
case 'quark': {
|
||||
const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: cfg.nickname });
|
||||
|
||||
// Get storage info (includes background calibration callback)
|
||||
const storage = await driver.getStorageInfo(
|
||||
(fullUsed: string, total: string) => {
|
||||
const dbInner = getDb();
|
||||
dbInner.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(fullUsed, total, cfg.id);
|
||||
console.log(`[Storage] Background calibration done for quark#${cfg.id}: ${fullUsed} / ${total}`);
|
||||
}
|
||||
);
|
||||
if (storage.totalBytes > 0 || storage.usedBytes > 0) {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(storage.used, storage.total, cfg.id);
|
||||
console.log(`[Storage] Updated quark#${cfg.id}: ${storage.used} / ${storage.total}`);
|
||||
}
|
||||
|
||||
// Cookie verification
|
||||
if (verifyCookies) {
|
||||
const valid = await driver.validate();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?`
|
||||
).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id);
|
||||
console.log(`[Storage] Verification for quark#${cfg.id}: ${valid ? 'valid' : 'invalid'}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'baidu': {
|
||||
const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: cfg.nickname });
|
||||
|
||||
// Get storage info
|
||||
const storage = await driver.getStorageInfo();
|
||||
if (storage.used !== '0 B' && storage.total !== '0 B') {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
|
||||
).run(storage.used, storage.total, cfg.id);
|
||||
console.log(`[Storage] Updated baidu#${cfg.id}: ${storage.used} / ${storage.total}`);
|
||||
}
|
||||
|
||||
// Cookie verification
|
||||
if (verifyCookies) {
|
||||
const valid = await driver.validate();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?`
|
||||
).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id);
|
||||
console.log(`[Storage] Verification for baidu#${cfg.id}: ${valid ? 'valid' : 'invalid'}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`[Storage] Skipping ${cfg.cloud_type}#${cfg.id} — unsupported cloud type for storage refresh`);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
|
||||
console.error(`[Storage] Failed to refresh ${cfg.cloud_type}#${cfg.id}:`, err.message);
|
||||
// On error, mark as invalid if verification is enabled
|
||||
if (verifyCookies) {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||
).run(localTimestamp(), cfg.id);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface CloudConfig {
|
||||
is_active: number;
|
||||
promotion_account?: string;
|
||||
is_transfer_enabled: number;
|
||||
is_primary: number;
|
||||
storage_used?: string;
|
||||
storage_total?: string;
|
||||
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||
@@ -70,7 +71,7 @@ function extractQuarkUid(cookie: string): string | null {
|
||||
export function getCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -81,7 +82,7 @@ export function getCloudConfigs(): CloudConfig[] {
|
||||
export function getAvailableClouds(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -93,7 +94,7 @@ export function getAvailableClouds(): CloudConfig[] {
|
||||
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -105,7 +106,7 @@ export function getCloudConfigByType(cloudType: string): CloudConfig | undefined
|
||||
export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||
@@ -117,7 +118,7 @@ export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -126,6 +127,31 @@ export function getActiveCloudConfigs(): CloudConfig[] {
|
||||
).all() as CloudConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the is_primary flag for a cloud config.
|
||||
* Enforces max 2 primary accounts per cloud type.
|
||||
*/
|
||||
export function togglePrimary(id: number, setPrimary: boolean): CloudConfig {
|
||||
const db = getDb();
|
||||
const config = getCloudConfigById(id);
|
||||
if (!config) throw new Error(`Cloud config ${id} not found`);
|
||||
|
||||
if (setPrimary) {
|
||||
// Check how many primary accounts already exist for this cloud type
|
||||
const primaryCount = db.prepare(
|
||||
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?`
|
||||
).get(config.cloud_type, id) as { c: number };
|
||||
if (primaryCount.c >= 2) {
|
||||
throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`)
|
||||
.run(setPrimary ? 1 : 0, id);
|
||||
|
||||
return getCloudConfigById(id)!;
|
||||
}
|
||||
|
||||
export function saveCloudConfig(data: {
|
||||
id?: number;
|
||||
cloud_type: string;
|
||||
@@ -163,7 +189,7 @@ export function saveCloudConfig(data: {
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
|
||||
} else {
|
||||
// Try to find existing config by cloud_type + cloud_type_uid
|
||||
let existing: any = null;
|
||||
@@ -194,12 +220,12 @@ export function saveCloudConfig(data: {
|
||||
consecutive_failures = 0,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
|
||||
).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
|
||||
|
||||
// Re-read savedId for return
|
||||
const savedId = existing.id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -210,12 +236,12 @@ export function saveCloudConfig(data: {
|
||||
// No existing config found — insert new
|
||||
db.prepare(
|
||||
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
|
||||
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
|
||||
}
|
||||
|
||||
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||
return db.prepare(
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
|
||||
cloud_type_uid,
|
||||
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||
last_used_at, total_saves, created_at, updated_at
|
||||
@@ -402,13 +428,15 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
|
||||
let config: CloudConfig | undefined;
|
||||
|
||||
|
||||
|
||||
if (!ipAddress) {
|
||||
// No IP info — fallback to simple LUR
|
||||
config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
} else {
|
||||
@@ -428,17 +456,24 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
|
||||
const ipTodayCount = ipCountRow?.total || 0;
|
||||
|
||||
if (ipTodayCount < 3) {
|
||||
// First 2 saves — use the primary account (least recently used, healthy)
|
||||
// How many primary accounts does this cloud type have?
|
||||
const primaryCountRow = db.prepare(
|
||||
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1`
|
||||
).get(cloudType) as { c: number };
|
||||
const primaryCount = primaryCountRow?.c || 0;
|
||||
const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP
|
||||
|
||||
if (ipTodayCount < primaryThreshold) {
|
||||
// First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy
|
||||
config = db.prepare(
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
} else {
|
||||
// 3rd+ save — exclude accounts this IP has already used today,
|
||||
// After primary threshold — exclude accounts this IP has already used today,
|
||||
// fall back to other available accounts round-robin
|
||||
const usedConfigIds = db.prepare(
|
||||
`SELECT DISTINCT config_id FROM ip_daily_save_counts
|
||||
@@ -464,7 +499,7 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
|
||||
`SELECT * FROM cloud_configs
|
||||
WHERE cloud_type = ? AND is_active = 1
|
||||
AND consecutive_failures < 5
|
||||
ORDER BY last_used_at ASC NULLS FIRST
|
||||
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
|
||||
LIMIT 1`
|
||||
).get(cloudType) as CloudConfig | undefined;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,30 @@ function migrateCloudConfigs(db: Database.Database): void {
|
||||
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||
}
|
||||
}
|
||||
// Migration 6: Add notify_config JSON column for per-config notification settings
|
||||
const row6 = db!.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get();
|
||||
if (!row6) {
|
||||
db!.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT '{}'");
|
||||
console.log('[DB] cloud_configs migration: notify_config column added');
|
||||
}
|
||||
|
||||
// Migration 7: Add push_users table for multi-user notification settings
|
||||
const hasPushUsers = db!.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='push_users'"
|
||||
).get();
|
||||
if (!hasPushUsers) {
|
||||
db!.exec(
|
||||
`CREATE TABLE IF NOT EXISTS push_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT NOT NULL UNIQUE,
|
||||
notify_config TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
)`
|
||||
);
|
||||
console.log('[DB] push_users table created');
|
||||
}
|
||||
|
||||
|
||||
function seedAdmin(db: Database.Database): void {
|
||||
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||
@@ -306,6 +330,11 @@ function seedSystemConfigs(db: Database.Database): void {
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_whitelist_dirs', value: '[]', description: '清理白名单目录名称列表(JSON数组),这些目录不会被自动清理' },
|
||||
{ key: 'storage_refresh_interval', value: '60', description: '存储空间刷新间隔(分钟),0=不自动刷新' },
|
||||
{ key: 'cleanup_auto_refresh_storage', value: 'true', description: '启用自动刷新存储空间信息' },
|
||||
{ key: 'cleanup_verify_enabled', value: 'true', description: '定期验证网盘 Cookie 有效性(随存储刷新一起执行)' },
|
||||
{ key: 'cleanup_verify_interval', value: '30', description: 'Cookie 有效性检测间隔(分钟)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ export function getAdKeywords(): string[] {
|
||||
const raw = getSystemConfig("quark_ad_keywords") || "";
|
||||
return raw
|
||||
.split("\n")
|
||||
.flatMap((line) => line.split(","))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -26,6 +27,7 @@ export function getWarningFolderNames(): string[] {
|
||||
const raw = getSystemConfig("quark_warning_folder_names") || "";
|
||||
return raw
|
||||
.split("\n")
|
||||
.flatMap((line) => line.split(","))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -66,7 +68,8 @@ export async function deleteAdFiles(
|
||||
dirFid: string,
|
||||
keywords: string[],
|
||||
): Promise<number> {
|
||||
if (!keywords.length) return 0;
|
||||
const extensions = getSusExtensions();
|
||||
if (!keywords.length && !extensions.length) return 0;
|
||||
|
||||
let deletedCount = 0;
|
||||
const stack: string[] = [dirFid];
|
||||
@@ -125,6 +128,7 @@ async function batchDeleteFiles(
|
||||
cookie: string,
|
||||
fids: string[],
|
||||
): Promise<boolean> {
|
||||
if (!fids.length) return true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
|
||||
@@ -135,13 +139,17 @@ async function batchDeleteFiles(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action_type: 2, // 2 = 移入回收站
|
||||
file_list: fids.map((fid) => ({ fid })),
|
||||
exclude_fids: [],
|
||||
action_type: 1,
|
||||
filelist: fids,
|
||||
exclude_filelist: [],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
|
||||
return false;
|
||||
}
|
||||
const data = (await resp.json()) as any;
|
||||
if (data.status === 200) {
|
||||
return true;
|
||||
@@ -156,6 +164,7 @@ async function batchDeleteFiles(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== 警示文件夹创建 ====================
|
||||
|
||||
/**
|
||||
@@ -166,12 +175,13 @@ async function batchDeleteFiles(
|
||||
export async function createWarningDirectories(
|
||||
cookie: string,
|
||||
dirNames: string[],
|
||||
parentDirFid: string = "0",
|
||||
): Promise<void> {
|
||||
if (!dirNames.length) return;
|
||||
|
||||
// 先获取根目录下所有文件夹,避免重复创建
|
||||
await humanDelay();
|
||||
const rootFiles = await listDirAllPages(cookie, "0");
|
||||
const rootFiles = await listDirAllPages(cookie, parentDirFid);
|
||||
const existingDirs = new Set(
|
||||
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
|
||||
);
|
||||
@@ -192,7 +202,7 @@ export async function createWarningDirectories(
|
||||
continue;
|
||||
}
|
||||
|
||||
await createSingleDir(cookie, formattedName);
|
||||
await createSingleDir(cookie, formattedName, parentDirFid);
|
||||
// 加入已存在集合,防止同名重试
|
||||
existingDirs.add(formattedName);
|
||||
}
|
||||
@@ -204,6 +214,7 @@ export async function createWarningDirectories(
|
||||
async function createSingleDir(
|
||||
cookie: string,
|
||||
dirName: string,
|
||||
pdirFid: string = "0",
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
@@ -215,7 +226,7 @@ async function createSingleDir(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pdir_fid: "0",
|
||||
pdir_fid: pdirFid,
|
||||
file_name: dirName,
|
||||
dir: true,
|
||||
dir_path: "",
|
||||
@@ -253,22 +264,23 @@ export async function runAdCleanup(
|
||||
savedDirFid: string,
|
||||
): Promise<{ adDeleted: number; warningDirs: number }> {
|
||||
const keywords = getAdKeywords();
|
||||
const susExtensions = getSusExtensions();
|
||||
const warningNames = getWarningFolderNames();
|
||||
|
||||
let adDeleted = 0;
|
||||
let warningDirs = 0;
|
||||
|
||||
// 1. 广告关键词清理
|
||||
if (keywords.length > 0) {
|
||||
// 1. 广告关键词 + 可疑后缀清理
|
||||
if (keywords.length > 0 || susExtensions.length > 0) {
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`,
|
||||
`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`,
|
||||
);
|
||||
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
||||
`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
||||
);
|
||||
} else {
|
||||
console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理");
|
||||
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
|
||||
}
|
||||
|
||||
// 2. 创建警示文件夹
|
||||
@@ -276,7 +288,7 @@ export async function runAdCleanup(
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`,
|
||||
);
|
||||
await createWarningDirectories(cookie, warningNames);
|
||||
await createWarningDirectories(cookie, warningNames, savedDirFid);
|
||||
warningDirs = warningNames.length;
|
||||
console.log(
|
||||
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
|
||||
|
||||
@@ -21,13 +21,7 @@ const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock
|
||||
*/
|
||||
export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> {
|
||||
try {
|
||||
const mparam = getMparam(cookie);
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
kps: mparam.kps || '',
|
||||
sign: mparam.sign || '',
|
||||
vcode: mparam.vcode || '',
|
||||
});
|
||||
const params = new URLSearchParams(getCommonParams());
|
||||
const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
@@ -44,14 +38,32 @@ export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Quick used-space estimate: sum root-level file sizes + subdir sizes
|
||||
// Accurate used space via /member API (1 call, no full traversal needed)
|
||||
// Ref: pan.quark.cn/1/clouddrive/member returns use_capacity + total_capacity
|
||||
let usedBytes = 0;
|
||||
try {
|
||||
const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' });
|
||||
const memberResp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (memberResp.ok) {
|
||||
const memberData = await memberResp.json() as any;
|
||||
if (memberData.status === 200 && memberData.data?.use_capacity != null) {
|
||||
usedBytes = memberData.data.use_capacity;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: sum root-level file sizes (夸克 folders return size=0)
|
||||
if (usedBytes === 0) {
|
||||
try {
|
||||
const rootFiles = await listRootDir(cookie);
|
||||
for (const f of rootFiles) {
|
||||
usedBytes += f.size || 0;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Cache the result (3h window)
|
||||
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
||||
@@ -87,20 +99,22 @@ export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string
|
||||
/**
|
||||
* Get storage info with used space calculation.
|
||||
*/
|
||||
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
||||
/**
|
||||
* Fast estimation (root-level files only) + background full traversal.
|
||||
* First call returns quickly; full traversal runs async and updates DB later.
|
||||
* `onBackgroundComplete` is called when traversal finishes.
|
||||
*/
|
||||
export async function getStorageInfo(
|
||||
cookie: string,
|
||||
onBackgroundComplete?: (used: string, total: string) => void
|
||||
): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
|
||||
try {
|
||||
const mparam = getMparam(cookie);
|
||||
let totalBytes = 0;
|
||||
const params = new URLSearchParams({
|
||||
...getCommonParams(),
|
||||
kps: mparam.kps || '',
|
||||
sign: mparam.sign || '',
|
||||
vcode: mparam.vcode || '',
|
||||
});
|
||||
const params = new URLSearchParams(getCommonParams());
|
||||
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||
headers: getHeaders(cookie),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
let totalBytes = 0;
|
||||
if (response.ok) {
|
||||
const data = await response.json() as any;
|
||||
if (data.status === 200 && data.data) {
|
||||
@@ -112,17 +126,32 @@ export async function getStorageInfo(cookie: string): Promise<{ used: string; to
|
||||
}
|
||||
}
|
||||
|
||||
const usedBytes = await calculateUsedSpace(cookie);
|
||||
const totalFormatted = totalBytes > 0 ? formatBytes(totalBytes) : '-';
|
||||
|
||||
if (totalBytes > 0 || usedBytes > 0) {
|
||||
return {
|
||||
total: totalBytes > 0 ? formatBytes(totalBytes) : '-',
|
||||
used: formatBytes(usedBytes),
|
||||
usedBytes,
|
||||
totalBytes: totalBytes > 0 ? totalBytes : 0,
|
||||
};
|
||||
// Quick estimation: sum root-level files only
|
||||
let quickUsed = 0;
|
||||
try {
|
||||
const rootFiles = await listRootDir(cookie);
|
||||
for (const f of rootFiles) {
|
||||
quickUsed += f.size || 0;
|
||||
}
|
||||
return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 };
|
||||
} catch {}
|
||||
|
||||
// Budget full traversal in background (no await)
|
||||
calculateUsedSpace(cookie).then(fullUsed => {
|
||||
if (onBackgroundComplete) {
|
||||
onBackgroundComplete(formatBytes(fullUsed), totalFormatted);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[Storage] Background full traversal failed:', err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
total: totalFormatted,
|
||||
totalBytes,
|
||||
used: formatBytes(quickUsed),
|
||||
usedBytes: quickUsed,
|
||||
};
|
||||
} catch {
|
||||
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
|
||||
}
|
||||
@@ -220,7 +249,7 @@ export async function emptyTrash(cookie: string): Promise<boolean> {
|
||||
/**
|
||||
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
|
||||
*/
|
||||
export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> {
|
||||
export async function cleanupOldDateFolders(cookie: string, days: number, whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
@@ -231,6 +260,7 @@ export async function cleanupOldDateFolders(cookie: string, days: number): Promi
|
||||
const oldFolders = rootItems.filter(item => {
|
||||
if (!item.dir) return false;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
|
||||
if (whitelistDirs && whitelistDirs.includes(item.file_name)) return false;
|
||||
return item.file_name < cutoffStr;
|
||||
});
|
||||
|
||||
@@ -259,6 +289,7 @@ export async function cleanupBySpaceThreshold(
|
||||
cookie: string,
|
||||
thresholdPercent: number,
|
||||
deletePercent: number,
|
||||
whitelistDirs?: string[],
|
||||
): Promise<{ trashed: number; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -277,6 +308,7 @@ export async function cleanupBySpaceThreshold(
|
||||
const rootItems = await listRootDir(cookie);
|
||||
const dateFolders = rootItems
|
||||
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
|
||||
.filter(item => !whitelistDirs || !whitelistDirs.includes(item.file_name))
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||
|
||||
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function saveFromShare(
|
||||
renamed?: string[];
|
||||
fileCount?: number;
|
||||
folderCount?: number;
|
||||
fileSize?: number;
|
||||
originalFolderName?: string;
|
||||
}> {
|
||||
try {
|
||||
@@ -203,6 +204,10 @@ export async function saveFromShare(
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total file size
|
||||
const allFiles = topDir && childFiles ? childFiles : topFiles;
|
||||
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
||||
|
||||
const renameMsg = renamed.length > 0
|
||||
? `,已重命名 ${renamed.length} 个文件`
|
||||
: '';
|
||||
@@ -218,6 +223,7 @@ export async function saveFromShare(
|
||||
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
|
||||
fileCount,
|
||||
folderCount,
|
||||
fileSize,
|
||||
originalFolderName,
|
||||
};
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -89,8 +89,8 @@ export class QuarkDriver {
|
||||
return getStorageInfoQuick(this.config.cookie);
|
||||
}
|
||||
|
||||
async getStorageInfo() {
|
||||
return getStorageInfo(this.config.cookie);
|
||||
async getStorageInfo(onBackgroundComplete?: (used: string, total: string) => void) {
|
||||
return getStorageInfo(this.config.cookie, onBackgroundComplete);
|
||||
}
|
||||
|
||||
async calculateUsedSpace(): Promise<number> {
|
||||
|
||||
@@ -1,95 +1,227 @@
|
||||
// Native fetch available in Node 20+
|
||||
// ============================================================
|
||||
// notification.service.ts — 插件化消息推送
|
||||
// 基于 notifiers/ 注册器,支持 14+ 个推送通道
|
||||
// 支持:全局推送(系统配置)和按网盘配置推送
|
||||
// ============================================================
|
||||
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { findPushUserForConfig } from './push-user.service';
|
||||
import { getAllNotifiers, getNotifier, notifyWith } from './notifiers';
|
||||
|
||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
export { getAllNotifiers, getNotifier, getAllNotifierParams } from './notifiers';
|
||||
export type { NotifyLevel, Notifier, NotifierParam, NotifyParams, NotifyResult } from './notifiers/notifier.types';
|
||||
|
||||
interface NotifyChannel {
|
||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||
/** 用户级推送配置(存储在 cloud_configs.notify_config) */
|
||||
export interface PerConfigNotify {
|
||||
// 每个通道的配置 = { channelName: { paramKey: value, ... } }
|
||||
channels?: Record<string, Record<string, string>>;
|
||||
events?: {
|
||||
on_save_success?: boolean;
|
||||
on_save_fail?: boolean;
|
||||
on_cookie_expire?: boolean;
|
||||
on_cleanup?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Feishu Webhook Channel ----
|
||||
class FeishuChannel implements NotifyChannel {
|
||||
private webhookUrl: string;
|
||||
// ======================== 全局(系统级)通道管理 ========================
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
let _globalChannelsCache: { name: string; params: Record<string, string> }[] | null = null;
|
||||
let _configHash: string = '';
|
||||
|
||||
function getGlobalNotifyConfigs(): { name: string; params: Record<string, string> }[] {
|
||||
// 从 global_notify_config JSON 读取(格式同 push user 的 notify_config)
|
||||
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||
let globalConfig: any = {};
|
||||
try { globalConfig = JSON.parse(raw); } catch {}
|
||||
|
||||
const channels: { name: string; params: Record<string, string> }[] = [];
|
||||
if (globalConfig.channels) {
|
||||
for (const [name, params] of Object.entries(globalConfig.channels)) {
|
||||
if (params && typeof params === 'object') {
|
||||
channels.push({ name, params: { ...(params as Record<string, string>), title: 'CloudSearch' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
||||
return channels;
|
||||
}
|
||||
|
||||
function checkEventEnabled(eventName: string): boolean {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
header: {
|
||||
title: { tag: 'plain_text', content: title },
|
||||
template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue',
|
||||
},
|
||||
elements: [
|
||||
{ tag: 'div', text: { tag: 'lark_md', content } },
|
||||
{
|
||||
tag: 'note',
|
||||
elements: [
|
||||
{ tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
// 优先读 global_notify_config.events
|
||||
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||
let globalConfig: any = {};
|
||||
try { globalConfig = JSON.parse(raw); } catch {}
|
||||
if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) {
|
||||
return globalConfig.events[`on_${eventName}`] !== false;
|
||||
}
|
||||
// 降级到旧字段
|
||||
const val = getSystemConfig(`notify_on_${eventName}`);
|
||||
return val !== 'false' && val !== '0';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 核心发送函数 ========================
|
||||
|
||||
async function sendToChannels(
|
||||
channels: { name: string; params: Record<string, string> }[],
|
||||
title: string,
|
||||
content: string,
|
||||
level: string
|
||||
): Promise<void> {
|
||||
for (const ch of channels) {
|
||||
try {
|
||||
await notifyWith(ch.name, {
|
||||
...ch.params,
|
||||
title,
|
||||
content,
|
||||
level,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Feishu send error:', err.message);
|
||||
console.error(`[Notify] ${ch.name} error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notification Manager ----
|
||||
let _channel: NotifyChannel | null = null;
|
||||
// ======================== 导出 API ========================
|
||||
|
||||
function getChannel(): NotifyChannel | null {
|
||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
||||
if (!feishuUrl) return null;
|
||||
|
||||
if (!_channel) {
|
||||
_channel = new FeishuChannel(feishuUrl);
|
||||
console.log('[Notify] Feishu webhook configured');
|
||||
}
|
||||
return _channel;
|
||||
/** 通过全局通道发送通知(不检查事件开关) */
|
||||
export function notify(title: string, content: string, level: 'info' | 'warn' | 'error' = 'info'): void {
|
||||
const channels = getGlobalNotifyConfigs();
|
||||
if (channels.length === 0) return;
|
||||
sendToChannels(channels, title, content, level).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification through configured channels.
|
||||
* Returns immediately — failures are logged silently.
|
||||
*/
|
||||
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||
const ch = getChannel();
|
||||
if (!ch) return;
|
||||
// Fire-and-forget — don't block the caller
|
||||
ch.send(title, content, level).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on critical events:
|
||||
* - Cookie expired / login failed
|
||||
* - Save/transfer failed repeatedly
|
||||
* - Storage below threshold
|
||||
*/
|
||||
export function notifyError(title: string, detail: string): void {
|
||||
notify(`⚠️ ${title}`, detail, 'error');
|
||||
notify(title, detail, 'error');
|
||||
}
|
||||
|
||||
export function notifyWarn(title: string, detail: string): void {
|
||||
notify(`🔔 ${title}`, detail, 'warn');
|
||||
notify(title, detail, 'warn');
|
||||
}
|
||||
|
||||
export function notifyInfo(title: string, detail: string): void {
|
||||
notify(`ℹ️ ${title}`, detail, 'info');
|
||||
notify(title, detail, 'info');
|
||||
}
|
||||
|
||||
/** 事件通知(检查全局事件开关) */
|
||||
export function notifyEvent(
|
||||
eventName: string,
|
||||
title: string,
|
||||
content: string,
|
||||
level: 'info' | 'warn' | 'error' = 'info'
|
||||
): void {
|
||||
if (!checkEventEnabled(eventName)) return;
|
||||
notify(title, content, level);
|
||||
}
|
||||
|
||||
/** 按网盘配置发送通知(检查用户级推送配置,无配置则降级到全局) */
|
||||
const DB_PATH = '/app/dist/database/database';
|
||||
|
||||
function getConfigNotifySettings(configId: number): PerConfigNotify {
|
||||
try {
|
||||
const { getDb } = require(DB_PATH);
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT notify_config FROM cloud_configs WHERE id = ?').get(configId) as any;
|
||||
if (row && row.notify_config) {
|
||||
return JSON.parse(row.notify_config);
|
||||
}
|
||||
} catch {}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function notifyConfigEvent(
|
||||
configId: number,
|
||||
eventName: string,
|
||||
title: string,
|
||||
content: string,
|
||||
level: 'info' | 'warn' | 'error' = 'info'
|
||||
): void {
|
||||
// Find matching push user by cloud_configs.promotion_account
|
||||
const pushUser = findPushUserForConfig(configId);
|
||||
if (!pushUser) {
|
||||
// No matching push user, fallback to global
|
||||
notifyEvent(eventName, title, content, level);
|
||||
return;
|
||||
}
|
||||
|
||||
let notifyConfig: any = {};
|
||||
try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {}
|
||||
|
||||
// Check event switch
|
||||
const eventKey = 'on_' + eventName;
|
||||
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
|
||||
|
||||
// Build channels from push user config
|
||||
const userChannels: { name: string; params: Record<string, string> }[] = [];
|
||||
if (notifyConfig.channels) {
|
||||
for (const [name, params] of Object.entries(notifyConfig.channels)) {
|
||||
userChannels.push({ name, params: { ...(params as Record<string, string>), title } });
|
||||
}
|
||||
}
|
||||
|
||||
if (userChannels.length > 0) {
|
||||
sendToChannels(userChannels, title, content, level).catch(() => {});
|
||||
} else {
|
||||
// Fallback to global
|
||||
notifyEvent(eventName, title, content, level);
|
||||
}
|
||||
}
|
||||
|
||||
/** 测试某个通道 */
|
||||
export async function testChannel(
|
||||
channelName: string,
|
||||
account?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
let params: Record<string, string> = {};
|
||||
|
||||
if (account) {
|
||||
const pushUser = findPushUserForConfig(undefined);
|
||||
// Use pushUser lookup by account instead
|
||||
const { getPushUserByAccount } = require('./push-user.service');
|
||||
const user = getPushUserByAccount(account);
|
||||
if (user) {
|
||||
let notifyConfig: any = {};
|
||||
try { notifyConfig = JSON.parse(user.notify_config); } catch {}
|
||||
const chParams = notifyConfig.channels?.[channelName];
|
||||
if (!chParams) return { success: false, message: '该用户未配置此渠道' };
|
||||
params = chParams;
|
||||
} else {
|
||||
return { success: false, message: '未找到该推送用户' };
|
||||
}
|
||||
} else {
|
||||
const channels = getGlobalNotifyConfigs();
|
||||
const ch = channels.find(c => c.name === channelName);
|
||||
if (!ch) return { success: false, message: '全局未配置此渠道' };
|
||||
params = ch.params;
|
||||
}
|
||||
|
||||
const result = await notifyWith(channelName, {
|
||||
...params,
|
||||
title: '\ud83d\udd14 CloudSearch \u6d4b\u8bd5',
|
||||
content: `\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u6d88\u606f\n\u901a\u9053: ${channelName}\n\u65f6\u95f4: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
level: 'info',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 保存用户级推送配置到数据库 */
|
||||
export function saveConfigNotifySettings(configId: number, notify: PerConfigNotify): void {
|
||||
try {
|
||||
const { getDb } = require(DB_PATH);
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE cloud_configs SET notify_config = ? WHERE id = ?').run(
|
||||
JSON.stringify(notify),
|
||||
configId
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Failed to save config notify settings:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取用户级推送配置 */
|
||||
export function getConfigNotifySettingsJSON(configId: number): PerConfigNotify {
|
||||
return getConfigNotifySettings(configId);
|
||||
}
|
||||
44
packages/backend/src/cloud/notifiers/bark.notifier.ts
Normal file
44
packages/backend/src/cloud/notifiers/bark.notifier.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'key', label: 'Bark Key', type: 'text', required: true, placeholder: 'xxxxxxxxxxxxxxxxx' },
|
||||
{ key: 'server', label: '服务器', type: 'url', default: 'https://api.day.app', required: false, placeholder: 'https://api.day.app' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch 通知', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const barkNotifier: Notifier = {
|
||||
name: 'bark',
|
||||
label: 'Bark',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const key = params.key;
|
||||
const server = (params.server || 'https://api.day.app').replace(/\/+$/, '');
|
||||
const title = params.title || 'CloudSearch';
|
||||
const content = params.content || '';
|
||||
const level: string = params.level || 'info';
|
||||
const icon = level === 'error' ? '⚠️' : level === 'warn' ? '🔔' : 'ℹ️';
|
||||
|
||||
const resp = await fetch(`${server}/${key}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: `${icon} ${title}`,
|
||||
body: content,
|
||||
group: 'CloudSearch',
|
||||
level: level === 'error' ? 'timeSensitive' : 'active',
|
||||
icon: '',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
return { success: false, message: `HTTP ${resp.status}: ${text.slice(0, 100)}` };
|
||||
}
|
||||
return { success: true, message: 'Bark 推送成功' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
34
packages/backend/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
34
packages/backend/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const dingtalkNotifier: Notifier = {
|
||||
name: 'dingtalk',
|
||||
label: '钉钉机器人',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const level: string = params.level || 'info';
|
||||
const text = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`;
|
||||
const resp = await fetch(params.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: { title: params.title || 'CloudSearch', text },
|
||||
at: { isAtAll: false },
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.errcode === 0) return { success: true, message: '钉钉推送成功' };
|
||||
return { success: false, message: data.errmsg || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
36
packages/backend/src/cloud/notifiers/discord.notifier.ts
Normal file
36
packages/backend/src/cloud/notifiers/discord.notifier.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const discordNotifier: Notifier = {
|
||||
name: 'discord',
|
||||
label: 'Discord',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const level: string = params.level || 'info';
|
||||
const color = level === 'error' ? 0xff0000 : level === 'warn' ? 0xffa500 : 0x3498db;
|
||||
const resp = await fetch(params.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: params.title || 'CloudSearch',
|
||||
description: params.content || '',
|
||||
color,
|
||||
footer: { text: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN') },
|
||||
}],
|
||||
}),
|
||||
});
|
||||
if (resp.ok) return { success: true, message: 'Discord 推送成功' };
|
||||
return { success: false, message: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
33
packages/backend/src/cloud/notifiers/gotify.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/gotify.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'server', label: '服务器地址', type: 'url', required: true, placeholder: 'https://gotify.example.com' },
|
||||
{ key: 'token', label: 'App Token', type: 'password', required: true, placeholder: 'Gotify App Token' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'priority', label: '优先级', type: 'number', default: 5, required: false },
|
||||
];
|
||||
|
||||
export const gotifyNotifier: Notifier = {
|
||||
name: 'gotify',
|
||||
label: 'Gotify',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const server = params.server.replace(/\/+$/, '');
|
||||
const resp = await fetch(`${server}/message?token=${params.token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: params.title || 'CloudSearch',
|
||||
message: params.content || '',
|
||||
priority: params.priority || 5,
|
||||
}),
|
||||
});
|
||||
if (resp.ok) return { success: true, message: 'Gotify 推送成功' };
|
||||
return { success: false, message: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
72
packages/backend/src/cloud/notifiers/index.ts
Normal file
72
packages/backend/src/cloud/notifiers/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* notifiers/index.ts — 注册器
|
||||
* 统一管理所有通知渠道,支持按 name 查找和列表获取
|
||||
*/
|
||||
|
||||
import { Notifier } from './notifier.types';
|
||||
|
||||
import { barkNotifier } from './bark.notifier';
|
||||
import { serverchanNotifier } from './serverchan.notifier';
|
||||
import { serverchanturboNotifier } from './serverchanturbo.notifier';
|
||||
import { telegramNotifier } from './telegram.notifier';
|
||||
import { larkNotifier } from './lark.notifier';
|
||||
import { webhookNotifier } from './webhook.notifier';
|
||||
import { wechatWorkBotNotifier } from './wechat_work_bot.notifier';
|
||||
import { pushplusNotifier } from './pushplus.notifier';
|
||||
import { dingtalkNotifier } from './dingtalk.notifier';
|
||||
import { gotifyNotifier } from './gotify.notifier';
|
||||
import { ntfyNotifier } from './ntfy.notifier';
|
||||
import { discordNotifier } from './discord.notifier';
|
||||
import { smtpNotifier } from './smtp.notifier';
|
||||
import { qmsgNotifier } from './qmsg.notifier';
|
||||
|
||||
const registry = new Map<string, Notifier>();
|
||||
|
||||
function register(n: Notifier): void {
|
||||
registry.set(n.name, n);
|
||||
}
|
||||
|
||||
// ==================== 注册所有内置通知器 ====================
|
||||
|
||||
register(barkNotifier);
|
||||
register(serverchanNotifier);
|
||||
register(serverchanturboNotifier);
|
||||
register(telegramNotifier);
|
||||
register(larkNotifier);
|
||||
register(webhookNotifier);
|
||||
register(wechatWorkBotNotifier);
|
||||
register(pushplusNotifier);
|
||||
register(dingtalkNotifier);
|
||||
register(gotifyNotifier);
|
||||
register(ntfyNotifier);
|
||||
register(discordNotifier);
|
||||
register(smtpNotifier);
|
||||
register(qmsgNotifier);
|
||||
|
||||
// ==================== API ====================
|
||||
|
||||
/** 根据名称获取通知器 */
|
||||
export function getNotifier(name: string): Notifier | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
/** 获取所有已注册的通知器 */
|
||||
export function getAllNotifiers(): Notifier[] {
|
||||
return Array.from(registry.values());
|
||||
}
|
||||
|
||||
/** 获取所有通知器的参数定义(用于前端动态生成表单) */
|
||||
export function getAllNotifierParams(): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
for (const [name, n] of registry) {
|
||||
result[name] = { name: n.name, label: n.label, params: n.params };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 向指定通知器发送通知 */
|
||||
export async function notifyWith(name: string, params: Record<string, any>): Promise<{ success: boolean; message: string }> {
|
||||
const n = getNotifier(name);
|
||||
if (!n) return { success: false, message: `未知的通知渠道: ${name}` };
|
||||
return n.notify(params);
|
||||
}
|
||||
39
packages/backend/src/cloud/notifiers/lark.notifier.ts
Normal file
39
packages/backend/src/cloud/notifiers/lark.notifier.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const larkNotifier: Notifier = {
|
||||
name: 'lark',
|
||||
label: '飞书/Lark',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const level: string = params.level || 'info';
|
||||
const template = level === 'error' ? 'red' : level === 'warn' ? 'orange' : 'blue';
|
||||
const resp = await fetch(params.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
header: { title: { tag: 'plain_text', content: params.title || 'CloudSearch' }, template },
|
||||
elements: [
|
||||
{ tag: 'div', text: { tag: 'lark_md', content: params.content || '' } },
|
||||
{ tag: 'note', elements: [{ tag: 'plain_text', content: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) }] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (resp.ok) return { success: true, message: '飞书推送成功' };
|
||||
const text = await resp.text();
|
||||
return { success: false, message: text.slice(0, 150) };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
34
packages/backend/src/cloud/notifiers/notifier.types.ts
Normal file
34
packages/backend/src/cloud/notifiers/notifier.types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 通知器类型定义 —— 参考 onepush 插件化设计
|
||||
* 每个 provider 注册为一个 Notifier,统一 notify(params) 接口
|
||||
* 新增通道只需在这里加一个文件 + 在 index.ts 注册
|
||||
*/
|
||||
|
||||
export type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface NotifyParams {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface NotifyResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 每个 provider 必须实现这个接口 */
|
||||
export interface Notifier {
|
||||
name: string;
|
||||
label: string;
|
||||
/** 参数描述(用于前端动态生成表单) */
|
||||
params: NotifierParam[];
|
||||
notify(params: NotifyParams): Promise<NotifyResult>;
|
||||
}
|
||||
|
||||
export interface NotifierParam {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'url' | 'switch' | 'number';
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
required: boolean;
|
||||
}
|
||||
34
packages/backend/src/cloud/notifiers/ntfy.notifier.ts
Normal file
34
packages/backend/src/cloud/notifiers/ntfy.notifier.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'my-notification-topic' },
|
||||
{ key: 'server', label: '服务器', type: 'url', default: 'https://ntfy.sh', required: false, placeholder: 'https://ntfy.sh' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'priority', label: '优先级(1-5)', type: 'number', default: 3, required: false },
|
||||
];
|
||||
|
||||
export const ntfyNotifier: Notifier = {
|
||||
name: 'ntfy',
|
||||
label: 'ntfy',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const server = (params.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const resp = await fetch(`${server}/${params.topic}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: params.title || 'CloudSearch',
|
||||
message: params.content || '',
|
||||
priority: params.priority || 3,
|
||||
tags: ['cloudsearch'],
|
||||
}),
|
||||
});
|
||||
if (resp.ok) return { success: true, message: 'ntfy 推送成功' };
|
||||
return { success: false, message: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
31
packages/backend/src/cloud/notifiers/pushplus.notifier.ts
Normal file
31
packages/backend/src/cloud/notifiers/pushplus.notifier.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'pushplus token' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
];
|
||||
|
||||
export const pushplusNotifier: Notifier = {
|
||||
name: 'pushplus',
|
||||
label: 'PushPlus (微信)',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const resp = await fetch('https://www.pushplus.plus/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: params.token,
|
||||
title: params.title || 'CloudSearch',
|
||||
content: params.content || '',
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.code === 200) return { success: true, message: 'PushPlus 推送成功' };
|
||||
return { success: false, message: data.msg || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
33
packages/backend/src/cloud/notifiers/qmsg.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/qmsg.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'key', label: 'API Key', type: 'password', required: true, placeholder: 'Qmsg API Key' },
|
||||
{ key: 'qq', label: 'QQ 号', type: 'text', required: false, placeholder: '留空则推送到所有绑定QQ' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
];
|
||||
|
||||
export const qmsgNotifier: Notifier = {
|
||||
name: 'qmsg',
|
||||
label: 'Qmsg (QQ)',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
msg: `[${params.title || 'CloudSearch'}]\n${params.content || ''}`,
|
||||
type: 'send',
|
||||
};
|
||||
if (params.qq) body.qq = params.qq;
|
||||
const resp = await fetch(`https://qmsg.zendee.cn/api/${params.key}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(body).toString(),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.code === 0) return { success: true, message: 'Qmsg 推送成功' };
|
||||
return { success: false, message: data.reason || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
28
packages/backend/src/cloud/notifiers/serverchan.notifier.ts
Normal file
28
packages/backend/src/cloud/notifiers/serverchan.notifier.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 SendKey' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
];
|
||||
|
||||
export const serverchanNotifier: Notifier = {
|
||||
name: 'serverchan',
|
||||
label: 'Server酱',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const sendkey = params.sendkey;
|
||||
const resp = await fetch(`https://sctapi.ftqq.com/${sendkey}.send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.code === 0) return { success: true, message: 'Server酱 推送成功' };
|
||||
return { success: false, message: data.message || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 Turbo SendKey' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
];
|
||||
|
||||
export const serverchanturboNotifier: Notifier = {
|
||||
name: 'serverchanturbo',
|
||||
label: 'Server酱 Turbo',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const resp = await fetch(`https://sctapi.ftqq.com/${params.sendkey}.send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.code === 0) return { success: true, message: 'Server酱 Turbo 推送成功' };
|
||||
return { success: false, message: data.message || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
40
packages/backend/src/cloud/notifiers/smtp.notifier.ts
Normal file
40
packages/backend/src/cloud/notifiers/smtp.notifier.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
/** SMTP 邮件推送 — 使用 nodemailer(需要安装) */
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'host', label: 'SMTP 服务器', type: 'text', required: true, placeholder: 'smtp.qq.com' },
|
||||
{ key: 'port', label: '端口', type: 'number', default: 465, required: false },
|
||||
{ key: 'secure', label: 'SSL', type: 'switch', default: true, required: false },
|
||||
{ key: 'user', label: '用户名', type: 'text', required: true, placeholder: 'user@example.com' },
|
||||
{ key: 'pass', label: '密码/授权码', type: 'password', required: true, placeholder: 'SMTP 授权码' },
|
||||
{ key: 'from', label: '发件人', type: 'text', required: true, placeholder: 'sender@example.com' },
|
||||
{ key: 'to', label: '收件人', type: 'text', required: true, placeholder: 'receiver@example.com' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
];
|
||||
|
||||
export const smtpNotifier: Notifier = {
|
||||
name: 'smtp',
|
||||
label: 'SMTP 邮件',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const nodemailer = require('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: params.host,
|
||||
port: params.port || 465,
|
||||
secure: params.secure !== false,
|
||||
auth: { user: params.user, pass: params.pass },
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
subject: `[CloudSearch] ${params.title || ''}`,
|
||||
text: params.content || '',
|
||||
});
|
||||
return { success: true, message: 'SMTP 邮件发送成功' };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
38
packages/backend/src/cloud/notifiers/telegram.notifier.ts
Normal file
38
packages/backend/src/cloud/notifiers/telegram.notifier.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC-def' },
|
||||
{ key: 'chat_id', label: 'Chat ID', type: 'text', required: true, placeholder: '@频道 或 -1001234567890' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const telegramNotifier: Notifier = {
|
||||
name: 'telegram',
|
||||
label: 'Telegram',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const iconMap: Record<string, string> = { error: '\ud83d\udea8', warn: '\u26a0\ufe0f', info: '\u2139\ufe0f' };
|
||||
const level: string = params.level || 'info';
|
||||
const text = `${iconMap[level] || iconMap.info} *${params.title || 'CloudSearch'}*\n\n${params.content || ''}`;
|
||||
|
||||
const resp = await fetch(`https://api.telegram.org/bot${params.token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: params.chat_id,
|
||||
text,
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.ok) return { success: true, message: 'Telegram 推送成功' };
|
||||
return { success: false, message: data.description || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
33
packages/backend/src/cloud/notifiers/webhook.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/webhook.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://example.com/webhook' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const webhookNotifier: Notifier = {
|
||||
name: 'webhook',
|
||||
label: '自定义 Webhook',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const resp = await fetch(params.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: params.title || 'CloudSearch',
|
||||
content: params.content || '',
|
||||
level: params.level || 'info',
|
||||
source: 'CloudSearch',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
if (resp.ok) return { success: true, message: 'Webhook 推送成功' };
|
||||
return { success: false, message: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||
|
||||
const params: NotifierParam[] = [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' },
|
||||
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||
];
|
||||
|
||||
export const wechatWorkBotNotifier: Notifier = {
|
||||
name: 'wechat_work_bot',
|
||||
label: '企业微信机器人',
|
||||
params,
|
||||
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||
try {
|
||||
const content = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`;
|
||||
const resp = await fetch(params.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: { content },
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
if (data.errcode === 0) return { success: true, message: '企业微信推送成功' };
|
||||
return { success: false, message: data.errmsg || `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
83
packages/backend/src/cloud/push-user.service.ts
Normal file
83
packages/backend/src/cloud/push-user.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// ============================================================
|
||||
// push-user.service.ts — Multi-user notification settings
|
||||
// Each "push user" has: account (linked to cloud_configs.promotion_account)
|
||||
// + notify_config (channels + events)
|
||||
// ============================================================
|
||||
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
export interface PushUser {
|
||||
id?: number;
|
||||
account: string;
|
||||
notify_config: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface PushUserRow {
|
||||
id: number;
|
||||
account: string;
|
||||
notify_config: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Get all push users */
|
||||
export function getAllPushUsers(): PushUserRow[] {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM push_users ORDER BY account ASC').all() as PushUserRow[];
|
||||
}
|
||||
|
||||
/** Get a single push user by id */
|
||||
export function getPushUserById(id: number): PushUserRow | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM push_users WHERE id = ?').get(id) as PushUserRow | undefined;
|
||||
}
|
||||
|
||||
/** Get a push user by account */
|
||||
export function getPushUserByAccount(account: string): PushUserRow | undefined {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM push_users WHERE account = ?').get(account) as PushUserRow | undefined;
|
||||
}
|
||||
|
||||
/** Create or update a push user */
|
||||
export function upsertPushUser(account: string, notifyConfig: string): PushUserRow {
|
||||
const db = getDb();
|
||||
const existing = getPushUserByAccount(account);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE push_users SET notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
|
||||
.run(notifyConfig, existing.id);
|
||||
return getPushUserById(existing.id)!;
|
||||
} else {
|
||||
const result = db.prepare('INSERT INTO push_users (account, notify_config) VALUES (?, ?)')
|
||||
.run(account, notifyConfig);
|
||||
return getPushUserById(result.lastInsertRowid as number)!;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a push user by id */
|
||||
export function updatePushUser(id: number, account: string, notifyConfig: string): PushUserRow {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE push_users SET account = ?, notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
|
||||
.run(account, notifyConfig, id);
|
||||
return getPushUserById(id)!;
|
||||
}
|
||||
|
||||
/** Delete a push user */
|
||||
export function deletePushUser(id: number): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM push_users WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find matching push user config for a given cloud config
|
||||
* Match based on: cloud_configs.promotion_account == push_users.account
|
||||
*/
|
||||
export function findPushUserForConfig(configId?: number): PushUserRow | undefined {
|
||||
if (!configId) return undefined;
|
||||
const db = getDb();
|
||||
const config = db.prepare('SELECT promotion_account FROM cloud_configs WHERE id = ?').get(configId) as any;
|
||||
if (!config || !config.promotion_account) return undefined;
|
||||
return getPushUserByAccount(config.promotion_account);
|
||||
}
|
||||
@@ -129,6 +129,14 @@ function runMigrations(db: Database.Database): void {
|
||||
save_count INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(ip_address, date, cloud_type, config_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account TEXT UNIQUE NOT NULL,
|
||||
notify_config TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
`);
|
||||
seedSystemConfigs(db);
|
||||
migrateSaveRecords(db);
|
||||
@@ -138,27 +146,6 @@ function runMigrations(db: Database.Database): void {
|
||||
}
|
||||
|
||||
/** 迁移: 给已有 save_records 表补充新列 */
|
||||
function migrateSaveRecords(db: Database.Database): void {
|
||||
const newCols: { col: string; def: string }[] = [
|
||||
{ col: 'share_pwd', def: 'TEXT' },
|
||||
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
|
||||
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
|
||||
{ col: 'error_message', def: 'TEXT' },
|
||||
{ col: 'folder_name', def: 'TEXT' },
|
||||
{ col: 'request_url', def: 'TEXT' },
|
||||
{ col: 'ip_location', def: 'TEXT' },
|
||||
{ col: 'original_folder_name', def: 'TEXT' },
|
||||
];
|
||||
for (const { col, def } of newCols) {
|
||||
try {
|
||||
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
|
||||
} catch {
|
||||
// Column already exists — ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 content_cache 表加 douban_url 列 */
|
||||
function migrateContentCache(db: Database.Database): void {
|
||||
@@ -254,6 +241,21 @@ function migrateCloudConfigs(db: Database.Database): void {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
|
||||
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||
}
|
||||
|
||||
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
|
||||
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_primary%'").get();
|
||||
if (!row6) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
|
||||
console.log('[DB] cloud_configs migration: is_primary column added');
|
||||
}
|
||||
}
|
||||
|
||||
/** 迁移: 给 save_records 表加 config_id 字段 */
|
||||
function migrateSaveRecords(db: any): void {
|
||||
if (!db.prepare("SELECT sql FROM sqlite_master WHERE name='save_records' AND sql LIKE '%config_id%'").get()) {
|
||||
db.exec("ALTER TABLE save_records ADD COLUMN config_id INTEGER DEFAULT NULL");
|
||||
console.log('[DB] save_records migration: config_id column added');
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin(db: Database.Database): void {
|
||||
@@ -267,6 +269,12 @@ function seedAdmin(db: Database.Database): void {
|
||||
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||
).run(config.adminUsername, hash);
|
||||
|
||||
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
|
||||
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE %'is_primary%'").get();
|
||||
if (!row6) {
|
||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
|
||||
console.log("[DB] cloud_configs migration: is_primary column added");
|
||||
}
|
||||
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||
}
|
||||
|
||||
@@ -316,11 +324,30 @@ function seedSystemConfigs(db: Database.Database): void {
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_whitelist_dirs', value: '[]', description: '清理白名单目录名称列表(JSON数组),这些目录不会被自动清理' },
|
||||
{ key: 'storage_refresh_interval', value: '60', description: '存储空间刷新间隔(分钟),0=不自动刷新' },
|
||||
{ key: 'cleanup_auto_refresh_storage', value: 'true', description: '启用自动刷新存储空间信息' },
|
||||
{ key: 'cleanup_verify_enabled', value: 'true', description: '定期验证网盘 Cookie 有效性(随存储刷新一起执行)' },
|
||||
{ key: 'cleanup_verify_interval', value: '30', description: 'Cookie 有效性检测间隔(分钟)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
{ key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' },
|
||||
{ key: 'quark_warning_folder_names', value: '⚠️ 网盘内除您所需资源外', description: '夸克转存后自动创建的警示文件夹名(一行一个,自动加上 ⚠️ 前缀)' },
|
||||
{ key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' },
|
||||
{ key: 'link_valid_keywords', value: '链接有效', description: 'PanSou 链接有效关键词(一行一条)' },
|
||||
{ key: 'link_invalid_keywords', value: '链接失效', description: 'PanSou 链接失效关键词和本地验证失效关键词(一行一条)' },
|
||||
// ── 消息推送 ──
|
||||
{ key: 'feishu_webhook_url', value: '', description: '飞书机器人 Webhook URL(如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)' },
|
||||
{ key: 'serverchan_key', value: '', description: 'Server酱 SendKey(https://sct.ftqq.com,推送到微信)' },
|
||||
{ key: 'bark_key', value: '', description: 'Bark 推送 Key(https://api.day.app,推送到 iOS 设备)' },
|
||||
{ key: 'bark_server', value: 'https://api.day.app', description: 'Bark 自定义服务器地址(默认 https://api.day.app)' },
|
||||
{ key: 'webhook_url', value: '', description: '自定义 Webhook URL(POST JSON: {title, content, level, source})' },
|
||||
{ key: 'telegram_bot_token', value: '', description: 'Telegram Bot Token(如 123456:ABC-DEF,用于 TG 推送)' },
|
||||
{ key: 'telegram_chat_id', value: '', description: 'Telegram 接收消息的 Chat ID(数字或 @频道名)' },
|
||||
{ key: 'notify_on_save_success', value: 'true', description: '转存成功时推送通知' },
|
||||
{ key: 'notify_on_save_fail', value: 'true', description: '转存失败时推送通知(连续失败 3 次后推送)' },
|
||||
{ key: 'notify_on_cookie_expire', value: 'true', description: 'Cookie 过期时推送通知' },
|
||||
{ key: 'notify_on_cleanup', value: 'true', description: '自动清理完成时推送通知' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
|
||||
@@ -4,7 +4,7 @@ import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import config from './config';
|
||||
import { APP_VERSION } from "./version";
|
||||
import { VERSION as APP_VERSION } from "./version";
|
||||
import { getDb } from './database/database';
|
||||
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||||
import rateLimiter from './middleware/rate-limit';
|
||||
@@ -177,10 +177,20 @@ async function start(): Promise<void> {
|
||||
setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL);
|
||||
setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000);
|
||||
|
||||
// Storage info refresh scheduler — every 60 minutes
|
||||
const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000;
|
||||
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL);
|
||||
// Storage info refresh scheduler — configurable via system_configs
|
||||
const { getSystemConfig: getSysCfg } = require('./admin/system-config.service');
|
||||
const storageRefreshEnabled = getSysCfg('cleanup_auto_refresh_storage') !== 'false';
|
||||
const storageRefreshMin = parseInt(getSysCfg('storage_refresh_interval') || '60', 10) || 60;
|
||||
const storageRefreshMs = storageRefreshMin * 60 * 1000;
|
||||
const verifyCookies = getSysCfg('cleanup_verify_enabled') === 'true';
|
||||
|
||||
if (!storageRefreshEnabled || storageRefreshMin === 0) {
|
||||
console.log(`[Storage] Auto-refresh disabled (enabled=${storageRefreshEnabled}, interval=${storageRefreshMin} min)`);
|
||||
} else {
|
||||
console.log(`[Storage] Auto-refresh every ${storageRefreshMin} minutes (verifyCookies=${verifyCookies})`);
|
||||
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, storageRefreshMs);
|
||||
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
|
||||
}
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
|
||||
|
||||
@@ -4,7 +4,9 @@ import fs from "fs";
|
||||
import { execSync } from 'child_process';
|
||||
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||
import { getSaveRecords } from '../cloud/cloud.service';
|
||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service';
|
||||
import { testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getAllNotifierParams } from '../cloud/notification.service';
|
||||
import { getAllPushUsers, getPushUserById, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service';
|
||||
// Note: check-in routes were removed (sign-in feature removed)
|
||||
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||
@@ -134,6 +136,12 @@ router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res:
|
||||
// Auth wall — all routes below require JWT
|
||||
// ═══════════════════════════════════════
|
||||
router.use('/admin', authMiddleware);
|
||||
router.use('/admin', (_req: Request, res: Response, next) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
next();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// Cloud Configs CRUD
|
||||
@@ -199,6 +207,20 @@ router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/cloud-configs/:id/primary — toggle primary status (max 2 per type)
|
||||
*/
|
||||
router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { primary } = req.body;
|
||||
const config = togglePrimary(id, !!primary);
|
||||
res.json(config);
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to toggle primary status' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -612,4 +634,104 @@ router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/admin/cloud-configs/:id/notify — get per-config notification settings */
|
||||
router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const settings = getConfigNotifySettingsJSON(id);
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to get notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/admin/cloud-configs/:id/notify — update per-config notification settings */
|
||||
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const settings = req.body;
|
||||
saveConfigNotifySettings(id, settings);
|
||||
res.json({ success: true, message: '推送配置已保存' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to save notification settings' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/notify/test — test a notification channel */
|
||||
router.post('/admin/notify/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { channelType, account, configId } = req.body;
|
||||
const ctx = account || (configId ? String(configId) : undefined);
|
||||
const result = await testChannel(channelType as string, ctx);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.json({ success: false, message: err.message || '测试发送失败' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/** GET /api/admin/notify/providers — get all available notifier providers */
|
||||
router.get('/admin/notify/providers', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const providers = getAllNotifierParams();
|
||||
res.json(providers);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to get providers' });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/admin/push-users — list all push users */
|
||||
router.get('/admin/push-users', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const users = getAllPushUsers();
|
||||
// Parse notify_config for each user for frontend display
|
||||
const parsed = users.map(u => ({
|
||||
...u,
|
||||
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
|
||||
}));
|
||||
res.json(parsed);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Failed to list push users' });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/admin/push-users — create or update a push user */
|
||||
router.post('/admin/push-users', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { account, notify_config } = req.body;
|
||||
if (!account) return res.status(400).json({ error: 'account is required' });
|
||||
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
|
||||
const user = upsertPushUser(account, configStr);
|
||||
res.json({ ...user, notify_config: JSON.parse(user.notify_config) });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to save push user' });
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/admin/push-users/:id — update push user */
|
||||
router.put('/admin/push-users/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const { account, notify_config } = req.body;
|
||||
if (!account) return res.status(400).json({ error: 'account is required' });
|
||||
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
|
||||
const user = updatePushUser(id, account, configStr);
|
||||
res.json({ ...user, notify_config: JSON.parse(user.notify_config) });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to update push user' });
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/admin/push-users/:id — delete push user */
|
||||
router.delete('/admin/push-users/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const ok = deletePushUser(id);
|
||||
if (ok) res.json({ success: true });
|
||||
else res.status(404).json({ error: 'Push user not found' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message || 'Failed to delete push user' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,12 +1 @@
|
||||
/**
|
||||
* CloudSearch 应用版本号
|
||||
*
|
||||
* 版本管理规则:
|
||||
* - 每次小优化/修复:patch +1 (0.0.1 → 0.0.2)
|
||||
* - 20 次 patch 后:minor +1, patch 归零 (0.1.0)
|
||||
* - 10 次 minor 后:major +1, minor 归零 (1.0.0)
|
||||
*
|
||||
* 修改此文件的同时请同步更新后端 package.json 中的 version 字段。
|
||||
*/
|
||||
|
||||
export const APP_VERSION = "0.0.2";
|
||||
export const VERSION = "0.2.2";
|
||||
|
||||
4
packages/frontend/package-lock.json
generated
4
packages/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cloudsearch-frontend",
|
||||
"version": "1.1.8",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudsearch-frontend",
|
||||
"version": "1.1.8",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-frontend",
|
||||
"version": "1.1.8",
|
||||
"version": "0.2.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,43 +26,107 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* =============================================
|
||||
CloudSearch Design System — Global Tokens
|
||||
============================================= */
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
/* ── Backgrounds ── */
|
||||
--bg: #f0f2f5;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #f5f7fa;
|
||||
--text: #303133;
|
||||
--text2: #909399;
|
||||
--text3: #c0c4cc;
|
||||
--border: #e4e7ed;
|
||||
--bg-page: #f0f2f5;
|
||||
--bg-card-header: linear-gradient(135deg, #f8f9fc 0%, #eef1f8 100%);
|
||||
|
||||
/* ── Text ── */
|
||||
--text: #1d2129;
|
||||
--text-secondary: #4e5969;
|
||||
--text-tertiary: #86909c;
|
||||
--text-placeholder: #c9cdd4;
|
||||
|
||||
/* ── Borders ── */
|
||||
--border: #e5e6eb;
|
||||
--border-light: #f2f3f5;
|
||||
|
||||
/* ── Primary ── */
|
||||
--primary: #409eff;
|
||||
--primary-hover: #66b1ff;
|
||||
--primary-light: rgba(64, 158, 255, 0.08);
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--hover: #f5f7fa;
|
||||
--primary-soft: #ecf5ff;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
|
||||
/* ── Radius ── */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* ── Sizing ── */
|
||||
--sidebar-w: 240px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #141414;
|
||||
--bg-card: #1f1f1f;
|
||||
--bg-card: #1d1d1d;
|
||||
--bg-input: #2a2a2a;
|
||||
--bg-page: #0f0f0f;
|
||||
--bg-card-header: linear-gradient(135deg, #1d1d1d 0%, #1a1a1a 100%);
|
||||
|
||||
--text: #e5e5e5;
|
||||
--text2: #999999;
|
||||
--text3: #666666;
|
||||
--text-secondary: #999999;
|
||||
--text-tertiary: #666666;
|
||||
--text-placeholder: #555555;
|
||||
|
||||
--border: #333333;
|
||||
--border-light: #2a2a2a;
|
||||
|
||||
--primary: #409eff;
|
||||
--primary-hover: #66b1ff;
|
||||
--primary-light: rgba(64, 158, 255, 0.15);
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
--hover: #2a2a2a;
|
||||
--primary-soft: rgba(64, 158, 255, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] body { background: var(--bg); color: var(--text); }
|
||||
[data-theme="dark"] .el-card, [data-theme="dark"] .el-dialog, [data-theme="dark"] .el-menu { background-color: var(--bg-card) !important; border-color: var(--border) !important; color: var(--text) !important; }
|
||||
[data-theme="dark"] .el-input__wrapper, [data-theme="dark"] .el-select .el-input__wrapper { background-color: var(--bg-input) !important; border-color: var(--border) !important; }
|
||||
[data-theme="dark"] .el-input__inner, [data-theme="dark"] .el-textarea__inner { background-color: var(--bg-input) !important; color: var(--text) !important; }
|
||||
[data-theme="dark"] .el-button--default { background: var(--bg-card); border-color: var(--border); color: var(--text); }
|
||||
[data-theme="dark"] .rank-panel, [data-theme="dark"] .rank-item { background: var(--bg-card); border-color: var(--border); }
|
||||
[data-theme="dark"] .panel-title, [data-theme="dark"] .rank-name, [data-theme="dark"] .card-title { color: var(--text); }
|
||||
[data-theme="dark"] .card-meta, [data-theme="dark"] .rank-cnt, [data-theme="dark"] .panel-footer span:first-child { color: var(--text2); }
|
||||
[data-theme="dark"] .site-footer { background: var(--bg-card); border-color: var(--border); color: var(--text2); }
|
||||
[data-theme="dark"] .el-card,
|
||||
[data-theme="dark"] .el-dialog,
|
||||
[data-theme="dark"] .el-menu,
|
||||
[data-theme="dark"] .el-table,
|
||||
[data-theme="dark"] .el-select-dropdown,
|
||||
[data-theme="dark"] .el-popover {
|
||||
--el-bg-color: var(--bg-card) !important;
|
||||
--el-border-color: var(--border) !important;
|
||||
--el-text-color-primary: var(--text) !important;
|
||||
}
|
||||
[data-theme="dark"] .el-dialog__body,
|
||||
[data-theme="dark"] .el-table__body td {
|
||||
background-color: var(--bg-card) !important;
|
||||
}
|
||||
[data-theme="dark"] .el-table__header th {
|
||||
background-color: #262626 !important;
|
||||
}
|
||||
[data-theme="dark"] .el-input__wrapper,
|
||||
[data-theme="dark"] .el-select .el-input__wrapper {
|
||||
background-color: var(--bg-input) !important;
|
||||
border-color: var(--border) !important;
|
||||
box-shadow: 0 0 0 1px var(--border) inset !important;
|
||||
}
|
||||
[data-theme="dark"] .el-input__inner,
|
||||
[data-theme="dark"] .el-textarea__inner {
|
||||
background-color: var(--bg-input) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
[data-theme="dark"] .el-button--default {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
@@ -79,10 +143,51 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.theme-toggle:hover { transform: scale(1.1); border-color: var(--primary); }
|
||||
|
||||
#app { min-height: 100vh; background: var(--bg); color: var(--text); }
|
||||
#app { min-height: 100vh; background: var(--bg-page); color: var(--text); }
|
||||
|
||||
/* ── Global element-plus overrides ── */
|
||||
.el-card {
|
||||
border-radius: var(--radius-lg) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
.el-card:hover {
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
.el-card__header {
|
||||
padding: 16px 20px !important;
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
background: var(--bg-card-header);
|
||||
}
|
||||
.el-card__body {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
/* ── Typography helpers ── */
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.section-divider {
|
||||
border-top: 1px solid var(--border-light);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* ── Fade transition ── */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -267,6 +267,14 @@ export async function deleteCloudConfig(
|
||||
await api.delete(`/admin/cloud-configs/${id}`)
|
||||
}
|
||||
|
||||
export async function setPrimary(
|
||||
id: number,
|
||||
primary: boolean
|
||||
): Promise<any> {
|
||||
const { data } = await api.put(`/admin/cloud-configs/${id}/primary`, { primary })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getStats(days?: number): Promise<StatsData> {
|
||||
const params: Record<string, number> = {}
|
||||
if (days) params.days = days
|
||||
@@ -312,6 +320,38 @@ export interface SaveRecord {
|
||||
}
|
||||
|
||||
// ===== 系统配置 =====
|
||||
|
||||
/** Save/update per-config notification settings */
|
||||
export async function saveConfigNotify(
|
||||
configId: number,
|
||||
settings: Record<string, any>
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { data } = await api.put(`/admin/cloud-configs/${configId}/notify`, settings)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Get per-config notification settings */
|
||||
export async function getConfigNotify(
|
||||
configId: number
|
||||
): Promise<Record<string, any>> {
|
||||
const { data } = await api.get(`/admin/cloud-configs/${configId}/notify`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Test a notification channel (global or per-config) */
|
||||
export async function getAllNotifierProviders(): Promise<Record<string, { name: string; label: string; params: { key: string; label: string; type: string; required: boolean; placeholder?: string; default?: any }[] }>> {
|
||||
const { data } = await api.get('/admin/notify/providers')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testNotifyChannel(
|
||||
channelType: string,
|
||||
configId?: number
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { data } = await api.post('/admin/notify/test', { channelType, configId })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
|
||||
const { data } = await api.get('/admin/system-configs')
|
||||
return data
|
||||
|
||||
@@ -7,17 +7,20 @@
|
||||
<div class="dash-row dash-row-stats">
|
||||
<el-card
|
||||
v-for="item in statItems" :key="item.key"
|
||||
class="stat-card stt-card"
|
||||
class="stat-card"
|
||||
shadow="never"
|
||||
>
|
||||
<div class="stt-label">{{ item.label }}</div>
|
||||
<div class="stt-value">{{ (stats as any)[item.key] ?? 0 }}</div>
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-value">{{ (stats as any)[item.key] ?? 0 }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: 网盘使用空间 — flex-wrap 自适应 -->
|
||||
<!-- Row 2: 网盘使用空间 -->
|
||||
<div class="dash-row">
|
||||
<el-card class="storage-section-card">
|
||||
<template #header><span>💾 网盘使用空间</span></template>
|
||||
<el-card class="storage-card" shadow="never">
|
||||
<template #header>
|
||||
<span>💾 网盘存储空间</span>
|
||||
</template>
|
||||
<div class="storage-grid">
|
||||
<div
|
||||
v-for="item in stats.cloudUsage" :key="item.cloudType + '-' + (item.nickname || '')"
|
||||
@@ -37,21 +40,21 @@
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="cloudStoragePercent(item)"
|
||||
:stroke-width="12"
|
||||
:stroke-width="10"
|
||||
:color="cloudStoragePercent(item) > 80 ? '#f56c6c' : cloudStoragePercent(item) > 60 ? '#e6a23c' : '#67c23a'"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="80" />
|
||||
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="72" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: 近 7 天趋势 + 热门搜索关键词 Top 20 + 操作来源 IP Top 10 -->
|
||||
<div class="dash-row dash-row-cols-3">
|
||||
<el-card class="insight-card trend-card">
|
||||
<el-card class="insight-card trend-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="insight-header">
|
||||
<span>📈 网站使用趋势图</span>
|
||||
<span>📈 使用趋势</span>
|
||||
<div class="trend-day-btns">
|
||||
<button
|
||||
v-for="d in trendDayOptions" :key="d"
|
||||
@@ -82,8 +85,8 @@
|
||||
<div ref="chartRef" class="trend-chart-echarts"></div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="insight-card">
|
||||
<template #header><span>🔍 热门搜索关键词 Top 20</span></template>
|
||||
<el-card class="insight-card" shadow="never">
|
||||
<template #header><span>🔍 热门搜索 Top 20</span></template>
|
||||
<div class="keyword-list">
|
||||
<el-tag
|
||||
v-for="(kw, i) in stats.hotKeywords" :key="kw.keyword"
|
||||
@@ -91,11 +94,11 @@
|
||||
size="small"
|
||||
class="keyword-tag"
|
||||
>{{ kw.keyword }}<sup class="kw-count">{{ kw.count }}</sup></el-tag>
|
||||
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无搜索数据" :image-size="60" />
|
||||
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无搜索数据" :image-size="56" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="insight-card">
|
||||
<el-card class="insight-card" shadow="never">
|
||||
<template #header>
|
||||
<el-tabs v-model="rightTab" class="card-tabs" @tab-click="handleTabClick">
|
||||
<el-tab-pane label="🌐 IP Top 10" name="ip" />
|
||||
@@ -109,7 +112,7 @@
|
||||
<span class="ip-loc" v-if="ip.ip_location">{{ formatLocation(ip.ip_location) }}</span>
|
||||
<span class="ip-count">{{ ip.count }} 次</span>
|
||||
</div>
|
||||
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="60" />
|
||||
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="56" />
|
||||
</div>
|
||||
<div class="province-list" v-show="rightTab === 'province'">
|
||||
<div
|
||||
@@ -130,16 +133,18 @@
|
||||
<span class="province-name">{{ item.province }}</span>
|
||||
<span class="province-count">{{ item.count }} 次</span>
|
||||
</div>
|
||||
<el-empty v-if="!stats.provinceRankings?.length" description="暂无数据" :image-size="60" />
|
||||
<el-empty v-if="!stats.provinceRankings?.length" description="暂无数据" :image-size="56" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网盘配置 - 设置及授权 -->
|
||||
<!-- 网盘设置及授权 -->
|
||||
<div v-if="showCloudToggle" class="section-content">
|
||||
<el-card class="config-card">
|
||||
<template #header><span>📂 网盘设置及授权</span></template>
|
||||
<el-card class="config-card" shadow="never">
|
||||
<template #header>
|
||||
<span>📂 网盘类型开关</span>
|
||||
</template>
|
||||
<div class="cloud-toggle-grid">
|
||||
<div
|
||||
v-for="ct in cloudTypes"
|
||||
@@ -163,7 +168,6 @@
|
||||
<CloudConfig />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<div v-if="showSystemConfig" class="section-content">
|
||||
<SystemConfig :section="activeSystemSection" />
|
||||
@@ -221,6 +225,8 @@ const siteName = ref('')
|
||||
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||
|
||||
// ── ECharts trend chart (via composable) ──
|
||||
/** ── Trend summary (MUST be declared before useTrendChart callback) ── */
|
||||
|
||||
const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s })
|
||||
const chartRef = _chartRef as any
|
||||
|
||||
@@ -233,14 +239,11 @@ watch(() => stats.value.trendTrend, () => {
|
||||
watch(() => activeMenu.value, (val, oldVal) => {
|
||||
if (val === 'dashboard' && oldVal !== 'dashboard') {
|
||||
nextTick(() => {
|
||||
// v-show keeps DOM alive; chart instance may need to be re-initialized
|
||||
// after being hidden (ECharts can lose its canvas when container has display:none)
|
||||
const el = document.querySelector('.trend-chart-echarts') as HTMLElement | null
|
||||
if (el && el.childElementCount === 0) {
|
||||
renderTrendChart()
|
||||
initTrendChart()
|
||||
} else if (el) {
|
||||
// Chart still exists, just resize
|
||||
initTrendChart()
|
||||
}
|
||||
})
|
||||
@@ -384,7 +387,7 @@ const pageTitle = computed(() => {
|
||||
return '系统配置 — ' + (sysSectionTitles[activeMenu.value] || '')
|
||||
}
|
||||
if (activeMenu.value === 'cloud-configs-toggle' || activeMenu.value === 'cloud-configs-cleanup') {
|
||||
return '网盘配置'
|
||||
return '网盘管理'
|
||||
}
|
||||
return pageTitles[activeMenu.value] || '管理后台'
|
||||
})
|
||||
@@ -481,218 +484,60 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.section-content { }
|
||||
|
||||
/* === 左侧菜单:深色渐变背景 === */
|
||||
.admin-menu {
|
||||
width: 220px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
border-right: none;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.admin-menu :deep(.el-menu-item) {
|
||||
color: #a2a3b7;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-menu :deep(.el-menu-item)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #409eff, #7c3aed);
|
||||
transform: scaleY(0);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
.admin-menu :deep(.el-menu-item.is-active) {
|
||||
color: #409eff;
|
||||
background: linear-gradient(90deg, rgba(64, 158, 255, 0.15) 0%, rgba(124, 58, 237, 0.08) 100%);
|
||||
}
|
||||
.admin-menu :deep(.el-menu-item.is-active)::after {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
.admin-menu :deep(.el-menu-item:hover) {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* Sub-menu styles */
|
||||
.admin-menu :deep(.el-sub-menu__title) {
|
||||
color: #a2a3b7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.admin-menu :deep(.el-sub-menu__title:hover) {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.02) 100%);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.admin-menu :deep(.el-sub-menu.is-active .el-sub-menu__title) {
|
||||
color: #409eff;
|
||||
}
|
||||
.admin-menu :deep(.el-sub-menu .el-menu) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.admin-menu :deep(.el-sub-menu .el-menu .el-menu-item) {
|
||||
padding-left: 56px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu-header {
|
||||
padding: 24px 20px 16px;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.menu-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
background: linear-gradient(90deg, #fff, #90caf9);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.menu-header p {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.version-footer {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 0;
|
||||
width: 220px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
letter-spacing: 0.5px;
|
||||
padding: 8px 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* === 右侧内容区 === */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: none;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.content-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.content-body {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* === 网盘类型开关 — 网格布局 === */
|
||||
.config-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.config-card :deep(.el-card__header) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding: 16px 20px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #fafafa, #f5f5f5);
|
||||
}
|
||||
.config-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
.cloud-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.cloud-toggle-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #eee;
|
||||
transition: background 0.2s, box-shadow 0.15s;
|
||||
}
|
||||
.cloud-toggle-chip:hover {
|
||||
background: #f0f2f5;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||||
}
|
||||
.cloud-icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
.cloud-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* === 仪表盘布局 === */
|
||||
.dash-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
/* ── Row 1: 统计卡片 ── */
|
||||
.dash-row { margin-bottom: 20px; }
|
||||
.dash-row-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1200px) { .dash-row-stats { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 700px) { .dash-row-stats { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* 统计卡片 — 文字在上,数字在下 */
|
||||
.stt-card {
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
border: none !important;
|
||||
background: linear-gradient(135deg, #f0f5ff, #e8f0fe);
|
||||
border-radius: 14px;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stt-card:hover {
|
||||
/* 统计卡片左侧装饰条 — 不同颜色区分 */
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
border-radius: 14px 0 0 14px;
|
||||
}
|
||||
.stat-card:nth-child(1)::before { background: linear-gradient(180deg, #409eff, #79bbff); }
|
||||
.stat-card:nth-child(2)::before { background: linear-gradient(180deg, #67c23a, #95d475); }
|
||||
.stat-card:nth-child(3)::before { background: linear-gradient(180deg, #e6a23c, #f3d19e); }
|
||||
.stat-card:nth-child(4)::before { background: linear-gradient(180deg, #7c3aed, #a78bfa); }
|
||||
.stat-card:nth-child(5)::before { background: linear-gradient(180deg, #f56c6c, #f89898); }
|
||||
.stat-card:nth-child(6)::before { background: linear-gradient(180deg, #36cfc9, #6fe0d9); }
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.15);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08) !important;
|
||||
}
|
||||
.stt-card :deep(.el-card__body) {
|
||||
padding: 20px 12px 16px;
|
||||
.stat-card :deep(.el-card__body) {
|
||||
padding: 22px 14px 18px !important;
|
||||
}
|
||||
.stt-label {
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.stt-value {
|
||||
font-size: 32px;
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #409eff, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
@@ -702,26 +547,35 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* === 第二排:3 列洞察卡片 === */
|
||||
/* ── Row 2: 三列洞察卡片 ── */
|
||||
.dash-row-cols-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1100px) { .dash-row-cols-3 { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 700px) { .dash-row-cols-3 { grid-template-columns: 1fr; } }
|
||||
|
||||
.insight-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border) !important;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.insight-card :deep(.el-card__header) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding: 14px 18px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, #fafafa, #f5f5f5);
|
||||
.insight-card:hover {
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
.insight-card :deep(.el-card__body) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.insight-card.trend-card :deep(.el-card__body) {
|
||||
padding: 8px 6px 0 !important;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.insight-card:not(.trend-card) :deep(.el-card__body) {
|
||||
padding: 14px 18px !important;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.insight-header {
|
||||
display: flex;
|
||||
@@ -734,80 +588,63 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
}
|
||||
.trend-day-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.trend-day-btn:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.trend-day-btn.active {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.insight-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
.insight-card.trend-card :deep(.el-card__body) {
|
||||
padding: 8px 6px 0;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.insight-card:not(.trend-card) :deep(.el-card__body) {
|
||||
padding: 14px 18px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ECharts 趋势图容器 — 铺满整个卡片 */
|
||||
/* ECharts */
|
||||
.trend-chart-echarts {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 310px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* 趋势汇总行 */
|
||||
/* Trend summary */
|
||||
.trend-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 12px 4px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.trend-summary-item {
|
||||
text-align: center;
|
||||
padding: 12px 14px 6px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.trend-summary-item { text-align: center; }
|
||||
.trend-summary-num {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.trend-summary-desc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 关键词列表 */
|
||||
/* Keywords */
|
||||
.keyword-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.keyword-tag {
|
||||
cursor: default;
|
||||
}
|
||||
.keyword-tag { cursor: default; }
|
||||
.kw-count {
|
||||
font-size: 9px;
|
||||
margin-left: 2px;
|
||||
@@ -815,7 +652,7 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* IP 列表 */
|
||||
/* IP list */
|
||||
.ip-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -826,12 +663,12 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ip-row:hover {
|
||||
background: #f0f2f5;
|
||||
background: var(--border-light);
|
||||
}
|
||||
.ip-rank {
|
||||
width: 20px;
|
||||
@@ -847,9 +684,9 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ip-addr {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -861,19 +698,19 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
color: #409eff;
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ip-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 地域使用榜(省份排行) */
|
||||
/* Province */
|
||||
.province-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -884,11 +721,11 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
background: #f8f9fa;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
}
|
||||
.province-row:hover {
|
||||
background: #f0f2f5;
|
||||
background: var(--border-light);
|
||||
}
|
||||
.province-rank {
|
||||
width: 20px;
|
||||
@@ -906,7 +743,7 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
.province-bar-wrap {
|
||||
flex: 1;
|
||||
height: 16px;
|
||||
background: #e8e8e8;
|
||||
background: var(--border-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
@@ -920,7 +757,7 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
}
|
||||
.province-name {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 90px;
|
||||
@@ -929,45 +766,46 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
}
|
||||
.province-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* === 第三排:网盘空间 — flex-wrap 自适应 === */
|
||||
.storage-section-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06) !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.storage-section-card :deep(.el-card__header) {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
padding: 14px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, #fafafa, #f5f5f5);
|
||||
}
|
||||
.storage-section-card :deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
/* ── Storage section ── */
|
||||
.storage-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
.storage-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
.storage-drive-card {
|
||||
flex: 1 1 280px;
|
||||
min-width: 260px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
background: #fafbfc;
|
||||
transition: box-shadow 0.2s;
|
||||
background: var(--bg-card);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.storage-drive-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #409eff, #7c3aed);
|
||||
}
|
||||
.storage-drive-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.drive-header {
|
||||
display: flex;
|
||||
@@ -976,8 +814,8 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.drive-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
@@ -985,7 +823,7 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
.drive-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -998,74 +836,54 @@ async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drive-status.active { background: #f0f9eb; color: #67c23a; }
|
||||
.drive-status.inactive { background: #fef0f0; color: #f56c6c; }
|
||||
.drive-status.inactive { background: var(--bg); color: var(--text-tertiary); }
|
||||
.drive-space {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.drive-used {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.drive-sep {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.drive-total {
|
||||
color: #909399;
|
||||
gap: 3px;
|
||||
}
|
||||
.drive-used { color: var(--text); font-weight: 600; }
|
||||
.drive-sep { color: var(--border); }
|
||||
.drive-total { color: var(--text-secondary); font-weight: 500; }
|
||||
|
||||
/* Section content scroll */
|
||||
.section-content {
|
||||
min-height: 400px;
|
||||
/* ── Cloud toggle ── */
|
||||
.config-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Tabs switcher inside card header */
|
||||
.insight-card :deep(.card-tabs) {
|
||||
margin-top: -10px;
|
||||
.config-card :deep(.el-card__header) {
|
||||
background: var(--bg-card-header);
|
||||
}
|
||||
.insight-card :deep(.card-tabs .el-tabs__header) {
|
||||
margin: 0;
|
||||
.cloud-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.insight-card :deep(.card-tabs .el-tabs__nav-wrap::after) {
|
||||
height: 0;
|
||||
.cloud-toggle-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.2s, box-shadow 0.15s;
|
||||
}
|
||||
.insight-card :deep(.card-tabs .el-tabs__item) {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
font-size: 13px;
|
||||
padding: 0 12px;
|
||||
.cloud-toggle-chip:hover {
|
||||
background: var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.insight-card :deep(.card-tabs .el-tabs__active-bar) {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Province rows in narrow card */
|
||||
.province-list .province-row {
|
||||
gap: 4px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.province-list .province-rank {
|
||||
.cloud-icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
.province-list .province-bar-wrap {
|
||||
height: 12px;
|
||||
}
|
||||
.province-list .province-name {
|
||||
width: 60px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.province-list .province-count {
|
||||
width: 42px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Remove old tab styles */
|
||||
.admin-tabs :deep(.el-tabs__header) {
|
||||
display: none;
|
||||
.cloud-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,76 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-logo">☁️</div>
|
||||
<div class="sidebar-brand-text">
|
||||
<h2>{{ siteName || 'CloudSearch' }}</h2>
|
||||
<p>管理控制台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="admin-menu"
|
||||
class="sidebar-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<div class="menu-header">
|
||||
<h2>{{ siteName || 'CloudSearch' }}</h2>
|
||||
<p>管理后台</p>
|
||||
</div>
|
||||
<el-menu-item index="dashboard">
|
||||
<el-icon><DataBoard /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="cloud-configs">
|
||||
<template #title>
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>网盘配置</span>
|
||||
<span>网盘管理</span>
|
||||
</template>
|
||||
<el-menu-item index="cloud-configs-toggle">网盘设置及授权</el-menu-item>
|
||||
<el-menu-item index="cloud-configs-cleanup">存储清理</el-menu-item>
|
||||
<el-menu-item index="cloud-configs-toggle">📋 设置及授权</el-menu-item>
|
||||
<el-menu-item index="cloud-configs-cleanup">🧹 存储清理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="system">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统配置</span>
|
||||
<span>系统设置</span>
|
||||
</template>
|
||||
<el-menu-item index="sys-site">网站设置</el-menu-item>
|
||||
<el-menu-item index="sys-services">外部服务和缓存</el-menu-item>
|
||||
<el-menu-item index="sys-strategy">性能配置</el-menu-item>
|
||||
<el-menu-item index="sys-password">修改管理员密码</el-menu-item>
|
||||
<el-menu-item index="sys-site">🌐 网站设置</el-menu-item>
|
||||
<el-menu-item index="sys-services">🔗 外部服务 & 缓存</el-menu-item>
|
||||
<el-menu-item index="sys-strategy">⚡ 性能配置</el-menu-item>
|
||||
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
|
||||
<el-menu-item index="sys-notify">📬 消息推送</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item index="save-records">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>转存日志</span>
|
||||
</el-menu-item>
|
||||
<div class="version-footer">T {{ appVersion }}</div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
<div class="sidebar-version">v{{ appVersion }}</div>
|
||||
|
||||
<el-menu-item index="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出登录</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
</aside>
|
||||
|
||||
<div class="admin-content">
|
||||
<div class="content-header">
|
||||
<h2>{{ pageTitle }}</h2>
|
||||
<el-button text @click="goBackHome">返回前台</el-button>
|
||||
<header class="content-header">
|
||||
<div class="content-breadcrumb">
|
||||
<span class="breadcrumb-current">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="content-body">
|
||||
<div class="content-actions">
|
||||
<el-button text size="small" @click="goBackHome">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回前台
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content-body">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,7 +78,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { DataBoard, Connection, Setting, SwitchButton, DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { DataBoard, Connection, Setting, SwitchButton, DocumentCopy, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { getSiteConfig } from '../../api'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -75,6 +95,7 @@ const pageTitles: Record<string, string> = {
|
||||
'sys-services': '外部服务 & 缓存',
|
||||
'sys-strategy': '性能配置',
|
||||
'sys-password': '修改管理员密码',
|
||||
'sys-notify': '消息推送',
|
||||
'save-records': '转存日志',
|
||||
}
|
||||
|
||||
@@ -129,14 +150,163 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-menu .menu-header { padding: 16px 20px 8px; text-align: center; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.admin-menu .menu-header h2 { margin: 0; font-size: 16px; color: var(--el-color-primary); }
|
||||
.admin-menu .menu-header p { margin: 4px 0 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.version-footer { padding: 8px; text-align: center; font-size: 11px; color: var(--el-text-color-placeholder); border-top: 1px solid var(--el-border-color-light); margin-top: auto; }
|
||||
.admin-layout { display: flex; height: 100vh; }
|
||||
.admin-menu { width: 220px; flex-shrink: 0; display: flex; flex-direction: column; }
|
||||
.admin-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.content-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; border-bottom: 1px solid var(--el-border-color-light); background: var(--el-bg-color); }
|
||||
.content-header h2 { margin: 0; font-size: 18px; }
|
||||
.content-body { flex: 1; overflow-y: auto; padding: 20px 24px; background: var(--el-bg-color-page); }
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.admin-sidebar {
|
||||
width: var(--sidebar-w);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #111827 0%, #1e293b 100%);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sidebar-logo {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-brand-text h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-brand-text p {
|
||||
font-size: 11px;
|
||||
margin: 2px 0 0;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ── Menu ── */
|
||||
.sidebar-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent !important;
|
||||
border-right: none !important;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.sidebar-menu :deep(.el-menu-item),
|
||||
.sidebar-menu :deep(.el-sub-menu__title) {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
transition: all 0.2s ease;
|
||||
margin: 0 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.sidebar-menu :deep(.el-menu-item):hover,
|
||||
.sidebar-menu :deep(.el-sub-menu__title):hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(64, 158, 255, 0.25) 0%, rgba(99, 102, 241, 0.15) 100%);
|
||||
font-weight: 500;
|
||||
}
|
||||
.sidebar-menu :deep(.el-menu-item)::after {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-menu :deep(.el-sub-menu .el-menu) {
|
||||
background: rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
.sidebar-menu :deep(.el-sub-menu .el-menu .el-menu-item) {
|
||||
padding-left: 52px !important;
|
||||
font-size: 13px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
}
|
||||
.sidebar-menu :deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar-version {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
padding: 8px 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Content Area ── */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 28px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.content-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.breadcrumb-current {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.content-actions :deep(.el-button) {
|
||||
color: var(--text-secondary);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
/* ── Global page save bar ── */
|
||||
.content-body :deep(.save-bar) {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
z-index: 100;
|
||||
background: var(--bg-card);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
.content-body :deep(.save-bar:hover) {
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<div class="login-bg-pattern"></div>
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">{{ siteName || 'CloudSearch' }} 管理后台</h1>
|
||||
<div class="login-brand">
|
||||
<div class="login-logo">☁️</div>
|
||||
<h1 class="login-title">{{ siteName || 'CloudSearch' }}</h1>
|
||||
<p class="login-subtitle">管理后台</p>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" size="large" @keyup.enter="handleLogin">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
@@ -11,17 +16,18 @@
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
|
||||
登录
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
<p class="login-footer">CloudSearch v{{ appVersion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSiteConfig, adminLogin } from '../../api'
|
||||
@@ -31,8 +37,8 @@ const formRef = ref<InstanceType<typeof ElForm>>()
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const siteName = ref('')
|
||||
const appVersion = ref('')
|
||||
|
||||
// 获取网站名称
|
||||
getSiteConfig().then(cfg => {
|
||||
if (cfg.site_name) siteName.value = cfg.site_name
|
||||
}).catch(() => {})
|
||||
@@ -64,6 +70,14 @@ async function handleLogin() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const h = await fetch('/health')
|
||||
const hv = await h.json()
|
||||
appVersion.value = hv.version || ''
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -72,29 +86,84 @@ async function handleLogin() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-bg-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 50%, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 30%, rgba(118, 75, 162, 0.12) 0%, transparent 50%),
|
||||
radial-gradient(circle at 50% 80%, rgba(64, 158, 255, 0.08) 0%, transparent 50%);
|
||||
}
|
||||
.login-card {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: var(--bg-white);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
padding: 48px 40px 36px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.login-logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
margin-bottom: 32px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #1d2129;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.error-msg {
|
||||
text-align: center;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fef0f0;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #c9cdd4;
|
||||
font-size: 11px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-card {
|
||||
background: rgba(29, 29, 29, 0.95);
|
||||
}
|
||||
[data-theme="dark"] .login-title {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
[data-theme="dark"] .login-subtitle {
|
||||
color: #666666;
|
||||
}
|
||||
[data-theme="dark"] .error-msg {
|
||||
background: rgba(245, 108, 108, 0.12);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,53 +2,108 @@
|
||||
<div class="cleanup-section">
|
||||
<el-card class="config-card">
|
||||
<template #header><span>🧹 存储清理</span></template>
|
||||
<el-form label-width="160px" label-position="left" size="small">
|
||||
<el-form-item label="启用自动清理">
|
||||
<el-switch v-model="cleanupEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">
|
||||
每天自动检查一次,将过期文件移入回收站、删除旧日志、清空回收站释放空间
|
||||
<div class="cleanup-grid">
|
||||
<!-- 列1: ⏱ 基础清理策略 -->
|
||||
<div class="cleanup-group">
|
||||
<div class="cleanup-group-label">⏱ 基础清理策略</div>
|
||||
<el-form label-width="120px" label-position="left" size="small">
|
||||
<el-form-item label="自动清理">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<el-switch v-model="cleanupEnabled" size="small" />
|
||||
<span class="cleanup-hint">每天自动检查一次,删除过期日志、移入回收站文件</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="云盘文件保留天数">
|
||||
<el-input-number v-model="cleanupFileRetentionDays" :min="1" :max="365" style="width: 140px" />
|
||||
<div class="form-tip" style="margin-left: 8px;">超过此天数的日期文件夹将被移入回收站</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="转存日志保留天数">
|
||||
<el-input-number v-model="cleanupLogRetentionDays" :min="1" :max="365" style="width: 140px" />
|
||||
<div class="form-tip" style="margin-left: 8px;">超过此天数的转存记录将被删除</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="清空回收站">
|
||||
<el-switch v-model="cleanupEmptyTrash" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">移入回收站后自动清空,永久删除文件以释放存储空间</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<el-switch v-model="cleanupEmptyTrash" size="small" />
|
||||
<span class="cleanup-hint">清理时一并清空各网盘回收站</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">空间阈值自动清理</el-divider>
|
||||
<el-form-item label="启用空间阈值清理">
|
||||
<el-switch v-model="cleanupSpaceThresholdEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">已用空间超过阈值时,按比例删除最旧的转存文件(优先级高于保留天数)</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="使用阈值">
|
||||
<el-slider v-model="cleanupSpaceThresholdPercent" :min="50" :max="99" style="width: 200px" show-input />
|
||||
<div class="form-tip" style="margin-left: 8px;">已用空间超过此百分比时触发强制清理</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="删除比例">
|
||||
<el-slider v-model="cleanupSpaceThresholdDeletePercent" :min="5" :max="50" :step="5" style="width: 200px" show-input />
|
||||
<div class="form-tip" style="margin-left: 8px;">触发清理时释放总空间的百分比(如 10% 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)</div>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">分享链接复用</el-divider>
|
||||
<el-form-item label="复用已有分享链接">
|
||||
<el-switch v-model="saveReuseEnabled" active-text="启用" inactive-text="关闭" />
|
||||
<div class="form-tip" style="margin-left: 8px;">相同原始链接不再重复转存,复用已有分享链接(会验证原链接有效性;60秒内重复请求直接返回已有链接)</div>
|
||||
<el-form-item label="白名单目录">
|
||||
<div style="width: 100%;">
|
||||
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;" v-if="whitelistDirs.length">
|
||||
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<el-input v-model="newWhitelistDir" placeholder="输入目录名" size="small" style="width: 160px" @keyup.enter="addWhitelistDir" />
|
||||
<el-button type="primary" size="small" @click="addWhitelistDir">添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-divider content-position="left">手动操作</el-divider>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<el-button type="primary" size="small" :loading="cleanupSaving" @click="handleSaveCleanupConfigs">💾 保存清理配置</el-button>
|
||||
<el-button type="danger" size="small" :loading="cleanupRunning" @click="handleRunCleanup">{{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }}</el-button>
|
||||
<el-button type="warning" size="small" :loading="emptyTrashRunning" @click="handleEmptyTrash">{{ emptyTrashRunning ? '清空中...' : '🧹 立即清空回收站' }}</el-button>
|
||||
</div>
|
||||
<div v-if="lastCleanupTime" class="cleanup-info" style="margin-top: 10px;">
|
||||
<span>⏰ 上次清理:{{ lastCleanupTime }}</span>
|
||||
<span v-if="lastCleanupStats" style="margin-left: 16px;">📊 {{ lastCleanupStats }}</span>
|
||||
|
||||
<!-- 列2: 📦 保留设置 -->
|
||||
<div class="cleanup-group">
|
||||
<div class="cleanup-group-label">📦 保留设置</div>
|
||||
<el-form label-width="120px" label-position="left" size="small">
|
||||
<el-form-item label="文件保留">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-input-number v-model="cleanupFileRetentionDays" :min="1" :max="365" style="width: 100px" size="small" />
|
||||
<span>天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志保留">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-input-number v-model="cleanupLogRetentionDays" :min="1" :max="365" style="width: 100px" size="small" />
|
||||
<span>天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="Cookie检测">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-input-number v-model="verifyIntervalMinutes" :min="5" :max="1440" :step="5" style="width: 100px" size="small" />
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="空间校准">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-input-number v-model="storageRefreshIntervalMinutes" :min="5" :max="1440" :step="5" style="width: 100px" size="small" />
|
||||
<span>分钟</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 列3: 📊 空间阈值自动清理 -->
|
||||
<div class="cleanup-group">
|
||||
<div class="cleanup-group-label">📊 空间阈值自动清理</div>
|
||||
<el-form label-width="120px" label-position="left" size="small">
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="cleanupSpaceThresholdEnabled" size="small" />
|
||||
<span class="cleanup-hint" style="margin-left: 8px;">已用空间超过阈值时,按比例删除最旧的转存文件</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="使用阈值">
|
||||
<el-slider v-model="cleanupSpaceThresholdPercent" :min="50" :max="99" style="width: 140px" show-input size="small" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="cleanupSpaceThresholdEnabled" label="删除比例">
|
||||
<el-slider v-model="cleanupSpaceThresholdDeletePercent" :min="5" :max="50" :step="5" style="width: 140px" show-input size="small" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 列4: 🔗 分享链接复用 -->
|
||||
<div class="cleanup-group">
|
||||
<div class="cleanup-group-label">🔗 分享链接复用</div>
|
||||
<el-form label-width="120px" label-position="left" size="small">
|
||||
<el-form-item label="复用">
|
||||
<el-switch v-model="saveReuseEnabled" size="small" />
|
||||
<span class="cleanup-hint" style="margin-left: 8px;">相同原始链接不再重复转存,复用已有分享链接(会自动验证原链接有效性;60秒内重复请求直接返回已有链接)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:手动操作(跨列全宽) -->
|
||||
<div class="cleanup-actions">
|
||||
<div class="cleanup-actions-buttons">
|
||||
<el-button type="primary" :loading="cleanupSaving" @click="handleSaveCleanupConfigs">💾 保存清理配置</el-button>
|
||||
<el-button type="danger" :loading="cleanupRunning" @click="handleRunCleanup">{{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }}</el-button>
|
||||
<el-button type="warning" :loading="emptyTrashRunning" @click="handleEmptyTrash">{{ emptyTrashRunning ? '清空中...' : '🧹 清空回收站' }}</el-button>
|
||||
</div>
|
||||
<div v-if="lastCleanupTime" class="cleanup-info">
|
||||
⏰ 上次清理:{{ lastCleanupTime }}
|
||||
<span v-if="lastCleanupStats" style="margin-left: 12px;">📊 {{ lastCleanupStats }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -116,12 +171,51 @@ const saveReuseEnabled = computed({
|
||||
set: (val: boolean) => { sysConfigs.save_reuse_enabled = val ? 'true' : 'false' },
|
||||
})
|
||||
|
||||
// 白名单
|
||||
const whitelistDirs = ref<string[]>([])
|
||||
const newWhitelistDir = ref('')
|
||||
|
||||
function loadWhitelistDirs() {
|
||||
try {
|
||||
const raw = String(sysConfigs.cleanup_whitelist_dirs || '[]')
|
||||
whitelistDirs.value = JSON.parse(raw)
|
||||
} catch {
|
||||
whitelistDirs.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function addWhitelistDir() {
|
||||
const name = newWhitelistDir.value.trim()
|
||||
if (!name) return
|
||||
if (whitelistDirs.value.includes(name)) {
|
||||
ElMessage.warning('该目录已在白名单中')
|
||||
return
|
||||
}
|
||||
whitelistDirs.value.push(name)
|
||||
newWhitelistDir.value = ''
|
||||
}
|
||||
|
||||
function removeWhitelistDir(index: number) {
|
||||
whitelistDirs.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Cookie检测间隔 + 空间校准间隔
|
||||
const verifyIntervalMinutes = computed({
|
||||
get: () => Number(sysConfigs.cleanup_verify_interval ?? 30),
|
||||
set: (val: number) => { sysConfigs.cleanup_verify_interval = val },
|
||||
})
|
||||
const storageRefreshIntervalMinutes = computed({
|
||||
get: () => Number(sysConfigs.storage_refresh_interval ?? 180),
|
||||
set: (val: number) => { sysConfigs.storage_refresh_interval = val },
|
||||
})
|
||||
|
||||
async function loadCleanupConfigs() {
|
||||
try {
|
||||
const raw = await getSystemConfigs()
|
||||
for (const cfg of raw) {
|
||||
sysConfigs[cfg.key] = cfg.value
|
||||
}
|
||||
loadWhitelistDirs()
|
||||
} catch (e) {
|
||||
console.error('加载清理配置失败', e)
|
||||
}
|
||||
@@ -130,8 +224,15 @@ async function loadCleanupConfigs() {
|
||||
async function handleSaveCleanupConfigs() {
|
||||
cleanupSaving.value = true
|
||||
try {
|
||||
const keys = ['cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days', 'cleanup_empty_trash', 'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent', 'save_reuse_enabled']
|
||||
const keys = [
|
||||
'cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days',
|
||||
'cleanup_empty_trash',
|
||||
'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent',
|
||||
'save_reuse_enabled',
|
||||
'cleanup_verify_interval', 'storage_refresh_interval',
|
||||
]
|
||||
const entries = keys.map(key => ({ key, value: String(sysConfigs[key] ?? '') }))
|
||||
entries.push({ key: 'cleanup_whitelist_dirs', value: JSON.stringify(whitelistDirs.value) })
|
||||
await updateSystemConfigs(entries)
|
||||
ElMessage.success('清理配置已保存')
|
||||
} catch (e: any) {
|
||||
@@ -181,14 +282,74 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.cleanup-section .config-card {
|
||||
max-width: 800px;
|
||||
/* 全宽展示,没有max-width限制 */
|
||||
}
|
||||
.form-tip {
|
||||
.cleanup-section :deep(.el-card__header) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── 2列网格布局 ── */
|
||||
.cleanup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cleanup-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 18px;
|
||||
background: var(--bg-card);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.cleanup-group:hover {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cleanup-group-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--border-light);
|
||||
}
|
||||
.cleanup-hint {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 底部操作栏(跨列全宽) ── */
|
||||
.cleanup-actions {
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.cleanup-actions-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cleanup-info {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏时改为单列 ── */
|
||||
@media (max-width: 900px) {
|
||||
.cleanup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
<el-button @click="verifyAll">全部重新验证</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="configs" stripe style="width: 100%">
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header><span>📋 网盘配置列表</span></template>
|
||||
<el-table :data="configs" stripe style="width: 100%" empty-text="暂无网盘配置,点击上方「新增配置」添加">
|
||||
<el-table-column label="网盘类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<CloudBadge :cloud_type="row.cloud_type" />
|
||||
@@ -99,6 +101,16 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="默认账号" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.is_primary === 1"
|
||||
:disabled="!row.is_transfer_enabled"
|
||||
size="small"
|
||||
@change="(val: boolean) => handleTogglePrimary(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="390" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||
@@ -111,10 +123,26 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<!-- 白名单目录提示 -->
|
||||
<el-alert
|
||||
v-show="whitelistDirs.length > 0"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 18px;"
|
||||
>
|
||||
<template #title>
|
||||
<div style="line-height: 1.6;">
|
||||
<div>🧹 请在网盘主目录内创建:<b>{{ whitelistDirs.join('、') }}</b> 目录</div>
|
||||
<div>并将你的重要文件移至该目录,<b>只有这个目录不会被自动清理</b></div>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form-item label="网盘类型" prop="cloud_type">
|
||||
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
|
||||
<el-option
|
||||
@@ -142,11 +170,6 @@
|
||||
input-style="font-family: monospace; font-size: 12px;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<el-button type="primary" :loading="form._verifying" @click="verifyAndFillNickname" style="width: 100%">
|
||||
{{ form._verifying ? '验证中...' : '🔍 自动获取(验证 Cookie 并回填信息)' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<!-- Cookie 获取教程(根据网盘类型切换) -->
|
||||
<el-form-item label=" " v-if="form.cloud_type && form.cloud_type !== ''" class="cookie-tips-item">
|
||||
<div class="cookie-tips" :class="`cookie-tips-${form.cloud_type}`">
|
||||
@@ -171,7 +194,8 @@ import { Loading } from '@element-plus/icons-vue'
|
||||
import { CLOUD_LABELS } from '../../types'
|
||||
import type { CloudType, CloudConfig } from '../../types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType, setPrimary } from '../../api'
|
||||
import { getSystemConfigs } from '../../api'
|
||||
import CloudBadge from '../../components/CloudBadge.vue'
|
||||
import type { ElForm } from 'element-plus'
|
||||
|
||||
@@ -295,6 +319,7 @@ const cookieTutorialHtml = computed(() => {
|
||||
onMounted(async () => {
|
||||
await loadConfigs()
|
||||
await loadCloudTypes()
|
||||
await loadSystemConfigs()
|
||||
})
|
||||
|
||||
// 每30分钟自动验证一次
|
||||
@@ -315,6 +340,27 @@ async function loadCloudTypes() {
|
||||
} catch (e) { console.error('加载网盘类型失败', e) }
|
||||
}
|
||||
|
||||
/** 加载系统配置,提取清理白名单目录 */
|
||||
const systemConfigs = ref<Record<string, string>>({})
|
||||
const whitelistDirs = computed(() => {
|
||||
try {
|
||||
const raw = systemConfigs.value.cleanup_whitelist_dirs || '[]'
|
||||
const arr = JSON.parse(raw)
|
||||
return Array.isArray(arr) && arr.length > 0 ? arr : []
|
||||
} catch { return [] }
|
||||
})
|
||||
|
||||
async function loadSystemConfigs() {
|
||||
try {
|
||||
const list = await getSystemConfigs()
|
||||
const map: Record<string, string> = {}
|
||||
for (const item of list) {
|
||||
map[item.key] = item.value
|
||||
}
|
||||
systemConfigs.value = map
|
||||
} catch (e) { console.error('加载系统配置失败', e) }
|
||||
}
|
||||
|
||||
async function handleCloudToggle(type: string, enabled: boolean) {
|
||||
const ct = cloudTypes.value.find(c => c.type === type)
|
||||
if (!ct) return
|
||||
@@ -341,7 +387,7 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||
nickname: row.nickname || '',
|
||||
promotion_account: row.promotion_account || '',
|
||||
is_transfer_enabled: newVal,
|
||||
is_active: row.is_active !== 0,
|
||||
is_active: row.is_active,
|
||||
cookie: undefined, // don't send cookie on toggle-only
|
||||
})
|
||||
row.is_transfer_enabled = newVal
|
||||
@@ -351,6 +397,16 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePrimary(row: CloudConfig, enabled: boolean) {
|
||||
try {
|
||||
await setPrimary(row.id!, enabled)
|
||||
row.is_primary = enabled ? 1 : 0
|
||||
ElMessage.success(enabled ? `已将「${row.nickname || row.cloud_type}」设为默认账号` : '已取消默认账号')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function autoVerifyAll() {
|
||||
for (const cfg of configs.value) {
|
||||
if (cfg.cookie_preview || cfg.nickname) {
|
||||
@@ -393,33 +449,6 @@ async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = f
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAndFillNickname() {
|
||||
if (!form.cookie) {
|
||||
ElMessage.warning('请先输入 Cookie')
|
||||
return
|
||||
}
|
||||
if (!form.cloud_type) {
|
||||
ElMessage.warning('请先选择网盘类型')
|
||||
return
|
||||
}
|
||||
form._verifying = true
|
||||
try {
|
||||
const result = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||
if (result.success) {
|
||||
if (result.nickname) form.nickname = result.nickname
|
||||
if (result.storage_used) form._storageUsed = result.storage_used
|
||||
if (result.storage_total) form._storageTotal = result.storage_total
|
||||
ElMessage.success(`昵称:${result.nickname || '获取成功'}`)
|
||||
} else {
|
||||
ElMessage.warning(result.message || '验证失败,请检查 Cookie')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.error || '验证失败,请检查 Cookie')
|
||||
} finally {
|
||||
form._verifying = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row: CloudConfig | null) {
|
||||
if (row) {
|
||||
editingId.value = row.id ?? null
|
||||
@@ -453,10 +482,9 @@ async function handleSave() {
|
||||
// 1. 表单校验(含推广账号必填)
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 2. 如果有 Cookie,先验证 Cookie
|
||||
// 2. 如果有 Cookie,验证 Cookie 有效/无效(不回填昵称和空间)
|
||||
if (form.cookie) {
|
||||
try {
|
||||
const verifyResult = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||
@@ -465,17 +493,12 @@ async function handleSave() {
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
// 保存验证结果
|
||||
if (verifyResult.nickname && !form.nickname) form.nickname = verifyResult.nickname
|
||||
if (verifyResult.storage_used) form._storageUsed = verifyResult.storage_used
|
||||
if (verifyResult.storage_total) form._storageTotal = verifyResult.storage_total
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`Cookie验证失败:${e.response?.data?.error || '网络错误'}`)
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 保存配置
|
||||
if (editingId.value) {
|
||||
await updateCloudConfig({
|
||||
@@ -483,11 +506,9 @@ async function handleSave() {
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
|
||||
cookie: form.cookie || undefined,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
is_active: 1,
|
||||
})
|
||||
ElMessage.success('配置更新成功')
|
||||
} else {
|
||||
@@ -495,11 +516,9 @@ async function handleSave() {
|
||||
cloud_type: form.cloud_type as CloudType,
|
||||
nickname: form.nickname,
|
||||
promotion_account: form.promotion_account,
|
||||
is_transfer_enabled: form.is_transfer_enabled,
|
||||
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
|
||||
cookie: form.cookie,
|
||||
is_active: true,
|
||||
storage_used: form._storageUsed || undefined,
|
||||
storage_total: form._storageTotal || undefined,
|
||||
is_active: 1,
|
||||
})
|
||||
ElMessage.success('配置保存成功')
|
||||
}
|
||||
@@ -564,16 +583,27 @@ function storageFree(row: CloudConfig): string {
|
||||
|
||||
<style scoped>
|
||||
.cloud-config {
|
||||
background: var(--bg-white);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 24px;
|
||||
/* Uses global card styles from App.vue */
|
||||
}
|
||||
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.cloud-toggle-chip { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 8px; background: var(--el-bg-color); }
|
||||
.cloud-toggle-chip:hover { border-color: var(--el-color-primary-light-5); }
|
||||
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; }
|
||||
.cloud-label { font-size: 13px; font-weight: 500; }
|
||||
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
|
||||
/* ── Table card wrapper ── */
|
||||
.table-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border) !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.table-card :deep(.el-card__header) {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-card-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 18px;
|
||||
}
|
||||
.table-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
@@ -581,24 +611,35 @@ function storageFree(row: CloudConfig): string {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sign-summary-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ── Table cells ── */
|
||||
.nickname-text {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
color: var(--text);
|
||||
}
|
||||
.promotion-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.uid-cell {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
/* 空间进度条 */
|
||||
.save-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.verifying {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Storage bar ── */
|
||||
.storage-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -607,7 +648,7 @@ function storageFree(row: CloudConfig): string {
|
||||
}
|
||||
.storage-bar-wrap {
|
||||
height: 4px;
|
||||
background: #f0f2f5;
|
||||
background: var(--border-light);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -621,44 +662,27 @@ function storageFree(row: CloudConfig): string {
|
||||
.storage-bar-fill.bar-danger { background: #f56c6c; }
|
||||
.storage-text {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.storage-used { color: #606266; font-weight: 600; }
|
||||
.storage-total { color: #303133; font-weight: 600; }
|
||||
.storage-free { color: #909399; }
|
||||
.save-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.verifying {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.el-input-group__append .el-button) {
|
||||
border-radius: 0;
|
||||
}
|
||||
.storage-used { color: var(--text-secondary); font-weight: 600; }
|
||||
.storage-total { color: var(--text); font-weight: 600; }
|
||||
.storage-free { color: var(--text-tertiary); }
|
||||
|
||||
/* Cookie 教程卡片 */
|
||||
/* ── Cookie tutorial card ── */
|
||||
.cookie-tips-item :deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.cookie-tips {
|
||||
background: #f8faff;
|
||||
border: 1px solid #e8f0fe;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: #606266;
|
||||
color: var(--text-secondary);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -667,7 +691,7 @@ function storageFree(row: CloudConfig): string {
|
||||
}
|
||||
.cookie-tips-title {
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.cookie-tips-steps {
|
||||
@@ -678,11 +702,11 @@ function storageFree(row: CloudConfig): string {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cookie-tips-steps code {
|
||||
background: #ecf5ff;
|
||||
background: var(--primary-soft);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
.cookie-tips-note {
|
||||
margin-top: 8px;
|
||||
@@ -698,4 +722,42 @@ function storageFree(row: CloudConfig): string {
|
||||
background: #f5f0e0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cookie-tips {
|
||||
background: rgba(64, 158, 255, 0.06);
|
||||
border-color: rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
[data-theme="dark"] .cookie-tips-title {
|
||||
color: #66b1ff;
|
||||
}
|
||||
[data-theme="dark"] .cookie-tips-steps code {
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
[data-theme="dark"] .cookie-tips-note {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-color: rgba(255, 193, 7, 0.2);
|
||||
color: #d4a84b;
|
||||
}
|
||||
[data-theme="dark"] .cookie-tips-note code {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
}
|
||||
|
||||
/* ── Cloud toggle (re-used from AdminDashboard) ── */
|
||||
.cloud-toggle-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.cloud-toggle-chip { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); }
|
||||
.cloud-toggle-chip:hover { border-color: var(--primary); }
|
||||
.cloud-icon-img { width: 20px; height: 20px; object-fit: contain; flex-shrink: 0; }
|
||||
.cloud-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* ── Dialog refinements ── */
|
||||
:deep(.el-dialog__header) {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
:deep(.el-dialog__wrapper .el-dialog) {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<el-card shadow="never" class="save-table-card">
|
||||
<template #header><span>📋 转存日志列表</span></template>
|
||||
<div class="el-table-wrap">
|
||||
<el-table
|
||||
:data="records" stripe style="width: 100%"
|
||||
@@ -85,15 +87,15 @@
|
||||
<el-table-column type="expand" width="36">
|
||||
<template #default="{ row }">
|
||||
<div class="expand-detail">
|
||||
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数量 -->
|
||||
<!-- Row 1: 原始链接 + 文件大小 + 文件夹+文件数 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell">
|
||||
<div class="detail-cell" style="flex:2">
|
||||
<span class="detail-label">原始链接</span>
|
||||
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.original_folder_name">
|
||||
<span class="detail-label">原始文件夹名</span>
|
||||
<code class="detail-code">{{ row.original_folder_name }}</code>
|
||||
<div class="detail-cell" v-if="row.file_size">
|
||||
<span class="detail-label">文件大小</span>
|
||||
<code class="detail-code">{{ row.file_size ? (function(n){if(n==null||n==='')return'-';var v=typeof n==='string'?parseInt(n,10):n;if(!v||v<=0)return'-';var u=['B','KB','MB','GB','TB'];var i=0,s=v;while(s>=1024&&i<4){s/=1024;i++}return s.toFixed(i===0?0:2)+' '+u[i]})(row.file_size) : '-' }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||
<span class="detail-label">文件夹</span>
|
||||
@@ -108,9 +110,24 @@
|
||||
<span class="reuse-msg">♻️ 直接使用已有分享链接,无需实际转存</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
|
||||
<!-- Row 2: 使用账号 + 原始文件夹名 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell" v-if="row.share_url">
|
||||
<div class="detail-cell" v-if="row.config_nickname">
|
||||
<span class="detail-label">使用账号</span>
|
||||
<el-tag size="small" type="success" effect="plain">{{ row.config_nickname }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.original_folder_name">
|
||||
<span class="detail-label">原始文件夹名</span>
|
||||
<code class="detail-code">{{ row.original_folder_name }}</code>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.folder_name">
|
||||
<span class="detail-label">转存文件夹</span>
|
||||
<code class="detail-code">{{ row.folder_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: 分享链接 + 分享密码 + 耗时 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-cell" v-if="row.share_url" style="flex:2">
|
||||
<span class="detail-label">分享链接</span>
|
||||
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
|
||||
</div>
|
||||
@@ -118,12 +135,12 @@
|
||||
<span class="detail-label">分享密码</span>
|
||||
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-cell" v-if="row.folder_name">
|
||||
<span class="detail-label">转存文件夹</span>
|
||||
<code class="detail-code">{{ row.folder_name }}</code>
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">耗时</span>
|
||||
<span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: IP地址 + 归属地 -->
|
||||
<!-- Row 4: IP地址 + 归属地 + 创建时间 -->
|
||||
<div class="detail-row" v-if="row.ip_address">
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">IP 地址</span>
|
||||
@@ -133,12 +150,22 @@
|
||||
<span class="detail-label">归属地</span>
|
||||
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
|
||||
</div>
|
||||
<div class="detail-cell">
|
||||
<span class="detail-label">时间</span>
|
||||
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
|
||||
</div>
|
||||
<!-- Row 4: 错误信息(整行) -->
|
||||
</div>
|
||||
<!-- Row 5: 错误信息(整行) -->
|
||||
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
|
||||
<div class="detail-cell detail-full">
|
||||
<span class="detail-label">错误信息</span>
|
||||
<pre class="detail-error">{{ row.error_message }}</pre>
|
||||
<pre class="detail-error">{{ row.error_message.includes(' | ') ? row.error_message.split(' | ')[1] : row.error_message.split(' | ')[0] }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row" v-if="row.status === 'failed' && row.error_message && row.error_message.includes(' | ')">
|
||||
<div class="detail-cell detail-full">
|
||||
<span class="detail-label">友好提示</span>
|
||||
<pre class="detail-error">{{ row.error_message.split(' | ')[0] }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,6 +252,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ── Pagination ── -->
|
||||
<div class="pagination-wrap" v-if="total > 0">
|
||||
@@ -314,7 +342,7 @@ function formatTime(t: string): string {
|
||||
const d = new Date(ts)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
@@ -574,6 +602,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.save-table-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
.save-table-card :deep(.el-card__header) {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-card-header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 18px;
|
||||
}
|
||||
.save-table-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
.save-records :deep(.el-table) {
|
||||
border: 1px solid var(--el-border-color-light, #ebeef5);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -490,24 +490,145 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<!-- 🔄 系统维护 --> <el-card id="section-sys-maintenance" v-show="!activeSection || activeSection === 'sys-maintenance'"> <template #header> <span>🔄 系统维护</span> </template> <el-form label-width="180px" label-position="left"> <el-form-item label="自动更新镜像"> <el-switch v-model="autoUpdateEnabled" active-text="启用" inactive-text="禁用" /> <div class="form-tip">启用后 CloudSearch 将自动检测并更新到最新镜像版本</div> <div class="form-tip" style="color: var(--(--el-color-warning,#e6a23c));"> 当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d </div> </el-form-item> </el-form> </el-card>
|
||||
|
||||
<!-- 📬 消息推送 -->
|
||||
<el-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
|
||||
<template #header>
|
||||
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<span>📬 消息推送</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 全局推送兜底(动态渲染全部通道) -->
|
||||
<el-collapse :model-value="['global']">
|
||||
<el-collapse-item title="全局推送(管理员兜底)" name="global">
|
||||
<div class="strategy-section">
|
||||
<el-form label-width="140px" label-position="left">
|
||||
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:8px;">
|
||||
<div v-for="(np, nkey) in notifyProviders" :key="nkey" style="border:1px solid var(--el-border-color-light); border-radius:6px; padding:8px 12px;">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
||||
<el-switch v-model="globalNotifyForm.channels[nkey]._enabled" size="small" />
|
||||
<strong>{{ np.label }}</strong>
|
||||
<el-button v-if="globalNotifyForm.channels[nkey]._enabled" size="small" text type="primary" @click="testGlobalChannel(nkey)" :loading="globalNotifyForm.channels[nkey]._testing">测试</el-button>
|
||||
</div>
|
||||
<div v-if="globalNotifyForm.channels[nkey]._enabled">
|
||||
<el-form-item v-for="p in np.params" :key="p.key" :label="p.label" style="margin-bottom:6px;">
|
||||
<el-input v-if="p.type==='password'" v-model="globalNotifyForm.channels[nkey][p.key]" type="password" show-password :placeholder="p.placeholder || ''" style="max-width:360px" />
|
||||
<el-switch v-else-if="p.type==='switch'" v-model="globalNotifyForm.channels[nkey][p.key]" />
|
||||
<el-input-number v-else-if="p.type==='number'" v-model="globalNotifyForm.channels[nkey][p.key]" :min="1" :max="10" style="max-width:160px" />
|
||||
<el-input v-else v-model="globalNotifyForm.channels[nkey][p.key]" :placeholder="p.placeholder || ''" style="max-width:360px" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider content-position="left">全局事件开关</el-divider>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:16px;">
|
||||
<el-switch v-model="globalNotifyForm.events.on_save_success" active-text="转存成功" />
|
||||
<el-switch v-model="globalNotifyForm.events.on_save_fail" active-text="转存失败" />
|
||||
<el-switch v-model="globalNotifyForm.events.on_cookie_expire" active-text="Cookie过期" />
|
||||
<el-switch v-model="globalNotifyForm.events.on_cleanup" active-text="清理完成" />
|
||||
</div>
|
||||
<div class="form-tip" style="margin-top:8px;">全局推送作为兜底通道。设置了推送用户的网盘配置走用户推送,未设置的走全局推送。</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse> <el-divider content-position="left">添加推送用户</el-divider>
|
||||
|
||||
<!-- Inline push user add/edit form -->
|
||||
<div style="border:1px solid var(--el-border-color-light); border-radius:6px; padding:12px 16px; margin-bottom:16px;">
|
||||
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
|
||||
<el-select v-model="pushUserForm.account" filterable allow-create clearable placeholder="选择推广账户" style="width:200px;">
|
||||
<el-option
|
||||
v-for="acc in pushUserAccountOptions"
|
||||
:key="acc"
|
||||
:label="acc"
|
||||
:value="acc"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="pushUserForm.channels" multiple collapse-tags collapse-tags-tooltip placeholder="选择您所需的消息频道" style="width:260px;">
|
||||
<el-option
|
||||
v-for="(np, nkey) in enabledNotifyProviders"
|
||||
:key="nkey"
|
||||
:label="np.label"
|
||||
:value="nkey"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-switch v-model="pushUserForm.events.on_save_success" active-text="转存成功" />
|
||||
<el-switch v-model="pushUserForm.events.on_save_fail" active-text="转存失败" />
|
||||
<el-switch v-model="pushUserForm.events.on_cookie_expire" active-text="Cookie过期" />
|
||||
<el-switch v-model="pushUserForm.events.on_cleanup" active-text="清理完成" />
|
||||
|
||||
<el-button type="primary" size="small" :loading="pushUserSaving" @click="savePushUser">{{ pushUserForm.id ? '更新' : '确认添加' }}</el-button>
|
||||
<el-button v-if="pushUserForm.id" size="small" @click="cancelEditPushUser">取消编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">推送用户列表</el-divider>
|
||||
|
||||
<el-table :data="pushUsers" stripe style="width:100%" empty-text="暂无推送用户">
|
||||
<el-table-column prop="account" label="推广账号" min-width="140" />
|
||||
<el-table-column label="转存成功" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="getEventEnabled(row, 'on_save_success')" type="success" size="small">✔</el-tag>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转存失败" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="getEventEnabled(row, 'on_save_fail')" type="success" size="small">✔</el-tag>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Cookie过期" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="getEventEnabled(row, 'on_cookie_expire')" type="success" size="small">✔</el-tag>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="清理完成" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="getEventEnabled(row, 'on_cleanup')" type="success" size="small">✔</el-tag>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已启用的通道" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="(_, key) in getEnabledChannels(row)" :key="key" size="small" style="margin-right:4px;margin-bottom:2px;">{{ getProviderLabel(key) }}</el-tag>
|
||||
<span v-if="!hasEnabledChannels(row)" style="color:#909399;font-size:12px;">走全局推送</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editPushUser(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定删除该推送用户?" @confirm="deletePushUser(row)">
|
||||
<template #reference>
|
||||
<el-button text type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 🔄 系统维护 --> <el-card id="section-sys-maintenance" v-show="!activeSection || activeSection === 'sys-maintenance'"> <template #header> <span>🔄 系统维护</span> </template> <el-form label-width="180px" label-position="left"> <el-form-item label="自动更新镜像"> <el-switch v-model="autoUpdateEnabled" active-text="启用" inactive-text="禁用" /> <div class="form-tip">启用后 CloudSearch 将自动检测并更新到最新镜像版本</div> <div class="form-tip" style="color: var(--(--el-color-warning,#e6a23c));"> 当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d </div> </el-form-item> </el-form> </el-card>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="save-bar">
|
||||
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
|
||||
保存配置
|
||||
</el-button>
|
||||
<el-button size="large" @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from "vue"
|
||||
import { ref, reactive, onMounted, computed, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ElForm } from 'element-plus'
|
||||
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService } from "../../api"
|
||||
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService, testNotifyChannel, getAllNotifierProviders, getCloudConfigs } from "../../api"
|
||||
import { Upload, Loading } from "@element-plus/icons-vue"
|
||||
|
||||
|
||||
@@ -572,6 +693,253 @@ const autoUpdateEnabled = computed({
|
||||
set: (val: boolean) => { configs.auto_update_enabled = val ? 'true' : 'false' },
|
||||
})
|
||||
|
||||
// ======================== Push User Notifications ========================
|
||||
const pushUsers = ref<any[]>([])
|
||||
const notifyProviders = ref<Record<string, any>>({})
|
||||
// const pushUserDialogVisible = ref(false) // removed - using inline form
|
||||
const pushUserSaving = ref(false)
|
||||
const pushUserAccountOptions = ref<string[]>([])
|
||||
|
||||
async function loadPushUserAccountOptions() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/cloud-configs', {
|
||||
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const configs = await resp.json()
|
||||
const options = Array.isArray(configs)
|
||||
? [...new Set(configs.map((c: any) => c.promotion_account || '').filter(Boolean))]
|
||||
: []
|
||||
pushUserAccountOptions.value = options
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const pushUserForm = reactive<any>({
|
||||
id: null,
|
||||
account: '',
|
||||
channels: [],
|
||||
events: {
|
||||
on_save_success: true,
|
||||
on_save_fail: true,
|
||||
on_cookie_expire: true,
|
||||
on_cleanup: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Only show channels that are enabled in global notification settings
|
||||
const enabledNotifyProviders = computed(() => {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [k, np] of Object.entries(notifyProviders.value)) {
|
||||
if (globalNotifyForm.channels[k]?._enabled) {
|
||||
result[k] = np
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const globalNotifyForm = reactive<{ channels: Record<string, any>; events: Record<string, boolean> }>({
|
||||
channels: {},
|
||||
events: { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false },
|
||||
})
|
||||
|
||||
function initPushUserChannelForm() {
|
||||
const channels: Record<string, any> = {}
|
||||
for (const [k, np] of Object.entries(notifyProviders.value)) {
|
||||
channels[k] = { _enabled: false, _testing: false }
|
||||
for (const p of np.params || []) {
|
||||
channels[k][p.key] = p.default || ''
|
||||
}
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
function editPushUser(row?: any) {
|
||||
if (row) {
|
||||
pushUserForm.id = row.id
|
||||
pushUserForm.account = row.account
|
||||
const nc = row.notify_config || {}
|
||||
pushUserForm.channels = Object.keys(nc.channels || {})
|
||||
pushUserForm.events = {
|
||||
on_save_success: nc.events?.on_save_success !== false,
|
||||
on_save_fail: nc.events?.on_save_fail !== false,
|
||||
on_cookie_expire: nc.events?.on_cookie_expire !== false,
|
||||
on_cleanup: nc.events?.on_cleanup === true,
|
||||
}
|
||||
} else {
|
||||
pushUserForm.id = null
|
||||
pushUserForm.account = ''
|
||||
pushUserForm.channels = []
|
||||
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditPushUser() {
|
||||
pushUserForm.id = null
|
||||
pushUserForm.account = ''
|
||||
pushUserForm.channels = []
|
||||
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
|
||||
}
|
||||
|
||||
function getEventEnabled(row: any, eventKey: string): boolean {
|
||||
const nc = row.notify_config || {}
|
||||
const events = nc.events || {}
|
||||
return events[eventKey] === true
|
||||
}
|
||||
|
||||
async function savePushUser() {
|
||||
if (!pushUserForm.account) {
|
||||
ElMessage.warning('请填写推广账号')
|
||||
return
|
||||
}
|
||||
pushUserSaving.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
account: pushUserForm.account,
|
||||
notify_config: { channels: {}, events: pushUserForm.events },
|
||||
}
|
||||
// Build channels from selected keys (no params — use global config at push time)
|
||||
const ch: Record<string, any> = {}
|
||||
for (const key of pushUserForm.channels) {
|
||||
ch[key] = {}
|
||||
}
|
||||
payload.notify_config.channels = ch
|
||||
if (pushUserForm.id) {
|
||||
await fetch('/api/admin/push-users/' + pushUserForm.id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} else {
|
||||
await fetch('/api/admin/push-users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
const isUpdate = !!pushUserForm.id
|
||||
pushUserForm.id = null
|
||||
pushUserForm.account = ''
|
||||
pushUserForm.channels = []
|
||||
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
|
||||
ElMessage.success(isUpdate ? '推送用户已更新' : '推送用户已添加')
|
||||
await loadPushUsers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally {
|
||||
pushUserSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPushUsers() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/push-users', {
|
||||
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
|
||||
})
|
||||
if (resp.ok) {
|
||||
pushUsers.value = await resp.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load push users', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifyProviders() {
|
||||
try {
|
||||
notifyProviders.value = await getAllNotifierProviders()
|
||||
} catch (e) {
|
||||
console.error('Failed to load providers', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePushUser(row: any) {
|
||||
try {
|
||||
await fetch('/api/admin/push-users/' + row.id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') } })
|
||||
ElMessage.success('已删除')
|
||||
await loadPushUsers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getEnabledChannels(row: any): Record<string, any> {
|
||||
const ch = row.notify_config?.channels || {}
|
||||
// Support both old format ({key: {...params}}) and new format ({key: {}})
|
||||
return ch
|
||||
}
|
||||
|
||||
function getProviderLabel(key: string): string {
|
||||
return notifyProviders.value[key]?.label || key
|
||||
}
|
||||
|
||||
function hasEnabledChannels(row: any): boolean {
|
||||
return Object.keys(getEnabledChannels(row)).length > 0
|
||||
}
|
||||
// ==================== End Push User Notifications ====================
|
||||
|
||||
|
||||
// ==================== Global Notify Functions ====================
|
||||
|
||||
function initGlobalNotifyForm() {
|
||||
const channels: Record<string, any> = {}
|
||||
for (const [k, np] of Object.entries(notifyProviders.value)) {
|
||||
channels[k] = { _enabled: false, _testing: false }
|
||||
for (const p of np.params || []) {
|
||||
channels[k][p.key] = p.default || ''
|
||||
}
|
||||
}
|
||||
globalNotifyForm.channels = channels
|
||||
globalNotifyForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
|
||||
}
|
||||
|
||||
async function loadGlobalNotifyConfig() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/system-configs', {
|
||||
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
|
||||
})
|
||||
const configs = await resp.json() as any[]
|
||||
const gcfg = configs.find((c: any) => c.key === 'global_notify_config')
|
||||
if (gcfg && gcfg.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(gcfg.value)
|
||||
const nc = parsed.channels || {}
|
||||
for (const [k, v] of Object.entries(nc)) {
|
||||
if (globalNotifyForm.channels[k]) {
|
||||
globalNotifyForm.channels[k]._enabled = true
|
||||
for (const [pk, pv] of Object.entries(v as Record<string, any>)) {
|
||||
globalNotifyForm.channels[k][pk] = pv
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed.events) {
|
||||
globalNotifyForm.events.on_save_success = parsed.events.on_save_success !== false
|
||||
globalNotifyForm.events.on_save_fail = parsed.events.on_save_fail !== false
|
||||
globalNotifyForm.events.on_cookie_expire = parsed.events.on_cookie_expire !== false
|
||||
globalNotifyForm.events.on_cleanup = parsed.events.on_cleanup === true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function testGlobalChannel(channelName: string) {
|
||||
const ch = globalNotifyForm.channels[channelName]
|
||||
if (!ch || !ch._enabled) return
|
||||
ch._testing = true
|
||||
try {
|
||||
const result = await testNotifyChannel(channelName)
|
||||
if (result.success) {
|
||||
ElMessage.success(result.message)
|
||||
} else {
|
||||
ElMessage.error(result.message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '测试失败')
|
||||
} finally {
|
||||
ch._testing = false
|
||||
}
|
||||
}
|
||||
const passwordForm = reactive({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
@@ -633,6 +1001,23 @@ onMounted(async () => {
|
||||
}
|
||||
// Auto-load PanSou info on page load
|
||||
fetchPansouInfo()
|
||||
await loadNotifyProviders()
|
||||
initGlobalNotifyForm()
|
||||
await loadGlobalNotifyConfig()
|
||||
loadPushUsers()
|
||||
loadPushUserAccountOptions()
|
||||
})
|
||||
|
||||
// Watch for notifyProviders loaded asynchronously — sync global form channels
|
||||
watch(notifyProviders, () => {
|
||||
for (const [k, np] of Object.entries(notifyProviders.value)) {
|
||||
if (!globalNotifyForm.channels[k]) {
|
||||
globalNotifyForm.channels[k] = { _enabled: false, _testing: false }
|
||||
for (const p of np.params || []) {
|
||||
globalNotifyForm.channels[k][p.key] = p.default || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleTestRedis() {
|
||||
@@ -920,10 +1305,26 @@ function handleLogout() {
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
// Build global_notify_config from form
|
||||
const channels: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(globalNotifyForm.channels)) {
|
||||
if ((v as any)._enabled) {
|
||||
const params: Record<string, string> = {}
|
||||
for (const [pk, pv] of Object.entries(v as Record<string, any>)) {
|
||||
if (!pk.startsWith('_') && pv !== '') params[pk] = String(pv)
|
||||
}
|
||||
if (Object.keys(params).length > 0) channels[k] = params
|
||||
}
|
||||
}
|
||||
const entries = rawConfigs.value.map(cfg => ({
|
||||
key: cfg.key,
|
||||
value: String(configs[cfg.key] ?? cfg.value),
|
||||
}))
|
||||
// Add global_notify_config as JSON entry
|
||||
entries.push({
|
||||
key: 'global_notify_config',
|
||||
value: JSON.stringify({ channels, events: globalNotifyForm.events }),
|
||||
})
|
||||
await updateSystemConfigs(entries)
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (e: any) {
|
||||
@@ -1097,16 +1498,6 @@ async function handleRemoveLogo() {
|
||||
background: #f0f0f0;
|
||||
object-fit: contain;
|
||||
}
|
||||
.save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: var(--bg-white);
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end; padding-right: 24px; gap: 12px;
|
||||
}
|
||||
|
||||
/* ── 搜索策略 3列网格 ── */
|
||||
.strategy-grid {
|
||||
|
||||
Reference in New Issue
Block a user