13 Commits

Author SHA1 Message Date
root
e38adee8ff feat: v0.1.7 消息推送模块 - 多通道通知(飞书/Server酱/Bark/Telegram/Webhook)+ 转存/清理/Cookie推送事件 + 推送配置页面 2026-05-15 23:59:55 +08:00
1c7b750cda docs: 完善README - 功能描述、架构图、一键部署命令 2026-05-15 23:29:54 +08:00
root
301bb63ef0 chore: v0.1.6 UI优化 - 两列网格布局、暗色适配、系统配置浮窗保存、退出登录统一到侧边栏 2026-05-15 23:08:33 +08:00
4b437c34c6 chore: version.ts同步更新至v0.1.1 2026-05-15 18:44:17 +08:00
cf965bcfee fix: getStorageInfo快速估算(3s)+后台全量遍历校准; quark-cleanup去kps依赖; QuarkDriver支持回调 2026-05-15 18:31:52 +08:00
dfcdddabad chore: 统一版本号为v0.1.1 2026-05-15 18:25:42 +08:00
4b9bcd7a96 fix: cookie加密后cloud.service.ts未解密导致API 401; 更新版本号至2.1.1/1.1.9 2026-05-15 18:23:40 +08:00
a12fec4d82 fix: 警示文件夹创建到转存目录内而非根目录
- createWarningDirectories 加 parentDirFid 参数
- createSingleDir 加 pdirFid 参数, pdir_fid 用传参而非硬编码 0
- runAdCleanup 传入 savedDirFid
- listDirAllPages 列出目标目录而非根目录
2026-05-15 07:05:58 +08:00
1c0c024b9a feat: 转存记录记录文件大小, 详情展示使用账号+文件大小+时间格式
- quark/baidu 驱动 saveFromShare 返回 fileSize 总大小
- cloud.service.ts 写入 file_size 字段(非null时转字符串)
- 详情页新增文件大小展示(formatFileSize 自动格式化)
- 详情页时间改为 formatTime(yyyy-MM-dd HH:mm:ss)
- SaveRecords 时间格式: 05-15 → 2026-05-15
2026-05-15 07:05:03 +08:00
359e15a82d fix: save_records JOIN 查询列名歧义, 加 sr. 前缀
- getRecords JOIN cloud_configs 后 WHERE 列需加 sr. 前缀
- 不带日期筛选时不触发 (srWhere 为空字符串)
- summaryRows 查询也用 srWhere 但去掉 sr. 前缀 (查询 save_records 不需要)
- 详情补充文件大小/使用账号/耗时
2026-05-15 06:57:50 +08:00
b7702d0285 feat: 同IP默认账号配额改为 primaryCount × 2
- 动态计算该类型默认账号数量 primaryCount
- 前 primaryCount × 2 次在同IP的两个默认账号间轮询
- 超限后再去其他非默认账号
- 无默认账号时 threshold=0, 直接走轮询
2026-05-15 06:51:15 +08:00
37aa05b1e1 revert: 去掉资源历史查询逻辑, 保留纯IP+is_primary分配
- 网盘分享按新资源奖励机制, 不需追资源转存历史
- getAndValidateCredential 去掉 shareUrl 参数
- 保留 save_records.config_id 字段(仅用于日志排查)
2026-05-15 06:48:23 +08:00
329256bd33 fix: 转存时先查资源历史, 复用原账号; save_records加config_id
- 资源维度优先级 > IP维度: 先查share_url是否被转存过
- save_records 表新增 config_id 字段 + 写入时记录
- cloud.service.ts 所有 INSERT 写入 config.id
- credential.service.ts: getAndValidateCredential 加 shareUrl 参数
- 数据库 migration: config_id 到 save_records
2026-05-15 06:45:48 +08:00
27 changed files with 3212 additions and 967 deletions

172
README.md
View File

@@ -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 SPAElement 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 Compose3 服务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 等凭证由用户自行保管,系统以加密形式存储。

View File

@@ -1,6 +1,6 @@
{
"name": "cloudsearch-backend",
"version": "2.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "cloudsearch-backend",
"version": "0.0.3",
"version": "0.1.7",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",

File diff suppressed because it is too large Load Diff

View File

@@ -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');
}
}

View File

@@ -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 } 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,
@@ -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,60 @@ 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;
notifyEvent('save_success', `✅ 转存成功`,
`**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`,
'info');
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
notifyEvent('cookie_expire', `⚠️ Cookie过期`,
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
'error');
} 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) {
notifyEvent('save_fail', `❌ 转存连续失败 ${failCount}`,
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
'warn');
}
}
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 +252,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 +301,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 +337,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();
db.prepare(
`UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?`
).run(storage.used, storage.total, cfg.id);
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 {}
}
}
}
}
}

View File

@@ -189,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;
@@ -220,7 +220,7 @@ 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;
@@ -236,7 +236,7 @@ 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;
@@ -428,6 +428,8 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
let config: CloudConfig | undefined;
if (!ipAddress) {
// No IP info — fallback to simple LUR
config = db.prepare(
@@ -454,8 +456,15 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
const ipTodayCount = ipCountRow?.total || 0;
if (ipTodayCount < 3) {
// First 2 saves — use a primary account (is_primary=1), fallback to any 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
@@ -464,7 +473,7 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
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

View File

@@ -306,6 +306,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' },
];

View File

@@ -166,12 +166,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 +193,7 @@ export async function createWarningDirectories(
continue;
}
await createSingleDir(cookie, formattedName);
await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试
existingDirs.add(formattedName);
}
@@ -204,6 +205,7 @@ export async function createWarningDirectories(
async function createSingleDir(
cookie: string,
dirName: string,
pdirFid: string = "0",
): Promise<boolean> {
try {
const resp = await fetch(
@@ -215,7 +217,7 @@ async function createSingleDir(
"Content-Type": "application/json",
},
body: JSON.stringify({
pdir_fid: "0",
pdir_fid: pdirFid,
file_name: dirName,
dir: true,
dir_path: "",
@@ -276,7 +278,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} 个)`,

View File

@@ -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,15 +38,33 @@ 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 rootFiles = await listRootDir(cookie);
for (const f of rootFiles) {
usedBytes += f.size || 0;
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);
storageCache.bytes = usedBytes;
@@ -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,
};
}
return { used: '0 B', total: '-', usedBytes: 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;
}
} 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: [] };
@@ -312,4 +344,4 @@ export async function cleanupBySpaceThreshold(
} catch (err: any) {
return { trashed: 0, errors: [err.message] };
}
}
}

View File

@@ -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) {

View File

@@ -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> {
@@ -119,4 +119,4 @@ export class QuarkDriver {
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number) {
return cleanupBySpaceThreshold(this.config.cookie, thresholdPercent, deletePercent);
}
}
}

View File

@@ -1,87 +1,248 @@
// Native fetch available in Node 20+
// ============================================================
// notification.service.ts — Multi-channel message notification
// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram
// ============================================================
import { getSystemConfig } from '../admin/system-config.service';
type NotifyLevel = 'info' | 'warn' | 'error';
export type NotifyLevel = 'info' | 'warn' | 'error';
interface NotifyChannel {
export interface NotifyChannel {
send(title: string, content: string, level: NotifyLevel): Promise<void>;
}
// ---- Feishu Webhook Channel ----
// ======================== Channel Implementations ========================
/** Feishu/Lark webhook — interactive card message */
class FeishuChannel implements NotifyChannel {
private webhookUrl: string;
constructor(webhookUrl: string) {
this.webhookUrl = webhookUrl;
}
constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; }
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
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,
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' })}` },
],
},
],
},
}),
});
if (!resp.ok) {
console.error(`[Notify] Feishu send failed: ${resp.status}`);
}
if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Feishu send error:', err.message);
}
}
}
// ---- Notification Manager ----
let _channel: NotifyChannel | null = null;
/** Server酱 — push to WeChat via https://sct.ftqq.com */
class ServerChanChannel implements NotifyChannel {
private sendKey: string;
constructor(sendKey: string) { this.sendKey = sendKey; }
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');
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
try {
const resp = await fetch(`https://sctapi.ftqq.com/${this.sendKey}.send`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ title, desp: content }).toString(),
});
if (!resp.ok) console.error(`[Notify] ServerChan send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] ServerChan send error:', err.message);
}
}
return _channel;
}
/** Bark — push to iOS devices via https://api.day.app */
class BarkChannel implements NotifyChannel {
private key: string;
private server: string;
constructor(key: string, server: string = 'https://api.day.app') {
this.key = key;
this.server = server.replace(/\/+$/, '');
}
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const iconMap: Record<NotifyLevel, string> = {
error: '⚠️', warn: '🔔', info: '',
};
const body = JSON.stringify({
title: `${iconMap[level]} ${title}`,
body: content,
group: 'CloudSearch',
level: level === 'error' ? 'timeSensitive' : 'active',
icon: level === 'error' ? 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/26a0.png' : undefined,
});
const resp = await fetch(`${this.server}/${this.key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
});
if (!resp.ok) console.error(`[Notify] Bark send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Bark send error:', err.message);
}
}
}
/** Custom Webhook — generic HTTP POST */
class WebhookChannel implements NotifyChannel {
private url: string;
constructor(url: string) { this.url = url; }
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const resp = await fetch(this.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, level, source: 'CloudSearch', timestamp: new Date().toISOString() }),
});
if (!resp.ok) console.error(`[Notify] Webhook send failed: ${resp.status}`);
} catch (err: any) {
console.error('[Notify] Webhook send error:', err.message);
}
}
}
/** Telegram Bot — send via Bot API */
class TelegramChannel implements NotifyChannel {
private botToken: string;
private chatId: string;
constructor(botToken: string, chatId: string) {
this.botToken = botToken;
this.chatId = chatId;
}
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
try {
const iconMap: Record<NotifyLevel, string> = { error: '🚨', warn: '⚠️', info: '' };
const text = `${iconMap[level]} *${title}*\n\n${content}`;
const resp = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: this.chatId,
text,
parse_mode: 'Markdown',
disable_web_page_preview: true,
}),
});
if (!resp.ok) {
const err = await resp.text();
console.error(`[Notify] Telegram send failed: ${resp.status}${err}`);
}
} catch (err: any) {
console.error('[Notify] Telegram send error:', err.message);
}
}
}
// ======================== Notification Manager ========================
interface ChannelConfig {
id: string;
create: () => NotifyChannel | null;
}
let _channels: NotifyChannel[] | null = null;
let _channelConfigs: ChannelConfig[] | null = null;
let _lastConfigHash: string = '';
let _debugLogged: boolean = false;
function buildConfigHash(): string {
const keys = ['feishu_webhook_url', 'serverchan_key', 'bark_key', 'bark_server', 'webhook_url', 'telegram_bot_token', 'telegram_chat_id'];
let hash = '';
for (const key of keys) {
try { hash += String(getSystemConfig(key) || ''); } catch { hash += ''; }
}
return hash;
}
function buildChannels(): NotifyChannel[] {
const channels: NotifyChannel[] = [];
// 1. Feishu
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url') || '';
if (feishuUrl) channels.push(new FeishuChannel(feishuUrl));
// 2. Server酱
const serverchanKey = getSystemConfig('serverchan_key') || '';
if (serverchanKey) channels.push(new ServerChanChannel(serverchanKey));
// 3. Bark
const barkKey = getSystemConfig('bark_key') || '';
if (barkKey) {
const barkServer = getSystemConfig('bark_server') || 'https://api.day.app';
channels.push(new BarkChannel(barkKey, barkServer));
}
// 4. Custom Webhook
const webhookUrl = getSystemConfig('webhook_url') || '';
if (webhookUrl) channels.push(new WebhookChannel(webhookUrl));
// 5. Telegram
const tgToken = getSystemConfig('telegram_bot_token') || '';
const tgChatId = getSystemConfig('telegram_chat_id') || '';
if (tgToken && tgChatId) channels.push(new TelegramChannel(tgToken, tgChatId));
if (!_debugLogged && channels.length > 0) {
_debugLogged = true;
console.log(`[Notify] ${channels.length} channel(s) configured: ${channels.map(c => c.constructor.name.replace('Channel','')).join(', ')}`);
}
return channels;
}
function getChannels(): NotifyChannel[] {
const hash = buildConfigHash();
if (hash !== _lastConfigHash) {
_channels = buildChannels();
_lastConfigHash = hash;
}
return _channels || [];
}
// ======================== Event Trigger Checks ========================
function shouldNotify(eventName: string): boolean {
try {
const val = getSystemConfig(`notify_on_${eventName}`);
return val !== 'false'; // default to true
} catch {
return true;
}
}
// ======================== Public API ========================
/**
* Send a notification through configured channels.
* Returns immediately — failures are logged silently.
* Send a notification through all configured channels.
* Fire-and-forget — 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(() => {});
const channels = getChannels();
if (channels.length === 0) return;
for (const ch of channels) {
ch.send(title, content, level).catch(() => {});
}
}
/**
* Notify on critical events:
* - Cookie expired / login failed
* - Save/transfer failed repeatedly
* - Storage below threshold
*/
/** Notify on critical errors (Cookie expired, save failure, etc.) */
export function notifyError(title: string, detail: string): void {
notify(`⚠️ ${title}`, detail, 'error');
}
@@ -93,3 +254,12 @@ export function notifyWarn(title: string, detail: string): void {
export function notifyInfo(title: string, detail: string): void {
notify(` ${title}`, detail, 'info');
}
/**
* Event-aware notification: checks if this event type is enabled,
* then sends the notification.
*/
export function notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info'): void {
if (!shouldNotify(eventName)) return;
notify(title, content, level);
}

View File

@@ -138,27 +138,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 {
@@ -263,6 +242,14 @@ function migrateCloudConfigs(db: Database.Database): void {
}
}
/** 迁移: 给 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 {
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
if (existing) return;
@@ -329,11 +316,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酱 SendKeyhttps://sct.ftqq.com推送到微信' },
{ key: 'bark_key', value: '', description: 'Bark 推送 Keyhttps://api.day.app推送到 iOS 设备)' },
{ key: 'bark_server', value: 'https://api.day.app', description: 'Bark 自定义服务器地址(默认 https://api.day.app' },
{ key: 'webhook_url', value: '', description: '自定义 Webhook URLPOST 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 (?, ?, ?)'

View File

@@ -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);
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
// 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})`);

View File

@@ -9,4 +9,4 @@
* 修改此文件的同时请同步更新后端 package.json 中的 version 字段。
*/
export const APP_VERSION = "0.0.2";
export const VERSION = '0.1.7';

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "cloudsearch-frontend",
"version": "1.1.8",
"version": "0.1.7",
"private": true,
"type": "module",
"scripts": {
@@ -26,4 +26,4 @@
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,75 @@
<template>
<div class="admin-layout">
<el-menu
:default-active="activeMenu"
class="admin-menu"
@select="handleMenuSelect"
>
<div class="menu-header">
<h2>{{ siteName || 'CloudSearch' }}</h2>
<p>管理后台</p>
<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-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>
</template>
<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>
</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-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>
<el-menu-item index="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-menu-item>
</el-menu>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
@select="handleMenuSelect"
>
<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>
</template>
<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>
</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-sub-menu>
<el-menu-item index="save-records">
<el-icon><DocumentCopy /></el-icon>
<span>转存日志</span>
</el-menu-item>
<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>
</div>
<div class="content-body">
<header class="content-header">
<div class="content-breadcrumb">
<span class="breadcrumb-current">{{ pageTitle }}</span>
</div>
<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 +77,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()
@@ -129,14 +148,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>

View File

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

View File

@@ -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>
</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>
</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>
</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 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="清空回收站">
<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-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>
</div>
<!-- 列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 v-if="lastCleanupTime" class="cleanup-info" style="margin-top: 10px;">
<span> 上次清理{{ lastCleanupTime }}</span>
<span v-if="lastCleanupStats" style="margin-left: 16px;">📊 {{ lastCleanupStats }}</span>
<!-- 底部手动操作跨列全宽 -->
<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>

View File

@@ -29,11 +29,13 @@
<el-button @click="verifyAll">全部重新验证</el-button>
</div>
<el-table :data="configs" stripe style="width: 100%">
<el-table-column label="网盘类型" width="110">
<template #default="{ row }">
<CloudBadge :cloud_type="row.cloud_type" />
</template>
<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" />
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" width="140">
<template #default="{ row }">
@@ -121,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
@@ -152,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}`">
@@ -182,6 +195,7 @@ import { CLOUD_LABELS } from '../../types'
import type { CloudType, CloudConfig } from '../../types'
import { ElMessage } from 'element-plus'
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'
@@ -305,6 +319,7 @@ const cookieTutorialHtml = computed(() => {
onMounted(async () => {
await loadConfigs()
await loadCloudTypes()
await loadSystemConfigs()
})
// 每30分钟自动验证一次
@@ -325,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
@@ -351,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
@@ -413,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
@@ -473,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)
@@ -485,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({
@@ -503,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 {
@@ -515,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('配置保存成功')
}
@@ -584,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;
@@ -601,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;
@@ -627,7 +648,7 @@ function storageFree(row: CloudConfig): string {
}
.storage-bar-wrap {
height: 4px;
background: #f0f2f5;
background: var(--border-light);
border-radius: 2px;
overflow: hidden;
}
@@ -641,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;
}
@@ -687,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 {
@@ -698,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;
@@ -718,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>

View File

@@ -74,26 +74,28 @@
</div>
<!-- Table -->
<div class="el-table-wrap">
<el-table
:data="records" stripe style="width: 100%"
v-loading="loading"
empty-text="暂无转存记录"
@expand-change="onExpandChange"
:row-class-name="rowClassName"
>
<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%"
v-loading="loading"
empty-text="暂无转存记录"
@expand-change="onExpandChange"
:row-class-name="rowClassName"
>
<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>
</div>
<!-- Row 4: 错误信息整行 -->
<!-- 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;

View File

@@ -490,14 +490,100 @@
</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>
<span>📬 消息推送</span>
</template>
<div class="strategy-section">
<el-divider content-position="left">推送通道配置</el-divider>
<!-- 飞书 -->
<el-form-item label="飞书 Webhook">
<el-input v-model="configs.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="max-width: 500px" />
<div class="form-tip">飞书机器人 Webhook URL配置后发送卡片消息到群聊</div>
<div class="form-tip" style="color: var(--el-color-primary); font-size: 12px; margin-top: 2px;">
优先从环境变量 FEISHU_WEBHOOK 读取其次读取此配置
</div>
</el-form-item>
<!-- Server酱 -->
<el-form-item label="Server酱 (微信)">
<el-input v-model="configs.serverchan_key" placeholder="SendKey" style="max-width: 300px" />
<div class="form-tip">通过 <a href="https://sct.ftqq.com" target="_blank" rel="noopener" style="color: var(--primary-color)">Server酱</a> 推送到微信只需填写 SendKey</div>
</el-form-item>
<!-- Bark -->
<el-form-item label="Bark (iOS)">
<el-input v-model="configs.bark_key" placeholder="xxxxxxxxxxxxxxxxxxxxxx" style="max-width: 300px" />
<div class="form-tip" style="margin-bottom: 4px;">通过 <a href="https://bark.day.app" target="_blank" rel="noopener" style="color: var(--primary-color)">Bark</a> 推送到 iOS 设备填写 API Key</div>
<div class="field-label-row">
<span class="field-label" style="font-size:12px; color:#909399;">自定义服务器</span>
<el-input v-model="configs.bark_server" placeholder="https://api.day.app" style="max-width: 280px" />
</div>
</el-form-item>
<!-- Telegram -->
<el-form-item label="Telegram">
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
<el-input v-model="configs.telegram_bot_token" placeholder="123456:ABC-DEF" style="max-width: 300px" />
<span style="font-size:12px; color:#909399;">Bot Token</span>
<el-input v-model="configs.telegram_chat_id" placeholder="@频道或 -100..." style="max-width: 200px" />
<span style="font-size:12px; color:#909399;">Chat ID</span>
</div>
<div class="form-tip">通过 TG Bot 推送消息需先创建 Bot 并获取 Token</div>
</el-form-item>
<!-- 自定义 Webhook -->
<el-form-item label="自定义 Webhook">
<el-input v-model="configs.webhook_url" placeholder="https://example.com/webhook" style="max-width: 500px" />
<div class="form-tip">POST JSON 到指定 URL格式{title, content, level, source: "CloudSearch"}</div>
</el-form-item>
<el-divider content-position="left">推送事件开关</el-divider>
<div class="strategy-grid" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));">
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> 转存成功</span>
<el-switch v-model="configs.notify_on_save_success" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">转存成功时推送通知</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> 转存连续失败</span>
<el-switch v-model="configs.notify_on_save_fail" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">连续失败 3 次后推送通知</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> Cookie 过期</span>
<el-switch v-model="configs.notify_on_cookie_expire" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">Cookie 过期时推送提醒</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label">🧹 清理完成</span>
<el-switch v-model="configs.notify_on_cleanup" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">每日自动清理完成时推送</div>
</div>
</div>
</div>
</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>
@@ -1097,16 +1183,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 {