chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
51
.env.example
Executable file
51
.env.example
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
# ============================================================
|
||||||
|
# CloudSearch 环境变量配置示例 (v2.1 优化版)
|
||||||
|
# 复制此文件为 .env 并修改对应值
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- 必需:管理员账号(请务必修改默认密码)---
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||||
|
|
||||||
|
# --- 必需:JWT 密钥(使用随机值,勿用默认值)---
|
||||||
|
# 生成命令: openssl rand -hex 32
|
||||||
|
JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||||
|
|
||||||
|
# --- 推荐:Cookie 加密密钥(保护网盘凭证)---
|
||||||
|
# 生成命令: openssl rand -hex 32
|
||||||
|
# 不设置则每次重启生成临时密钥(旧数据不可解密)
|
||||||
|
COOKIE_ENCRYPTION_KEY=change-me-to-a-random-key
|
||||||
|
|
||||||
|
# --- CORS 访问控制(生产环境必须设置真实域名)---
|
||||||
|
# 示例: CORS_ORIGIN=https://search.example.com
|
||||||
|
CORS_ORIGIN=https://your-production-domain.com
|
||||||
|
|
||||||
|
# --- Redis 连接(可选,无 Redis 时自动降级)---
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# --- PanSou 搜索引擎地址(容器内无需修改)---
|
||||||
|
PANSOU_URL=http://pansou:80
|
||||||
|
PANSOU_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# --- 视频解析服务地址(容器内无需修改)---
|
||||||
|
VIDEO_PARSER_URL=http://video-parser:3001
|
||||||
|
|
||||||
|
# --- 链接验证配置 ---
|
||||||
|
VALIDATION_CONCURRENCY=10
|
||||||
|
VALIDATION_TIMEOUT_MS=5000
|
||||||
|
VALIDATION_CACHE_TTL_VALID=600
|
||||||
|
VALIDATION_CACHE_TTL_INVALID=1800
|
||||||
|
|
||||||
|
# --- 日志级别 (debug|info|warn|error) ---
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- 可选:TMDB API Token(用于影视元数据)---
|
||||||
|
# 注册: https://www.themoviedb.org/settings/api
|
||||||
|
# TMDB_API_TOKEN=
|
||||||
|
|
||||||
|
# --- 可选:代理设置 ---
|
||||||
|
# HTTP_PROXY=http://proxy:8080
|
||||||
|
# HTTPS_PROXY=http://proxy:8080
|
||||||
|
|
||||||
|
# --- 站点访问地址(用于 CORS 和 SEO)---
|
||||||
|
SITE_URL=https://your-production-domain.com
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.h5/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vite/
|
||||||
|
*.bak
|
||||||
|
*.bak2
|
||||||
|
*.bak3
|
||||||
|
*.tar.gz
|
||||||
|
*.tar
|
||||||
|
deploy.sh
|
||||||
|
backup.sh
|
||||||
|
check.sh
|
||||||
|
icons/
|
||||||
|
scripts/
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.bak
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.yml.bak
|
||||||
|
pansou-web-latest.tar.gz
|
||||||
|
video-parser-latest.tar.gz
|
||||||
|
cloudsearch-app-v2.0.26.tar.gz
|
||||||
|
uploads/
|
||||||
32
README.md
Executable file
32
README.md
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
# CloudSearch — 网盘搜索 + 视频解析一站式平台
|
||||||
|
|
||||||
|
## 快速部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 解压
|
||||||
|
tar xzf cloudsearch-deploy.tar.gz
|
||||||
|
cd cloudsearch
|
||||||
|
|
||||||
|
# 2. 配置环境变量
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 首次使用
|
||||||
|
|
||||||
|
1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的)
|
||||||
|
2. 在「网盘配置」中添加夸克网盘 Cookie
|
||||||
|
3. 在「推广管理」中添加首页推广内容(可选)
|
||||||
|
4. 返回首页即可开始搜索
|
||||||
4147
package-lock.json
generated
Normal file
4147
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Executable file
41
package.json
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudsearch-backend",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"https-proxy-agent": "^9.0.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"playwright": "^1.52.0",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4200
packages/backend/package-lock.json
generated
Executable file
4200
packages/backend/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
43
packages/backend/package.json
Executable file
43
packages/backend/package.json
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudsearch-backend",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^11.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"https-proxy-agent": "^9.0.0",
|
||||||
|
"socks-proxy-agent": "^9.0.0",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"playwright": "^1.52.0",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
76
packages/backend/src/admin/auth.service.ts
Executable file
76
packages/backend/src/admin/auth.service.ts
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import config from '../config';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
|
||||||
|
export interface AuthPayload {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(username: string, password: string): string | null {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT id, username, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const valid = bcrypt.compareSync(password, row.password_hash);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
db.prepare('UPDATE admins SET last_login = datetime(\'now\') WHERE id = ?').run(row.id);
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const payload: AuthPayload = { id: row.id, username: row.username };
|
||||||
|
const token = jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' });
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): AuthPayload | null {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, config.jwtSecret) as AuthPayload;
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ error: 'Missing or invalid authorization header', code: 401 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ error: 'Invalid or expired token', code: 401 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(req as any).user = payload;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changePassword(username: string, oldPassword: string, newPassword: string): { success: boolean; message: string } {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT id, password_hash FROM admins WHERE username = ?').get(username) as any;
|
||||||
|
if (!row) {
|
||||||
|
return { success: false, message: '用户不存在' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = bcrypt.compareSync(oldPassword, row.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
return { success: false, message: '原密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = bcrypt.genSaltSync(10);
|
||||||
|
const hash = bcrypt.hashSync(newPassword, salt);
|
||||||
|
db.prepare("UPDATE admins SET password_hash = ? WHERE id = ?").run(hash, row.id);
|
||||||
|
return { success: true, message: '密码修改成功' };
|
||||||
|
}
|
||||||
161
packages/backend/src/admin/stats.service.ts
Executable file
161
packages/backend/src/admin/stats.service.ts
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { formatLocalDate } from '../utils/time';
|
||||||
|
|
||||||
|
export interface AdminStats {
|
||||||
|
todaySearches: number;
|
||||||
|
todaySaves: number;
|
||||||
|
monthSearches: number;
|
||||||
|
monthSaves: number;
|
||||||
|
totalSearches: number;
|
||||||
|
totalSaves: number;
|
||||||
|
hotKeywords: Array<{ keyword: string; count: number }>;
|
||||||
|
trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }>;
|
||||||
|
cloudUsage: Array<{
|
||||||
|
cloudType: string;
|
||||||
|
nickname: string;
|
||||||
|
storageUsed: string;
|
||||||
|
storageTotal: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}>;
|
||||||
|
topIps: Array<{ ip: string; ip_location: string | null; count: number }>;
|
||||||
|
provinceRankings: Array<{ province: string; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's date string in the configured timezone (e.g. "2026-05-04").
|
||||||
|
* Delegates to shared formatLocalDate() in utils/time.ts.
|
||||||
|
*/
|
||||||
|
function todayLocalDate(): string {
|
||||||
|
return formatLocalDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first day of the current month in the configured timezone.
|
||||||
|
*/
|
||||||
|
function monthStartLocalDate(): string {
|
||||||
|
return todayLocalDate().slice(0, 7) + '-01';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStats(trendDays: number = 7): AdminStats {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Use local timezone date — NOT UTC via toISOString()
|
||||||
|
const today = todayLocalDate();
|
||||||
|
const monthStart = monthStartLocalDate();
|
||||||
|
|
||||||
|
// IMPORTANT: created_at is stored as "YYYY-MM-DDTHH:mm:ss+08:00" (localTimestamp)
|
||||||
|
// SQLite's date() function would interpret the +08:00 timezone offset and
|
||||||
|
// convert to UTC, giving wrong date. Instead, use SUBSTR to get first 10 chars.
|
||||||
|
const todaySearchesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||||
|
).get(today) as any;
|
||||||
|
|
||||||
|
const todaySavesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||||
|
).get(today) as any;
|
||||||
|
|
||||||
|
const monthSearchesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) >= ?"
|
||||||
|
).get(monthStart) as any;
|
||||||
|
|
||||||
|
const monthSavesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) >= ?"
|
||||||
|
).get(monthStart) as any;
|
||||||
|
|
||||||
|
// Total searches
|
||||||
|
const totalSearchesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM search_stats"
|
||||||
|
).get() as any;
|
||||||
|
|
||||||
|
// Total saves
|
||||||
|
const totalSavesRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM save_records"
|
||||||
|
).get() as any;
|
||||||
|
|
||||||
|
// Hot keywords
|
||||||
|
const hotKeywords = db.prepare(
|
||||||
|
'SELECT keyword, search_count as count FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||||
|
).all() as Array<{ keyword: string; count: number }>;
|
||||||
|
|
||||||
|
// Trend data (configurable days, default 7)
|
||||||
|
const trendLen = Math.min(Math.max(trendDays, 1), 90);
|
||||||
|
const trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }> = [];
|
||||||
|
for (let i = trendLen - 1; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
const target = new Date(d.getTime() - i * 86400000);
|
||||||
|
const dateStr = formatLocalDate(target);
|
||||||
|
const searchRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||||
|
).get(dateStr) as any;
|
||||||
|
const saveRow = db.prepare(
|
||||||
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
||||||
|
).get(dateStr) as any;
|
||||||
|
trendTrend.push({
|
||||||
|
date: dateStr,
|
||||||
|
searches: searchRow?.count || 0,
|
||||||
|
saves: saveRow?.count || 0,
|
||||||
|
searchDelta: 0,
|
||||||
|
saveDelta: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Compute day-over-day delta (absolute change from previous day)
|
||||||
|
for (let i = trendTrend.length - 1; i > 0; i--) {
|
||||||
|
const prev = trendTrend[i - 1];
|
||||||
|
const curr = trendTrend[i];
|
||||||
|
curr.searchDelta = curr.searches - prev.searches;
|
||||||
|
curr.saveDelta = curr.saves - prev.saves;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud usage
|
||||||
|
const cloudUsage = db.prepare(
|
||||||
|
'SELECT cloud_type as cloudType, nickname, storage_used as storageUsed, storage_total as storageTotal, is_active as isActive FROM cloud_configs ORDER BY id ASC'
|
||||||
|
).all() as Array<{
|
||||||
|
cloudType: string;
|
||||||
|
nickname: string;
|
||||||
|
storageUsed: string;
|
||||||
|
storageTotal: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Top IPs from save_records — correctly count total per IP, then get latest location
|
||||||
|
const topIps = db.prepare(
|
||||||
|
`SELECT ip_address as ip, COUNT(*) as count,
|
||||||
|
(SELECT ip_location FROM save_records s2
|
||||||
|
WHERE s2.ip_address = s1.ip_address AND s2.ip_location IS NOT NULL AND s2.ip_location != ''
|
||||||
|
ORDER BY s2.created_at DESC LIMIT 1) as ip_location
|
||||||
|
FROM save_records s1
|
||||||
|
WHERE ip_address IS NOT NULL AND ip_address != ''
|
||||||
|
GROUP BY ip_address
|
||||||
|
ORDER BY count DESC LIMIT 10`
|
||||||
|
).all() as Array<{ ip: string; ip_location: string | null; count: number }>;
|
||||||
|
|
||||||
|
// Province rankings — extract province from ip_location (first segment)
|
||||||
|
let provinceRankings: Array<{ province: string; count: number }> = [];
|
||||||
|
const locRows = db.prepare(
|
||||||
|
`SELECT ip_location FROM save_records WHERE ip_location IS NOT NULL AND ip_location != ''`
|
||||||
|
).all() as Array<{ ip_location: string }>;
|
||||||
|
const provMap = new Map<string, number>();
|
||||||
|
for (const row of locRows) {
|
||||||
|
const parts = row.ip_location.trim().split(/\s+/);
|
||||||
|
const province = parts[0] || '未知';
|
||||||
|
provMap.set(province, (provMap.get(province) || 0) + 1);
|
||||||
|
}
|
||||||
|
provinceRankings = Array.from(provMap.entries())
|
||||||
|
.map(([province, count]) => ({ province, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 15);
|
||||||
|
|
||||||
|
return {
|
||||||
|
todaySearches: (todaySearchesRow as any)?.count || 0,
|
||||||
|
todaySaves: (todaySavesRow as any)?.count || 0,
|
||||||
|
monthSearches: (monthSearchesRow as any)?.count || 0,
|
||||||
|
monthSaves: (monthSavesRow as any)?.count || 0,
|
||||||
|
totalSearches: (totalSearchesRow as any)?.count || 0,
|
||||||
|
totalSaves: (totalSavesRow as any)?.count || 0,
|
||||||
|
hotKeywords,
|
||||||
|
trendTrend,
|
||||||
|
cloudUsage,
|
||||||
|
topIps,
|
||||||
|
provinceRankings,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
packages/backend/src/admin/system-config.service.ts
Executable file
40
packages/backend/src/admin/system-config.service.ts
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { localTimestamp } from '../utils/time';
|
||||||
|
|
||||||
|
export interface SystemConfigEntry {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSystemConfigs(): SystemConfigEntry[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT key, value, description, updated_at FROM system_configs ORDER BY key').all() as SystemConfigEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemConfig(key: string): string | null {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get(key) as { value: string } | undefined;
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemConfig(key: string, value: string): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||||
|
).run(value, localTimestamp(), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSystemConfigs(entries: { key: string; value: string }[]): void {
|
||||||
|
const db = getDb();
|
||||||
|
const update = db.prepare(
|
||||||
|
"UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?"
|
||||||
|
);
|
||||||
|
const tx = db.transaction((items: { key: string; value: string }[]) => {
|
||||||
|
for (const item of items) {
|
||||||
|
update.run(item.value, localTimestamp(), item.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx(entries);
|
||||||
|
}
|
||||||
615
packages/backend/src/cloud/admin.routes.ts
Normal file
615
packages/backend/src/cloud/admin.routes.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
import fs from "fs";
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||||
|
import { getSaveRecords } from '../cloud/cloud.service';
|
||||||
|
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||||
|
// Note: check-in routes were removed (sign-in feature removed)
|
||||||
|
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||||
|
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||||
|
import { getStats } from '../admin/stats.service';
|
||||||
|
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { testProxyConnection } from '../utils/proxy-agent';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
|
||||||
|
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||||
|
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Public routes (no auth required)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/login
|
||||||
|
* Admin login
|
||||||
|
*/
|
||||||
|
router.post('/admin/login', loginLimiter, (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = login(username, password);
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Login] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/cloud-types
|
||||||
|
* List all cloud types (public, read-only).
|
||||||
|
*/
|
||||||
|
router.get('/admin/cloud-types', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const types = getAllCloudTypes();
|
||||||
|
res.json({ types });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// QR Login routes (no auth — user not logged in yet)
|
||||||
|
// MUST be before authMiddleware!
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// ===== 夸克扫码登录 =====
|
||||||
|
router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result = await getQrLoginStatus(sessionId);
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
await cancelQrLogin(sessionId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 百度扫码登录 =====
|
||||||
|
router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await BaiduDriver.startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result: any = await BaiduDriver.getQrLoginStatus(sessionId);
|
||||||
|
// Map to frontend-expected format (frontend reads data.cookie)
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
status: result.status,
|
||||||
|
cookie: result.cookie || result.access_token || "",
|
||||||
|
nickname: result.nickname || "",
|
||||||
|
storage_used: result.storage_used || "",
|
||||||
|
storage_total: result.storage_total || "",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
BaiduDriver.cancelQrLogin(req.params.sessionId as string);
|
||||||
|
} catch {}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Auth wall — all routes below require JWT
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
router.use('/admin', authMiddleware);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Configs CRUD
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/cloud-configs — list all cloud configs */
|
||||||
|
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getCloudConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
|
||||||
|
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
if (!data.cloud_type) {
|
||||||
|
res.status(400).json({ error: 'cloud_type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
||||||
|
// Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0;
|
||||||
|
const saved = saveCloudConfig(data);
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */
|
||||||
|
router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const existing = getCloudConfigById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = saveCloudConfig({ ...req.body, id });
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/admin/cloud-configs/:id */
|
||||||
|
router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const ok = deleteCloudConfig(id);
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to delete cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||||
|
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const type = req.params.type as string;
|
||||||
|
const { cookie, id } = req.body;
|
||||||
|
|
||||||
|
// If cookie is provided directly, test with it (for new configs not yet saved)
|
||||||
|
if (cookie) {
|
||||||
|
const result = await testCloudConnectionWithCookie(type, cookie);
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise test by config id
|
||||||
|
if (id) {
|
||||||
|
const result = await testCloudConnection(parseInt(id));
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ success: false, message: 'Provide either cookie or id' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, message: err.message || 'Connection test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Stats
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/stats */
|
||||||
|
router.get('/admin/stats', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const days = req.query.days ? parseInt(req.query.days as string) : 7;
|
||||||
|
const stats = getStats(days);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Save Records (转存日志)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/save-records */
|
||||||
|
router.get('/admin/save-records', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
|
const startDate = req.query.startDate as string | undefined;
|
||||||
|
const endDate = req.query.endDate as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const sourceType = req.query.sourceType as string | undefined;
|
||||||
|
const keyword = req.query.keyword as string | undefined;
|
||||||
|
const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get save records' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// System Configs
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/system-configs */
|
||||||
|
router.get('/admin/system-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getAllSystemConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/system-configs — batch update */
|
||||||
|
router.put('/admin/system-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { entries } = req.body;
|
||||||
|
if (!entries || !Array.isArray(entries)) {
|
||||||
|
res.status(400).json({ error: 'entries array is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateSystemConfigs(entries);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Types Toggle
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */
|
||||||
|
router.put('/admin/cloud-types', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, enabled } = req.body;
|
||||||
|
if (!type) {
|
||||||
|
res.status(400).json({ error: 'type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||||
|
).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to toggle cloud type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Change Password
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/change-password */
|
||||||
|
router.post('/admin/change-password', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { oldPassword, newPassword } = req.body;
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
res.status(400).json({ error: 'Both old and new passwords are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get username from JWT
|
||||||
|
const authHeader = req.headers.authorization || '';
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = changePassword(payload.username, oldPassword, newPassword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to change password' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// DB Status
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/db-status */
|
||||||
|
router.get('/admin/db-status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dbFile = getSystemConfig('db_path') || '';
|
||||||
|
let dbSize = 'N/A';
|
||||||
|
if (dbFile) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(dbFile);
|
||||||
|
dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const counts = {
|
||||||
|
save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0,
|
||||||
|
search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0,
|
||||||
|
system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0,
|
||||||
|
cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0,
|
||||||
|
content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis status
|
||||||
|
let redis_status = 'disconnected';
|
||||||
|
let redis_url = getSystemConfig('redis_url') || '';
|
||||||
|
try {
|
||||||
|
const testResult = await testRedisConnection(redis_url);
|
||||||
|
redis_status = testResult.ok ? 'connected' : 'disconnected';
|
||||||
|
} catch {
|
||||||
|
redis_status = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
db_size: dbSize,
|
||||||
|
db_path: dbFile,
|
||||||
|
...counts,
|
||||||
|
redis_status,
|
||||||
|
redis_url,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get DB status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test Redis Connection
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-redis */
|
||||||
|
router.post('/admin/test-redis', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ ok: false, info: 'Redis URL is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testRedisConnection(url);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'Redis test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test External Service
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-external-service */
|
||||||
|
router.post('/admin/test-external-service', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, url, token } = req.body;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'pansou': {
|
||||||
|
const pansouUrl = url || getSystemConfig('pansou_url') || '';
|
||||||
|
if (!pansouUrl) {
|
||||||
|
res.json({ ok: false, info: 'PanSou URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok && data?.status === 'ok',
|
||||||
|
latency,
|
||||||
|
info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video_parser': {
|
||||||
|
const parserUrl = url || getSystemConfig('video_parser_url') || '';
|
||||||
|
if (!parserUrl) {
|
||||||
|
res.json({ ok: false, info: 'Video Parser URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tmdb': {
|
||||||
|
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
|
||||||
|
if (!tmdbToken) {
|
||||||
|
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch('https://api.themoviedb.org/3/configuration', {
|
||||||
|
headers: { Authorization: `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
const proxyUrl = url || getSystemConfig('search_proxy_url') || '';
|
||||||
|
if (!proxyUrl) {
|
||||||
|
res.json({ ok: false, info: 'Proxy URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testProxyConnection(proxyUrl);
|
||||||
|
res.json(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ip_geo': {
|
||||||
|
const geoUrl = url || getSystemConfig('ip_geo_api_url') || '';
|
||||||
|
if (!geoUrl) {
|
||||||
|
res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const testUrl = geoUrl.replace('{ip}', '8.8.8.8');
|
||||||
|
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
const valid = !!(data?.country || data?.region || data?.city || data?.countryCode);
|
||||||
|
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
res.json({ ok: false, info: `Unknown service type: ${type}` });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'External service test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Pansou Info & Update
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/pansou-info — pansou health + version + update check */
|
||||||
|
router.get('/admin/pansou-info', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getSystemConfig('pansou_url') || '';
|
||||||
|
if (!baseUrl) {
|
||||||
|
res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PanSou health
|
||||||
|
const healthUrl = baseUrl + '/api/health';
|
||||||
|
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const healthData: any = await response.json();
|
||||||
|
const channelCount = healthData.channels_count || 0;
|
||||||
|
const pluginCount = healthData.plugin_count || 0;
|
||||||
|
|
||||||
|
// Derive disk count from channel names
|
||||||
|
const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease'];
|
||||||
|
const drives = new Set<string>();
|
||||||
|
for (const ch of (healthData.channels || [])) {
|
||||||
|
for (const kw of driveKeywords) {
|
||||||
|
if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diskCount = drives.size || 5;
|
||||||
|
|
||||||
|
// Get local version from docker label
|
||||||
|
let version = '';
|
||||||
|
let hasUpdate = false;
|
||||||
|
let latestVersion = '';
|
||||||
|
try {
|
||||||
|
const created = execSync(
|
||||||
|
`docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`,
|
||||||
|
{ timeout: 5000, encoding: 'utf8' }
|
||||||
|
).trim();
|
||||||
|
version = created ? created.slice(0, 10) : '';
|
||||||
|
|
||||||
|
// Check update cache
|
||||||
|
const cacheFile = '/tmp/pansou-update-cache.json';
|
||||||
|
let cache: any = null;
|
||||||
|
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {}
|
||||||
|
const threeDays = 3 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
if (!cache || (Date.now() - cache.checkedAt) > threeDays) {
|
||||||
|
// Check GHCR for latest version
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch(
|
||||||
|
'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io'
|
||||||
|
);
|
||||||
|
const ghcrToken = (await tokenRes.json() as any).token;
|
||||||
|
const manifestRes = await fetch(
|
||||||
|
'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest',
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } }
|
||||||
|
);
|
||||||
|
const manifestList: any = await manifestRes.json();
|
||||||
|
const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux');
|
||||||
|
if (amd64) {
|
||||||
|
const blobRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } }
|
||||||
|
);
|
||||||
|
const blobData: any = await blobRes.json();
|
||||||
|
const cfgDigest = blobData.config?.digest;
|
||||||
|
if (cfgDigest) {
|
||||||
|
const cfgRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}` } }
|
||||||
|
);
|
||||||
|
const cfgData: any = await cfgRes.json();
|
||||||
|
const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created'];
|
||||||
|
if (remoteCreated) {
|
||||||
|
latestVersion = remoteCreated.slice(0, 10);
|
||||||
|
if (version && latestVersion !== version) hasUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion }));
|
||||||
|
} else {
|
||||||
|
hasUpdate = cache.hasUpdate;
|
||||||
|
latestVersion = cache.latestVersion;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: response.ok ? 'connected' : 'disconnected',
|
||||||
|
channelCount,
|
||||||
|
pluginCount,
|
||||||
|
diskCount,
|
||||||
|
version,
|
||||||
|
hasUpdate,
|
||||||
|
latestVersion,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */
|
||||||
|
router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 });
|
||||||
|
execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 });
|
||||||
|
try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {}
|
||||||
|
res.json({ success: true, message: 'PanSou 更新成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
254
packages/backend/src/cloud/cleanup.service.ts
Executable file
254
packages/backend/src/cloud/cleanup.service.ts
Executable file
@@ -0,0 +1,254 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { getSystemConfig, updateSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||||
|
import { QuarkDriver } from './drivers/quark.driver';
|
||||||
|
import { BaiduDriver } from './drivers/baidu.driver';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// CloudCleanupDriver — contract that each cloud driver must fulfill
|
||||||
|
// to participate in the cleanup cycle.
|
||||||
|
//
|
||||||
|
// To add a new cloud type (e.g. Baidu, Aliyun), implement these three
|
||||||
|
// methods in the driver and register it in getDriverForCleanup() below.
|
||||||
|
// The controller (this file) handles WHEN and WITH WHAT parameters;
|
||||||
|
// the driver handles HOW.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Each cleanup operation returns { trashed: number; errors: string[] } */
|
||||||
|
interface CleanupOpResult { trashed: number; errors: string[] }
|
||||||
|
|
||||||
|
interface CloudCleanupDriver {
|
||||||
|
/** Trash date folders (YYYY-MM-DD) older than `days`. */
|
||||||
|
cleanupOldDateFolders(days: number): Promise<CleanupOpResult>;
|
||||||
|
/**
|
||||||
|
* If used space exceeds thresholdPercent% of TOTAL capacity,
|
||||||
|
* delete oldest date folders until totalBytes * deletePercent/100
|
||||||
|
* of TOTAL capacity is freed.
|
||||||
|
* @param thresholdPercent — trigger when usage >= this % of total
|
||||||
|
* @param deletePercent — free this % of total capacity
|
||||||
|
*/
|
||||||
|
cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<CleanupOpResult>;
|
||||||
|
/** Permanently empty the recycle bin. */
|
||||||
|
emptyTrash(): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Driver factory — create the right driver for a given cloud config.
|
||||||
|
// When adding a new cloud type, add a case here.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function getDriverForCleanup(config: { cloud_type: string; cookie: string }): CloudCleanupDriver | null {
|
||||||
|
switch (config.cloud_type) {
|
||||||
|
case 'quark':
|
||||||
|
return new QuarkDriver({ cookie: config.cookie });
|
||||||
|
case 'baidu':
|
||||||
|
return new BaiduDriver({ cookie: config.cookie });
|
||||||
|
// case 'aliyun': return new AliyunDriver({ cookie: config.cookie });
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Cleanup controller — reads system configs and dispatches to each
|
||||||
|
// active cloud driver. Every driver receives the same parameters;
|
||||||
|
// the driver decides whether/how to act.
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface CleanupStats {
|
||||||
|
filesTrashed: number;
|
||||||
|
logsDeleted: number;
|
||||||
|
trashEmptied: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname FROM cloud_configs
|
||||||
|
WHERE is_active = 1 AND cookie IS NOT NULL AND cookie != ''`
|
||||||
|
).all() as Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch cleanupOldDateFolders to every active driver.
|
||||||
|
* Each driver receives the same `days` parameter.
|
||||||
|
*/
|
||||||
|
async function cleanupCloudFiles(days: number): Promise<CleanupOpResult> {
|
||||||
|
const configs = getActiveCleanupConfigs();
|
||||||
|
const errors: string[] = [];
|
||||||
|
let totalTrashed = 0;
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const driver = getDriverForCleanup(cfg);
|
||||||
|
if (!driver) {
|
||||||
|
console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await driver.cleanupOldDateFolders(days);
|
||||||
|
totalTrashed += result.trashed;
|
||||||
|
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupOldDateFolders: ${err.message}`);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trashed: totalTrashed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch cleanupBySpaceThreshold to every active driver.
|
||||||
|
* Each driver receives the same threshold/delete percentages.
|
||||||
|
*/
|
||||||
|
async function cleanupAllBySpaceThreshold(
|
||||||
|
thresholdPercent: number,
|
||||||
|
deletePercent: number,
|
||||||
|
): Promise<CleanupOpResult> {
|
||||||
|
const configs = getActiveCleanupConfigs();
|
||||||
|
const errors: string[] = [];
|
||||||
|
let totalTrashed = 0;
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const driver = getDriverForCleanup(cfg);
|
||||||
|
if (!driver) {
|
||||||
|
console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent);
|
||||||
|
totalTrashed += result.trashed;
|
||||||
|
errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`));
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupBySpaceThreshold: ${err.message}`);
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { trashed: totalTrashed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch emptyTrash to every active driver.
|
||||||
|
*/
|
||||||
|
export async function emptyAllTrash(): Promise<{ emptied: boolean; errors: string[] }> {
|
||||||
|
const configs = getActiveCleanupConfigs();
|
||||||
|
const errors: string[] = [];
|
||||||
|
let emptied = false;
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const driver = getDriverForCleanup(cfg);
|
||||||
|
if (!driver) continue;
|
||||||
|
try {
|
||||||
|
const ok = await driver.emptyTrash();
|
||||||
|
if (ok) {
|
||||||
|
emptied = true;
|
||||||
|
console.log(`[Cleanup] ✅ Emptied trash for [${cfg.cloud_type}#${cfg.id}]`);
|
||||||
|
} else {
|
||||||
|
errors.push(`[${cfg.cloud_type}#${cfg.id}] empty trash failed`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`[${cfg.cloud_type}#${cfg.id}]: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { emptied, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete save_records older than the specified number of days.
|
||||||
|
*/
|
||||||
|
function cleanupLogs(days: number): number {
|
||||||
|
const db = getDb();
|
||||||
|
const cutoffStr = formatLocalDateTime(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const result = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoffStr);
|
||||||
|
console.log(`[Cleanup] Deleted ${result.changes} save records older than ${days} days (before ${cutoffStr})`);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run full cleanup cycle:
|
||||||
|
* 0. Force-clean by space threshold (if enabled & exceeded) — priority highest
|
||||||
|
* 1. Delete old save_records
|
||||||
|
* 2. Trash old date folders by retention days
|
||||||
|
* 3. Empty recycle bin (permanently free space)
|
||||||
|
*/
|
||||||
|
export async function runFullCleanup(): Promise<CleanupStats> {
|
||||||
|
const fileDays = parseInt(getSystemConfig('cleanup_file_retention_days') || '7', 10);
|
||||||
|
const logDays = parseInt(getSystemConfig('cleanup_log_retention_days') || '30', 10);
|
||||||
|
const emptyTrashEnabled = getSystemConfig('cleanup_empty_trash') !== 'false';
|
||||||
|
|
||||||
|
const stats: CleanupStats = { filesTrashed: 0, logsDeleted: 0, trashEmptied: false, errors: [] };
|
||||||
|
|
||||||
|
// 0. Space threshold (highest priority)
|
||||||
|
const thresholdEnabled = getSystemConfig('cleanup_space_threshold_enabled');
|
||||||
|
if (thresholdEnabled === 'true') {
|
||||||
|
const thresholdPercent = parseInt(getSystemConfig('cleanup_space_threshold_percent') || '90', 10);
|
||||||
|
const deletePercent = parseInt(getSystemConfig('cleanup_space_threshold_delete_percent') || '10', 10);
|
||||||
|
if (thresholdPercent > 0 && thresholdPercent < 100) {
|
||||||
|
try {
|
||||||
|
const result = await cleanupAllBySpaceThreshold(thresholdPercent, deletePercent);
|
||||||
|
stats.filesTrashed += result.trashed;
|
||||||
|
stats.errors.push(...result.errors);
|
||||||
|
} catch (err: any) {
|
||||||
|
stats.errors.push(`空间阈值清理失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Delete old save_records
|
||||||
|
try {
|
||||||
|
stats.logsDeleted = cleanupLogs(logDays);
|
||||||
|
} catch (err: any) {
|
||||||
|
stats.errors.push(`日志清理失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Trash old files from cloud drives
|
||||||
|
try {
|
||||||
|
const result = await cleanupCloudFiles(fileDays);
|
||||||
|
stats.filesTrashed += result.trashed;
|
||||||
|
stats.errors.push(...result.errors);
|
||||||
|
} catch (err: any) {
|
||||||
|
stats.errors.push(`文件清理失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Empty recycle bin (only if enabled, and only if we trashed something)
|
||||||
|
if (emptyTrashEnabled && stats.filesTrashed > 0) {
|
||||||
|
try {
|
||||||
|
const result = await emptyAllTrash();
|
||||||
|
stats.trashEmptied = result.emptied;
|
||||||
|
stats.errors.push(...result.errors);
|
||||||
|
} catch (err: any) {
|
||||||
|
stats.errors.push(`清空回收站失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save last run timestamp and stats
|
||||||
|
updateSystemConfig('cleanup_last_run', formatLocalDateTime());
|
||||||
|
updateSystemConfig('cleanup_last_stats',
|
||||||
|
JSON.stringify({ filesTrashed: stats.filesTrashed, logsDeleted: stats.logsDeleted, trashEmptied: stats.trashEmptied, errors: stats.errors.length })
|
||||||
|
);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a daily cleanup is due and run it.
|
||||||
|
* Called periodically by the scheduler (setInterval).
|
||||||
|
*/
|
||||||
|
export async function checkAndRunScheduledCleanup(): Promise<void> {
|
||||||
|
const enabled = getSystemConfig('cleanup_enabled');
|
||||||
|
if (enabled !== 'true') return;
|
||||||
|
|
||||||
|
const lastRun = getSystemConfig('cleanup_last_run');
|
||||||
|
const todayStr = formatLocalDate();
|
||||||
|
|
||||||
|
if (lastRun && lastRun.startsWith(todayStr)) return;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
69
packages/backend/src/cloud/cloud-types.service.ts
Executable file
69
packages/backend/src/cloud/cloud-types.service.ts
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
export interface CloudTypeInfo {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网盘图标 — 使用打包进镜像的 PNG 图标文件
|
||||||
|
* 图标存放在 /app/dist/frontend/icons/,通过 Express static 中间件对外提供
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 网盘图标 — 内联 SVG data URI,无需外部文件
|
||||||
|
*/
|
||||||
|
function makeSvgIcon(bg: string, letter: string): string {
|
||||||
|
const c = encodeURIComponent(bg);
|
||||||
|
const l = encodeURIComponent(letter);
|
||||||
|
return `data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22${c}%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2213%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E${l}%3C%2Ftext%3E%3C%2Fsvg%3E`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
baidu: makeSvgIcon('#4e6ef2', '百'),
|
||||||
|
aliyun: makeSvgIcon('#ff6a00', '阿'),
|
||||||
|
quark: makeSvgIcon('#07c160', '夸'),
|
||||||
|
'115': makeSvgIcon('#9b59b6', '1'),
|
||||||
|
tianyi: makeSvgIcon('#00a1d6', '天'),
|
||||||
|
'123pan': makeSvgIcon('#e74c3c', '1'),
|
||||||
|
uc: makeSvgIcon('#f39c12', 'U'),
|
||||||
|
xunlei: makeSvgIcon('#2ecc71', '迅'),
|
||||||
|
pikpak: makeSvgIcon('#8e44ad', 'P'),
|
||||||
|
magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||||
|
ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||||
|
others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_CLOUD_TYPES: { type: string; label: string; icon: string }[] = [
|
||||||
|
{ type: 'quark', label: '夸克网盘', icon: ICONS.quark },
|
||||||
|
{ type: 'baidu', label: '百度网盘', icon: ICONS.baidu },
|
||||||
|
{ type: 'aliyun', label: '阿里云盘', icon: ICONS.aliyun },
|
||||||
|
{ type: '115', label: '115 网盘', icon: ICONS['115'] },
|
||||||
|
{ type: 'tianyi', label: '天翼云盘', icon: ICONS.tianyi },
|
||||||
|
{ type: '123pan', label: '123 云盘', icon: ICONS['123pan'] },
|
||||||
|
{ type: 'uc', label: 'UC 网盘', icon: ICONS.uc },
|
||||||
|
{ type: 'xunlei', label: '迅雷网盘', icon: ICONS.xunlei },
|
||||||
|
{ type: 'pikpak', label: 'PikPak', icon: ICONS.pikpak },
|
||||||
|
{ type: 'magnet', label: '磁力链接', icon: ICONS.magnet },
|
||||||
|
{ type: 'ed2k', label: '电驴链接', icon: ICONS.ed2k },
|
||||||
|
{ type: 'others', label: '其他', icon: ICONS.others },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isCloudTypeEnabled(type: string): boolean {
|
||||||
|
const val = getSystemConfig(`cloud_type_${type}_enabled`);
|
||||||
|
if (val === null) return type !== 'others';
|
||||||
|
return val === "true" || val === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCloudTypes(): CloudTypeInfo[] {
|
||||||
|
return ALL_CLOUD_TYPES.map(ct => ({ ...ct, enabled: isCloudTypeEnabled(ct.type) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnabledCloudTypeSet(): Set<string> {
|
||||||
|
const enabled = new Set<string>();
|
||||||
|
for (const ct of ALL_CLOUD_TYPES) {
|
||||||
|
if (isCloudTypeEnabled(ct.type)) enabled.add(ct.type);
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
323
packages/backend/src/cloud/cloud.service.ts
Normal file
323
packages/backend/src/cloud/cloud.service.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { localTimestamp, formatLocalDateTime } from '../utils/time';
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { QuarkDriver } from './drivers/quark.driver';
|
||||||
|
import { BaiduDriver } from './drivers/baidu.driver';
|
||||||
|
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||||
|
import { lookupIpLocation } from './ip-lookup';
|
||||||
|
|
||||||
|
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||||
|
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||||
|
|
||||||
|
export interface SaveResult {
|
||||||
|
success: boolean;
|
||||||
|
shareUrl?: string;
|
||||||
|
share_url?: string;
|
||||||
|
sharePwd?: string;
|
||||||
|
folderName?: string;
|
||||||
|
message: string;
|
||||||
|
file_count?: number;
|
||||||
|
folder_count?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRecord {
|
||||||
|
id: number;
|
||||||
|
source_type: string;
|
||||||
|
source_title: string | null;
|
||||||
|
source_url: string;
|
||||||
|
target_cloud: string;
|
||||||
|
share_url: string | null;
|
||||||
|
share_pwd: string | null;
|
||||||
|
file_size: string | null;
|
||||||
|
file_count: number;
|
||||||
|
folder_count: number;
|
||||||
|
duration_ms: number;
|
||||||
|
status: string;
|
||||||
|
error_message: string | null;
|
||||||
|
folder_name: string | null;
|
||||||
|
original_folder_name: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
ip_location: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Core save logic extracted so inFlight dedup can wrap it */
|
||||||
|
async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||||
|
const db = getDb();
|
||||||
|
const ipLocation = await lookupIpLocation(ipAddress || '');
|
||||||
|
|
||||||
|
// ── Short-term dedup: prevent duplicate saves of the same URL within 60 seconds ──
|
||||||
|
const DEDUP_WINDOW_SEC = 60;
|
||||||
|
let dedupCutoff = '';
|
||||||
|
try {
|
||||||
|
const recentCutoff = db.prepare(
|
||||||
|
`SELECT datetime('now','localtime', '-${DEDUP_WINDOW_SEC} seconds') as cutoff`
|
||||||
|
).get() as { cutoff: string };
|
||||||
|
dedupCutoff = recentCutoff.cutoff;
|
||||||
|
|
||||||
|
const recentRecord = db.prepare(
|
||||||
|
`SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records
|
||||||
|
WHERE source_url = ? AND created_at >= ?
|
||||||
|
ORDER BY created_at DESC LIMIT 1`
|
||||||
|
).get(shareUrl, dedupCutoff) as {
|
||||||
|
share_url: string | null; share_pwd: string | null; status: string;
|
||||||
|
error_message: string | null; folder_name: string | null; original_folder_name: string | null;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (recentRecord) {
|
||||||
|
const alreadySaved = recentRecord.status === 'success' || recentRecord.status === 'reused';
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).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(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `🛡️ 此资源刚在 ${DEDUP_WINDOW_SEC} 秒内转存过,直接返回已有分享链接`,
|
||||||
|
share_url: recentRecord.share_url, shareUrl: recentRecord.share_url,
|
||||||
|
sharePwd: recentRecord.share_pwd || '', folderName: '',
|
||||||
|
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Share] Dedup check failed: ${err.message}, proceeding with normal save`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share link reuse: if same source URL was already saved successfully, validate and reuse ──
|
||||||
|
const reuseEnabled = getSystemConfig('save_reuse_enabled');
|
||||||
|
if (reuseEnabled !== 'false') {
|
||||||
|
try {
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records
|
||||||
|
WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != ''
|
||||||
|
ORDER BY created_at DESC LIMIT 1`
|
||||||
|
).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined;
|
||||||
|
|
||||||
|
if (existing?.share_url) {
|
||||||
|
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||||
|
const validator = new LinkValidator();
|
||||||
|
const validation = await validator.validate(existing.share_url, 'quark');
|
||||||
|
if (validation.status === 'valid') {
|
||||||
|
const isFirstReuse = dedupCutoff ? !db.prepare(
|
||||||
|
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
||||||
|
).get(shareUrl, dedupCutoff) : true;
|
||||||
|
const reuseStatus = isFirstReuse ? 'success' : 'reused';
|
||||||
|
const reuseMsg = isFirstReuse
|
||||||
|
? `♻️ 检测到此资源之前已转存过,直接复用已存在的分享链接`
|
||||||
|
: `♻️ 短时间内重复请求,复用已有分享链接`;
|
||||||
|
|
||||||
|
console.log(`[Share] ♻️ Reusing existing share link for ${shareUrl}: ${existing.share_url} (firstReuse=${isFirstReuse})`);
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
cloudType, sourceTitle || null, shareUrl, cloudType,
|
||||||
|
existing.share_url, existing.share_pwd || null,
|
||||||
|
null, 0, 0, 0, reuseStatus, null,
|
||||||
|
existing.folder_name || null, existing.original_folder_name || null,
|
||||||
|
ipAddress || null, ipLocation, localTimestamp(),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true, message: reuseMsg,
|
||||||
|
share_url: existing.share_url, shareUrl: existing.share_url,
|
||||||
|
sharePwd: existing.share_pwd || '', folderName: '',
|
||||||
|
file_count: 0, folder_count: 0, duration_ms: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Share] Link reuse check failed: ${err.message}, proceeding with normal save`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unified credential validation ──
|
||||||
|
const credential = await getAndValidateCredential(cloudType);
|
||||||
|
if (!credential.valid || !credential.config) {
|
||||||
|
return { success: false, message: credential.message };
|
||||||
|
}
|
||||||
|
const config = credential.config;
|
||||||
|
|
||||||
|
// ── Check transfer enabled ──
|
||||||
|
if (config.is_transfer_enabled === 0) {
|
||||||
|
return { success: false, message: `${config.nickname || cloudType} 的转存功能已关闭,请先在后台开启` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string };
|
||||||
|
|
||||||
|
switch (cloudType) {
|
||||||
|
case 'quark': {
|
||||||
|
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||||
|
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'baidu': {
|
||||||
|
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname });
|
||||||
|
driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'aliyun':
|
||||||
|
return { success: false, message: '阿里云盘保存功能暂未实现' };
|
||||||
|
default:
|
||||||
|
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (driverResult.success) {
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||||
|
).run(config.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
|
||||||
|
driverResult.shareUrl || null, driverResult.sharePwd || null,
|
||||||
|
null, driverResult.fileCount || 0, driverResult.folderCount || 0,
|
||||||
|
durationMs, driverResult.success ? 'success' : 'failed',
|
||||||
|
driverResult.success ? null : driverResult.message,
|
||||||
|
driverResult.folderName || null, driverResult.originalFolderName || null,
|
||||||
|
ipAddress || null, ipLocation, localTimestamp(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: driverResult.success,
|
||||||
|
message: driverResult.message,
|
||||||
|
share_url: driverResult.shareUrl || '',
|
||||||
|
shareUrl: driverResult.shareUrl,
|
||||||
|
sharePwd: (driverResult as any).sharePwd || '',
|
||||||
|
folderName: driverResult.folderName || '',
|
||||||
|
file_count: driverResult.fileCount || 0,
|
||||||
|
folder_count: driverResult.folderCount || 0,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
const errorMessage = err.message || 'Failed to save to cloud';
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||||
|
).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)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp());
|
||||||
|
|
||||||
|
return { success: false, message: errorMessage };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise<SaveResult> {
|
||||||
|
const key = `${cloudType}:${shareUrl}`;
|
||||||
|
|
||||||
|
const inflight = inFlightSaves.get(key);
|
||||||
|
if (inflight) {
|
||||||
|
console.log(`[Share] ⏳ In-flight: ${shareUrl} — another save is already running, awaiting result`);
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = doSaveFromShare(shareUrl, cloudType, sourceTitle, ipAddress);
|
||||||
|
inFlightSaves.set(key, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inFlightSaves.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save Records ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getSaveRecords(page: number = 1, pageSize: number = 20, startDate?: string, endDate?: string, status?: string, sourceType?: string, keyword?: string): { total: number; records: SaveRecord[]; summary?: { total: number; success: number; failed: number; reused: number } } {
|
||||||
|
const db = getDb();
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
const summaryConditions: string[] = [];
|
||||||
|
const summaryParams: any[] = [];
|
||||||
|
if (startDate) {
|
||||||
|
conditions.push('created_at >= ?'); params.push(startDate);
|
||||||
|
summaryConditions.push('created_at >= ?'); summaryParams.push(startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
conditions.push('created_at < ?'); params.push(endDate);
|
||||||
|
summaryConditions.push('created_at < ?'); summaryParams.push(endDate);
|
||||||
|
}
|
||||||
|
if (status) { conditions.push('status = ?'); params.push(status); }
|
||||||
|
if (sourceType) {
|
||||||
|
conditions.push('source_type = ?'); params.push(sourceType);
|
||||||
|
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 records = db.prepare(
|
||||||
|
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
).all(...params, pageSize, offset) as SaveRecord[];
|
||||||
|
|
||||||
|
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : '';
|
||||||
|
const summaryRows = db.prepare(
|
||||||
|
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status`
|
||||||
|
).all(...summaryParams) as { status: string; cnt: number }[];
|
||||||
|
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
|
||||||
|
for (const r of summaryRows) {
|
||||||
|
sumTotal += r.cnt;
|
||||||
|
if (r.status === 'success') sumSuccess = r.cnt;
|
||||||
|
else if (r.status === 'failed') sumFailed = r.cnt;
|
||||||
|
else if (r.status === 'reused') sumReused = r.cnt;
|
||||||
|
}
|
||||||
|
const summary = { total: sumTotal, success: sumSuccess, failed: sumFailed, reused: sumReused };
|
||||||
|
|
||||||
|
return { total, records, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupOldSaveRecords(): void {
|
||||||
|
const db = getDb();
|
||||||
|
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||||
|
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||||
|
console.log(`[Cleanup] Deleted ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Storage Refresh ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function refreshAllStorageInfo(): Promise<void> {
|
||||||
|
const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie);
|
||||||
|
if (configs.length === 0) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
472
packages/backend/src/cloud/credential.service.ts
Normal file
472
packages/backend/src/cloud/credential.service.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||||
|
import { encrypt, decrypt, isEncrypted } from '../utils/crypto';
|
||||||
|
|
||||||
|
// ── Background Used-Space Calculation ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget: recursively calculate used space for a quark drive
|
||||||
|
* and update the database when done.
|
||||||
|
*/
|
||||||
|
async function calculateUsedSpaceAsync(cookie: string, configId: number): Promise<void> {
|
||||||
|
const { calculateUsedSpace } = require('./drivers/quark-cleanup');
|
||||||
|
const usedBytes = await calculateUsedSpace(cookie);
|
||||||
|
if (usedBytes > 0) {
|
||||||
|
const usedFormatted = usedBytes >= 1024 ** 4
|
||||||
|
? (usedBytes / 1024 ** 4).toFixed(1) + ' TB'
|
||||||
|
: usedBytes >= 1024 ** 3
|
||||||
|
? (usedBytes / 1024 ** 3).toFixed(1) + ' GB'
|
||||||
|
: (usedBytes / 1024 ** 2).toFixed(1) + ' MB';
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET storage_used = ?, updated_at = ? WHERE id = ?`
|
||||||
|
).run(usedFormatted, localTimestamp(), configId);
|
||||||
|
console.log(`[UsedSpace] Updated config #${configId}: used=${usedFormatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudConfig {
|
||||||
|
id: number;
|
||||||
|
cloud_type: string;
|
||||||
|
cookie?: string;
|
||||||
|
nickname?: string;
|
||||||
|
is_active: number;
|
||||||
|
promotion_account?: string;
|
||||||
|
is_transfer_enabled: number;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
|
||||||
|
last_checkin_at?: string;
|
||||||
|
checkin_message?: string;
|
||||||
|
consecutive_failures: number;
|
||||||
|
last_used_at?: string;
|
||||||
|
total_saves: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
verification_status?: string;
|
||||||
|
cloud_type_uid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie Encryption Helper ──────────────────────────────────────
|
||||||
|
/** Decrypt cookie. Handles legacy plaintext data transparently. */
|
||||||
|
function decryptCookie(encrypted: string | null | undefined): string {
|
||||||
|
if (!encrypted) return '';
|
||||||
|
// If already plaintext (legacy data), return as-is
|
||||||
|
if (!isEncrypted(encrypted)) return encrypted;
|
||||||
|
return decrypt(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract Quark __uid from cookie string.
|
||||||
|
* Used for dedup: same cloud_type + same __uid = same account.
|
||||||
|
*/
|
||||||
|
function extractQuarkUid(cookie: string): string | null {
|
||||||
|
const match = cookie.match(/(?:^|;\s*)__uid=([^;]+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config CRUD ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getCloudConfigs(): CloudConfig[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||||
|
FROM cloud_configs ORDER BY id ASC`
|
||||||
|
).all() as CloudConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableClouds(): CloudConfig[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at
|
||||||
|
FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC`
|
||||||
|
).all() as CloudConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the first active config matching the given cloud type. */
|
||||||
|
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||||
|
FROM cloud_configs WHERE cloud_type = ? AND is_active = 1
|
||||||
|
ORDER BY id ASC LIMIT 1`
|
||||||
|
).get(cloudType) as CloudConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCloudConfigById(id: number): CloudConfig | undefined {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at, verification_status
|
||||||
|
FROM cloud_configs WHERE id = ?`
|
||||||
|
).get(id) as CloudConfig | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns all active cloud configs (used by save flow for cloud type switching). */
|
||||||
|
export function getActiveCloudConfigs(): CloudConfig[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at
|
||||||
|
FROM cloud_configs WHERE is_active = 1
|
||||||
|
ORDER BY cloud_type ASC, id ASC`
|
||||||
|
).all() as CloudConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCloudConfig(data: {
|
||||||
|
id?: number;
|
||||||
|
cloud_type: string;
|
||||||
|
cookie?: string;
|
||||||
|
nickname?: string;
|
||||||
|
is_active?: number;
|
||||||
|
promotion_account?: string;
|
||||||
|
is_transfer_enabled?: number;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
}): CloudConfig {
|
||||||
|
const db = getDb();
|
||||||
|
// Encrypt cookie before storing
|
||||||
|
const encryptedCookie = data.cookie ? encrypt(data.cookie) : null;
|
||||||
|
|
||||||
|
// Extract cloud_type_uid from cookie (Quark __uid)
|
||||||
|
let cloudTypeUid: string | null = null;
|
||||||
|
if (data.cookie) {
|
||||||
|
cloudTypeUid = extractQuarkUid(data.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.id) {
|
||||||
|
// Update by ID — always succeeds
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET
|
||||||
|
cloud_type = COALESCE(?, cloud_type),
|
||||||
|
cookie = COALESCE(?, cookie),
|
||||||
|
nickname = COALESCE(?, nickname),
|
||||||
|
is_active = COALESCE(?, is_active),
|
||||||
|
promotion_account = COALESCE(?, promotion_account),
|
||||||
|
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
|
||||||
|
storage_used = COALESCE(?, storage_used),
|
||||||
|
storage_total = COALESCE(?, storage_total),
|
||||||
|
cloud_type_uid = COALESCE(?, cloud_type_uid),
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
// Try to find existing config by cloud_type + cloud_type_uid
|
||||||
|
let existing: any = null;
|
||||||
|
if (cloudTypeUid) {
|
||||||
|
existing = db.prepare(
|
||||||
|
`SELECT id FROM cloud_configs WHERE cloud_type = ? AND cloud_type_uid = ? LIMIT 1`
|
||||||
|
).get(data.cloud_type, cloudTypeUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match by cloud_type alone (legacy records without cloud_type_uid)
|
||||||
|
if (!existing) {
|
||||||
|
existing = db.prepare(
|
||||||
|
'SELECT id FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1'
|
||||||
|
).get(data.cloud_type) as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET
|
||||||
|
cookie = COALESCE(?, cookie),
|
||||||
|
nickname = COALESCE(?, nickname),
|
||||||
|
is_active = COALESCE(?, is_active),
|
||||||
|
promotion_account = COALESCE(?, promotion_account),
|
||||||
|
is_transfer_enabled = COALESCE(?, is_transfer_enabled),
|
||||||
|
storage_used = COALESCE(?, storage_used),
|
||||||
|
storage_total = COALESCE(?, storage_total),
|
||||||
|
cloud_type_uid = COALESCE(?, cloud_type_uid),
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Re-read savedId for return
|
||||||
|
const savedId = existing.id;
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at
|
||||||
|
FROM cloud_configs WHERE id = ?`
|
||||||
|
).get(savedId) as CloudConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
|
||||||
|
cloud_type_uid,
|
||||||
|
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
|
||||||
|
last_used_at, total_saves, created_at, updated_at
|
||||||
|
FROM cloud_configs WHERE id = ?`
|
||||||
|
).get(savedId) as CloudConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCloudConfig(id: number): boolean {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie Validation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchQuarkNickname(cookie: string): Promise<string | null> {
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://pan.quark.cn/account/info?fr=pc&platform=pc', {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Referer': 'https://pan.quark.cn/',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data?.data?.nickname) return data.data.nickname;
|
||||||
|
} catch {
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testCloudConnection(id: number): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
nickname?: string;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
}> {
|
||||||
|
const config = getCloudConfigById(id);
|
||||||
|
if (!config) {
|
||||||
|
return { success: false, message: 'Cloud config not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.cookie) {
|
||||||
|
return { success: false, message: 'Cookie not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let valid = false;
|
||||||
|
let nickname = '';
|
||||||
|
let storageUsed = config.storage_used || '';
|
||||||
|
let storageTotal = config.storage_total || '';
|
||||||
|
|
||||||
|
if (config.cloud_type === 'baidu') {
|
||||||
|
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||||
|
const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname });
|
||||||
|
valid = await driver.validate();
|
||||||
|
if (valid) {
|
||||||
|
const info = await driver.getUserInfo();
|
||||||
|
if (info) {
|
||||||
|
nickname = config.nickname || info.nickname || '百度网盘';
|
||||||
|
const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB';
|
||||||
|
storageUsed = fmt(info.usedBytes);
|
||||||
|
storageTotal = fmt(info.totalBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const decodedCookie = decrypt(config.cookie);
|
||||||
|
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||||
|
const driver = new QuarkDriver({ cookie: decodedCookie, nickname: config.nickname });
|
||||||
|
valid = await driver.validate();
|
||||||
|
if (valid) {
|
||||||
|
nickname = config.nickname || (await fetchQuarkNickname(decodedCookie)) || '夸克网盘';
|
||||||
|
const storage = await driver.getStorageInfoQuick(config.storage_total);
|
||||||
|
storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || '');
|
||||||
|
storageUsed = (storage.used && storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
if (!valid) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||||
|
).run(localTimestamp(), id);
|
||||||
|
return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' };
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?`
|
||||||
|
).run(nickname, storageTotal, storageUsed, localTimestamp(), id);
|
||||||
|
|
||||||
|
// Fire-and-forget: recalculate used space in background (slow for big drives)
|
||||||
|
if (config.cloud_type === 'quark') {
|
||||||
|
calculateUsedSpaceAsync(decrypt(config.cookie), id).catch(err => console.error(`[UsedSpace] Background calc failed for #${id}:`, err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '连接成功',
|
||||||
|
nickname,
|
||||||
|
storage_used: storageUsed,
|
||||||
|
storage_total: storageTotal,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||||
|
).run(localTimestamp(), id);
|
||||||
|
} catch {}
|
||||||
|
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
nickname?: string;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||||
|
const driver = new QuarkDriver({ cookie, nickname: '' });
|
||||||
|
const valid = await driver.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return { success: false, message: '连接失败:Cookie 无效或已过期' };
|
||||||
|
}
|
||||||
|
const nickname = (await fetchQuarkNickname(cookie)) || cloudType;
|
||||||
|
// getStorageInfo may timeout from overseas servers, don't fail if it does
|
||||||
|
let storage: { used: string; total: string } = { used: '-', total: '-' };
|
||||||
|
try {
|
||||||
|
const s = await driver.getStorageInfoQuick();
|
||||||
|
if (s) {
|
||||||
|
storage = { used: s.used || '-', total: s.total || '-' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// storage info is optional
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '连接成功',
|
||||||
|
nickname,
|
||||||
|
storage_used: storage.used,
|
||||||
|
storage_total: storage.total,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: `连接失败:${err.message || '未知错误'}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unified Credential Validation ─────────────────────────────────
|
||||||
|
|
||||||
|
export interface CredentialValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
config?: CloudConfig;
|
||||||
|
errorCode?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and validate a credential for the given cloud type.
|
||||||
|
*
|
||||||
|
* This is the unified entry point for all save/transfer operations.
|
||||||
|
* It handles:
|
||||||
|
* 1. Finding an active config with < 5 consecutive failures (round-robin)
|
||||||
|
* 2. Validating cookie freshness via driver.validate()
|
||||||
|
* 3. Returning structured result with error codes
|
||||||
|
*
|
||||||
|
* Reference: search-ucmao get_and_validate_credential() pattern.
|
||||||
|
*/
|
||||||
|
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const config = db.prepare(
|
||||||
|
`SELECT * FROM cloud_configs
|
||||||
|
WHERE cloud_type = ? AND is_active = 1
|
||||||
|
AND consecutive_failures < 5
|
||||||
|
ORDER BY last_used_at ASC NULLS FIRST
|
||||||
|
LIMIT 1`
|
||||||
|
).get(cloudType) as CloudConfig | undefined;
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorCode: 'NO_AVAILABLE_DRIVE',
|
||||||
|
message: `Cloud type "${cloudType}" is not configured or no available drives`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.cookie) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorCode: 'COOKIE_MISSING',
|
||||||
|
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decrypt cookie before validation
|
||||||
|
const decryptedCookie = decryptCookie(config.cookie);
|
||||||
|
if (!decryptedCookie) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorCode: 'COOKIE_MISSING',
|
||||||
|
message: `Cookie not configured for ${cloudType} drive #${config.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookieValid = false;
|
||||||
|
if (cloudType === 'baidu') {
|
||||||
|
const { BaiduDriver } = require('./drivers/baidu.driver');
|
||||||
|
const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: config.nickname });
|
||||||
|
cookieValid = await driver.validate();
|
||||||
|
} else {
|
||||||
|
const { QuarkDriver } = require('./drivers/quark.driver');
|
||||||
|
const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: config.nickname });
|
||||||
|
cookieValid = await driver.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookieValid) {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?`
|
||||||
|
).run(localTimestamp(), config.id);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorCode: 'COOKIE_EXPIRED',
|
||||||
|
message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
config: { ...config, cookie: decryptedCookie },
|
||||||
|
message: 'ok',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorCode: 'VALIDATION_ERROR',
|
||||||
|
message: `Credential validation failed: ${err.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
327
packages/backend/src/cloud/database.ts
Executable file
327
packages/backend/src/cloud/database.ts
Executable file
@@ -0,0 +1,327 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import config from '../config';
|
||||||
|
import { formatLocalDateTime } from '../utils/time';
|
||||||
|
|
||||||
|
let db: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (db) return db;
|
||||||
|
|
||||||
|
const dbDir = path.dirname(config.dbPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
db = new Database(config.dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
runMigrations(db);
|
||||||
|
seedAdmin(db);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
last_login TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cloud_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cloud_type TEXT NOT NULL,
|
||||||
|
cookie TEXT,
|
||||||
|
nickname TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
storage_used TEXT,
|
||||||
|
storage_total TEXT,
|
||||||
|
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||||
|
last_checkin_at TEXT,
|
||||||
|
checkin_message TEXT,
|
||||||
|
consecutive_failures INTEGER DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
total_saves INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promotions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT,
|
||||||
|
position TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
click_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS save_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_type TEXT,
|
||||||
|
source_title TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
target_cloud TEXT,
|
||||||
|
share_url TEXT,
|
||||||
|
share_pwd TEXT,
|
||||||
|
file_size TEXT,
|
||||||
|
file_count INTEGER DEFAULT 0,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS search_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT,
|
||||||
|
intent TEXT,
|
||||||
|
result_count INTEGER DEFAULT 0,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hot_keywords (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT UNIQUE NOT NULL,
|
||||||
|
search_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_configs (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
cover TEXT,
|
||||||
|
source TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
seedSystemConfigs(db);
|
||||||
|
migrateSaveRecords(db);
|
||||||
|
migrateContentCache(db);
|
||||||
|
migrateCloudConfigs(db);
|
||||||
|
cleanupOldSaveRecords(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移: 给已有 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 {
|
||||||
|
const columns: { col: string; def: string }[] = [
|
||||||
|
{ col: 'douban_url', def: 'TEXT' },
|
||||||
|
{ col: 'rating', def: 'TEXT' },
|
||||||
|
{ col: 'rating_count', def: 'TEXT' },
|
||||||
|
{ col: 'year', def: 'TEXT' },
|
||||||
|
{ col: 'genres', def: 'TEXT' },
|
||||||
|
{ col: 'directors', def: 'TEXT' },
|
||||||
|
{ col: 'actors', def: 'TEXT' },
|
||||||
|
{ col: 'region', def: 'TEXT' },
|
||||||
|
{ col: 'duration', def: 'TEXT' },
|
||||||
|
];
|
||||||
|
for (const { col, def } of columns) {
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb
|
||||||
|
db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */
|
||||||
|
function migrateCloudConfigs(db: Database.Database): void {
|
||||||
|
// 加新列
|
||||||
|
const newCols: { col: string; def: string }[] = [
|
||||||
|
{ col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" },
|
||||||
|
{ col: 'last_checkin_at', def: 'TEXT' },
|
||||||
|
{ col: 'checkin_message', def: 'TEXT' },
|
||||||
|
{ col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
|
||||||
|
{ col: 'last_used_at', def: 'TEXT' },
|
||||||
|
{ col: 'total_saves', def: 'INTEGER DEFAULT 0' },
|
||||||
|
];
|
||||||
|
for (const { col, def } of newCols) {
|
||||||
|
try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {}
|
||||||
|
}
|
||||||
|
// 检查旧表是否有 UNIQUE 约束,有则重建表
|
||||||
|
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any;
|
||||||
|
if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cloud_configs_v2 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cloud_type TEXT NOT NULL,
|
||||||
|
cookie TEXT,
|
||||||
|
nickname TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
storage_used TEXT,
|
||||||
|
storage_total TEXT,
|
||||||
|
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||||
|
last_checkin_at TEXT,
|
||||||
|
checkin_message TEXT,
|
||||||
|
consecutive_failures INTEGER DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
total_saves INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
||||||
|
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||||
|
DROP TABLE cloud_configs;
|
||||||
|
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||||
|
`);
|
||||||
|
console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 2: Add verification_status column
|
||||||
|
const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get();
|
||||||
|
if (!row2) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
|
||||||
|
console.log('[DB] cloud_configs migration: verification_status column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 3: Add cloud_type_uid column (for Quark __uid dedup)
|
||||||
|
const row3 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get();
|
||||||
|
if (!row3) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL");
|
||||||
|
console.log('[DB] cloud_configs migration: cloud_type_uid column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 4: Add promotion_account column
|
||||||
|
const row4 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get();
|
||||||
|
if (!row4) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT ''");
|
||||||
|
console.log('[DB] cloud_configs migration: promotion_account column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 5: Add is_transfer_enabled column
|
||||||
|
const row5 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_transfer_enabled%'").get();
|
||||||
|
if (!row5) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
|
||||||
|
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAdmin(db: Database.Database): void {
|
||||||
|
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const salt = bcrypt.genSaltSync(10);
|
||||||
|
const hash = bcrypt.hashSync(config.adminPassword, salt);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||||
|
).run(config.adminUsername, hash);
|
||||||
|
|
||||||
|
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSystemConfigs(db: Database.Database): void {
|
||||||
|
const defaults: { key: string; value: string; description: string }[] = [
|
||||||
|
{ key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' },
|
||||||
|
{ key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' },
|
||||||
|
{ key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' },
|
||||||
|
{ key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' },
|
||||||
|
{ key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' },
|
||||||
|
{ key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' },
|
||||||
|
{ key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' },
|
||||||
|
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||||
|
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||||
|
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||||
|
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
||||||
|
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
||||||
|
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
||||||
|
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
||||||
|
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
||||||
|
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
||||||
|
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
||||||
|
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
||||||
|
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
||||||
|
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
||||||
|
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
||||||
|
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
|
||||||
|
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||||
|
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||||
|
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||||
|
{ key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' },
|
||||||
|
{ key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' },
|
||||||
|
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||||
|
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||||
|
{ key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=ca7ccb3b9ca044dd993c8604bc9afd93&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' },
|
||||||
|
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||||
|
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||||
|
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||||
|
{ key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' },
|
||||||
|
{ key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' },
|
||||||
|
{ key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' },
|
||||||
|
{ key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' },
|
||||||
|
{ key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' },
|
||||||
|
{ key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' },
|
||||||
|
{ key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' },
|
||||||
|
{ key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' },
|
||||||
|
{ 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_last_run', value: '', description: '上次自动清理时间' },
|
||||||
|
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||||
|
];
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
for (const entry of defaults) {
|
||||||
|
insert.run(entry.key, entry.value, entry.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理 60 天前的转存记录 */
|
||||||
|
function cleanupOldSaveRecords(db: Database.Database): void {
|
||||||
|
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||||
|
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||||
|
console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getDb;
|
||||||
623
packages/backend/src/cloud/drivers/CloudConfig.vue
Executable file
623
packages/backend/src/cloud/drivers/CloudConfig.vue
Executable file
@@ -0,0 +1,623 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cloud-config">
|
||||||
|
<!-- 网盘类型开关 -->
|
||||||
|
<el-card class="toggle-card" style="margin-bottom: 20px;">
|
||||||
|
<template #header><span>📂 网盘设置及授权</span></template>
|
||||||
|
<div class="cloud-toggle-grid">
|
||||||
|
<div
|
||||||
|
v-for="ct in cloudTypes"
|
||||||
|
:key="ct.type"
|
||||||
|
class="cloud-toggle-chip"
|
||||||
|
>
|
||||||
|
<img :src="ct.icon" class="cloud-icon-img" />
|
||||||
|
<span class="cloud-label">{{ ct.label }}</span>
|
||||||
|
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
|
||||||
|
<el-switch
|
||||||
|
:model-value="ct.enabled"
|
||||||
|
size="small"
|
||||||
|
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-tip" style="margin-top: 12px;">
|
||||||
|
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
|
||||||
|
<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-table-column>
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
|
||||||
|
<el-text v-else type="info" size="small">未设置</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
|
||||||
|
<el-text v-else type="info" size="small">-</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="验证" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row._verifying" class="verifying">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
</span>
|
||||||
|
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
|
||||||
|
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未验证</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="空间" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.storage_total" class="storage-cell">
|
||||||
|
<div class="storage-bar-wrap">
|
||||||
|
<div
|
||||||
|
class="storage-bar-fill"
|
||||||
|
:style="{ width: storagePercent(row) + '%' }"
|
||||||
|
:class="storageBarClass(row)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-text">
|
||||||
|
<span class="storage-used">{{ row.storage_used || '?' }}</span>
|
||||||
|
<span class="storage-sep">/</span>
|
||||||
|
<span class="storage-total">{{ row.storage_total }}</span>
|
||||||
|
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-text v-else type="info" size="small">—</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<!-- 转存统计 -->
|
||||||
|
<el-table-column label="转存" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}次</span>
|
||||||
|
<el-text v-else type="info" size="small">-</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="390" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||||
|
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
|
||||||
|
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="网盘类型" prop="cloud_type">
|
||||||
|
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
|
||||||
|
<el-option
|
||||||
|
v-for="[key, label] in cloudTypeOptions"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称" prop="nickname">
|
||||||
|
<el-input v-model="form.nickname" placeholder="必填,用于区分多个同类型网盘">
|
||||||
|
<template #append>
|
||||||
|
<el-button :loading="form._verifying" @click="verifyAndFillNickname">自动获取</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Cookie" prop="cookie">
|
||||||
|
<el-input
|
||||||
|
v-model="form.cookie"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||||
|
:placeholder="cookiePlaceholder"
|
||||||
|
input-style="font-family: monospace; font-size: 12px;"
|
||||||
|
/>
|
||||||
|
</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}`">
|
||||||
|
<div class="cookie-tips-header">
|
||||||
|
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
|
||||||
|
</div>
|
||||||
|
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from 'vue'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { CLOUD_LABELS } from '../../types'
|
||||||
|
import type { CloudType, CloudConfig } from '../../types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||||
|
import CloudBadge from '../../components/CloudBadge.vue'
|
||||||
|
import type { ElForm } from 'element-plus'
|
||||||
|
|
||||||
|
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
|
||||||
|
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||||
|
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
cloud_type: '' as CloudType | '',
|
||||||
|
nickname: '',
|
||||||
|
cookie: '',
|
||||||
|
_verifying: false,
|
||||||
|
_storageUsed: '',
|
||||||
|
_storageTotal: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
cloud_type: CloudType | ''
|
||||||
|
nickname: string
|
||||||
|
cookie: string
|
||||||
|
_verifying: boolean
|
||||||
|
_storageUsed: string
|
||||||
|
_storageTotal: string
|
||||||
|
}>(defaultForm())
|
||||||
|
|
||||||
|
const rules = computed(() => ({
|
||||||
|
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
|
||||||
|
nickname: [{ required: true, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cloudTypeOptions = computed(() => {
|
||||||
|
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
|
||||||
|
})
|
||||||
|
|
||||||
|
const cookiePlaceholder = computed(() => {
|
||||||
|
if (!form.cloud_type) return '请先选择网盘类型'
|
||||||
|
const t = form.cloud_type
|
||||||
|
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
|
||||||
|
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloudTypeLabel = computed(() => {
|
||||||
|
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Cookie 获取教程 HTML(根据不同网盘类型) */
|
||||||
|
const cookieTutorialHtml = computed(() => {
|
||||||
|
const t = form.cloud_type
|
||||||
|
if (!t) return ''
|
||||||
|
const tutorials: Record<string, string> = {
|
||||||
|
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||||
|
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code>)</li>
|
||||||
|
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||||
|
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
|
||||||
|
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||||
|
.cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>(F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
|
||||||
|
|
||||||
|
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||||
|
<li>刷新页面,在请求列表中点击任意一个请求</li>
|
||||||
|
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||||
|
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
|
||||||
|
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||||
|
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
|
||||||
|
|
||||||
|
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
|
||||||
|
|
||||||
|
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
|
||||||
|
|
||||||
|
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
|
||||||
|
|
||||||
|
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
}
|
||||||
|
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadConfigs()
|
||||||
|
await loadCloudTypes()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 每30分钟自动验证一次
|
||||||
|
let verifyTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
onMounted(() => {
|
||||||
|
verifyTimer = setInterval(() => {
|
||||||
|
autoVerifyAll()
|
||||||
|
}, 30 * 60 * 1000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (verifyTimer) clearInterval(verifyTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCloudTypes() {
|
||||||
|
try {
|
||||||
|
const result = await getCloudTypes()
|
||||||
|
cloudTypes.value = result.types
|
||||||
|
} catch (e) { console.error('加载网盘类型失败', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloudToggle(type: string, enabled: boolean) {
|
||||||
|
const ct = cloudTypes.value.find(c => c.type === type)
|
||||||
|
if (!ct) return
|
||||||
|
try {
|
||||||
|
await toggleCloudType(type, enabled)
|
||||||
|
ct.enabled = enabled
|
||||||
|
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigs() {
|
||||||
|
try {
|
||||||
|
configs.value = await getCloudConfigs()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载网盘配置失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoVerifyAll() {
|
||||||
|
for (const cfg of configs.value) {
|
||||||
|
if (cfg.cookie_preview || cfg.nickname) {
|
||||||
|
await verifyOne(cfg, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAll() {
|
||||||
|
for (const cfg of configs.value) {
|
||||||
|
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
|
||||||
|
await verifyOne(cfg, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success('全部验证完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
|
||||||
|
if (!row.cookie_preview && !row.nickname) {
|
||||||
|
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row._verifying = true
|
||||||
|
try {
|
||||||
|
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
|
||||||
|
row.verification_status = result.success ? 'valid' : 'invalid'
|
||||||
|
if (result.success) {
|
||||||
|
if (result.nickname && !row.nickname) row.nickname = result.nickname
|
||||||
|
if (result.storage_used) row.storage_used = result.storage_used
|
||||||
|
if (result.storage_total) row.storage_total = result.storage_total
|
||||||
|
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||||
|
} else {
|
||||||
|
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
row.verification_status = 'invalid'
|
||||||
|
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
|
||||||
|
} finally {
|
||||||
|
row._verifying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
form.cloud_type = row.cloud_type
|
||||||
|
form.nickname = row.nickname || ''
|
||||||
|
form.cookie = row.cookie || ''
|
||||||
|
form._verifying = false
|
||||||
|
} else {
|
||||||
|
editingId.value = null
|
||||||
|
form.cloud_type = '' as CloudType | ''
|
||||||
|
form.nickname = ''
|
||||||
|
form.cookie = ''
|
||||||
|
form._verifying = false
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloudTypeChange() {
|
||||||
|
// Cookie 输入框提示会自动更新(computed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateCloudConfig({
|
||||||
|
id: editingId.value,
|
||||||
|
cloud_type: form.cloud_type as CloudType,
|
||||||
|
nickname: form.nickname,
|
||||||
|
cookie: form.cookie || undefined,
|
||||||
|
is_active: true,
|
||||||
|
storage_used: form._storageUsed || undefined,
|
||||||
|
storage_total: form._storageTotal || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('配置更新成功')
|
||||||
|
} else {
|
||||||
|
const saved = await saveCloudConfig({
|
||||||
|
cloud_type: form.cloud_type as CloudType,
|
||||||
|
nickname: form.nickname,
|
||||||
|
cookie: form.cookie,
|
||||||
|
is_active: true,
|
||||||
|
storage_used: form._storageUsed || undefined,
|
||||||
|
storage_total: form._storageTotal || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('配置保存成功')
|
||||||
|
if (!form._storageTotal) {
|
||||||
|
const result = await testCloudConnection(form.cloud_type as CloudType, undefined, saved.id)
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.warning(`配置已保存,但连接验证失败:${result.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
editingId.value = null
|
||||||
|
await loadConfigs()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: CloudConfig) {
|
||||||
|
try {
|
||||||
|
await deleteCloudConfig(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadConfigs()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析字节数 → 数值 */
|
||||||
|
function parseBytes(s: string): number {
|
||||||
|
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
|
||||||
|
if (!m) return 0
|
||||||
|
const n = parseFloat(m[1])
|
||||||
|
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
|
||||||
|
return n * (units[m[2].toUpperCase()] || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePercent(row: CloudConfig): number {
|
||||||
|
if (!row.storage_total || !row.storage_used) return 0
|
||||||
|
const total = parseBytes(row.storage_total)
|
||||||
|
const used = parseBytes(row.storage_used)
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.min(100, Math.round((used / total) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageBarClass(row: CloudConfig): string {
|
||||||
|
const pct = storagePercent(row)
|
||||||
|
if (pct >= 90) return 'bar-danger'
|
||||||
|
if (pct >= 70) return 'bar-warning'
|
||||||
|
return 'bar-normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageFree(row: CloudConfig): string {
|
||||||
|
if (!row.storage_total || !row.storage_used) return '?'
|
||||||
|
const total = parseBytes(row.storage_total)
|
||||||
|
const used = parseBytes(row.storage_used)
|
||||||
|
if (total === 0) return '?'
|
||||||
|
const free = total - used
|
||||||
|
if (free < 1024) return '小于 1 KB'
|
||||||
|
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
|
||||||
|
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||||
|
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-config {
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sign-summary-tag {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.nickname-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.uid-cell {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
/* 空间进度条 */
|
||||||
|
.storage-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.storage-bar-wrap {
|
||||||
|
height: 4px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.storage-bar-fill.bar-normal { background: #67c23a; }
|
||||||
|
.storage-bar-fill.bar-warning { background: #e6a23c; }
|
||||||
|
.storage-bar-fill.bar-danger { background: #f56c6c; }
|
||||||
|
.storage-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cookie 教程卡片 */
|
||||||
|
.cookie-tips-item :deep(.el-form-item__content) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
.cookie-tips {
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #e8f0fe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #606266;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cookie-tips-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.cookie-tips-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps code {
|
||||||
|
background: #ecf5ff;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
.cookie-tips-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #fff3c4;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cookie-tips-note code {
|
||||||
|
background: #f5f0e0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
packages/backend/src/cloud/drivers/aliyun.driver.ts
Executable file
113
packages/backend/src/cloud/drivers/aliyun.driver.ts
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
|
||||||
|
export interface AliyunConfig {
|
||||||
|
cookie?: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AliyunDriver {
|
||||||
|
private config: AliyunConfig;
|
||||||
|
private baseUrl = 'https://api.aliyundrive.com';
|
||||||
|
|
||||||
|
constructor(config: AliyunConfig = {}) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract share_id from an Aliyun share URL.
|
||||||
|
* Supports:
|
||||||
|
* https://www.aliyundrive.com/s/XXXYYY
|
||||||
|
* https://www.alipan.com/s/XXXYYY
|
||||||
|
* https://api.aliyundrive.com/v2/share_link/XXXYYY
|
||||||
|
*/
|
||||||
|
private extractShareId(shareUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(shareUrl);
|
||||||
|
const pathMatch = url.pathname.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||||
|
if (pathMatch) return pathMatch[1];
|
||||||
|
|
||||||
|
const shareMatch = url.pathname.match(/\/share_link\/([a-zA-Z0-9]+)/);
|
||||||
|
if (shareMatch) return shareMatch[1];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a share link using Aliyun's public anonymous API.
|
||||||
|
* No cookie or token required — this endpoint is open.
|
||||||
|
*
|
||||||
|
* API:
|
||||||
|
* POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous
|
||||||
|
* Body: { "share_id": "XXXYYY", "share_pwd": "" }
|
||||||
|
*
|
||||||
|
* Success: returns share_name, file_infos, creator info
|
||||||
|
* Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.)
|
||||||
|
*/
|
||||||
|
async validateShareLink(shareUrl: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
message: string;
|
||||||
|
fileCount?: number;
|
||||||
|
shareName?: string;
|
||||||
|
}> {
|
||||||
|
const shareId = this.extractShareId(shareUrl);
|
||||||
|
if (!shareId) {
|
||||||
|
return { valid: false, message: '无法解析阿里云盘链接格式' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/v2/share_link/get_share_by_anonymous`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://www.aliyundrive.com/',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
share_id: shareId,
|
||||||
|
share_pwd: '',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { valid: false, message: `HTTP ${response.status}: API 请求失败` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
|
||||||
|
// Check for error codes
|
||||||
|
if (data.code) {
|
||||||
|
switch (data.code) {
|
||||||
|
case 'ShareLinkExpired':
|
||||||
|
return { valid: false, message: '分享已失效(已过期)' };
|
||||||
|
case 'ShareLinkCancelled':
|
||||||
|
return { valid: false, message: '分享已被取消' };
|
||||||
|
case 'NotFound.ShareLink':
|
||||||
|
return { valid: false, message: '分享链接不存在' };
|
||||||
|
case 'ShareLinkPasswordIncorrect':
|
||||||
|
return { valid: true, message: '需要提取码(链接有效)' };
|
||||||
|
default:
|
||||||
|
return { valid: false, message: data.message || `未知错误 (${data.code})` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — valid share link
|
||||||
|
const fileInfos = data.file_infos || [];
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
message: `有效链接(${fileInfos.length} 个文件)`,
|
||||||
|
fileCount: fileInfos.length,
|
||||||
|
shareName: data.share_name || '',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return { valid: false, message: `网络错误: ${err.message || err}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1189
packages/backend/src/cloud/drivers/baidu.driver.ts
Normal file
1189
packages/backend/src/cloud/drivers/baidu.driver.ts
Normal file
File diff suppressed because it is too large
Load Diff
289
packages/backend/src/cloud/drivers/quark-ad-cleanup.ts
Normal file
289
packages/backend/src/cloud/drivers/quark-ad-cleanup.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { getSystemConfig } from "../../admin/system-config.service";
|
||||||
|
import { getHeaders, makeQuery } from "./quark-api";
|
||||||
|
import { listDir, listDirAllPages } from "./quark-api";
|
||||||
|
import { humanDelay } from "./quark-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广告关键词清理模块。
|
||||||
|
* 在转存完成后执行:
|
||||||
|
* 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容
|
||||||
|
* 2. 在转存根目录下创建警示文件夹(置顶提醒)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 配置读取 ====================
|
||||||
|
|
||||||
|
/** 从 DB 读取广告关键词列表 */
|
||||||
|
export function getAdKeywords(): string[] {
|
||||||
|
const raw = getSystemConfig("quark_ad_keywords") || "";
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 DB 读取警示文件夹名称列表 */
|
||||||
|
export function getWarningFolderNames(): string[] {
|
||||||
|
const raw = getSystemConfig("quark_warning_folder_names") || "";
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 DB 读取可疑文件后缀列表 */
|
||||||
|
export function getSusExtensions(): string[] {
|
||||||
|
const raw = getSystemConfig("quark_sus_extensions") || "";
|
||||||
|
if (raw.trim()) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
// 默认可疑后缀
|
||||||
|
return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 关键词检测 ====================
|
||||||
|
|
||||||
|
/** 检查文件名是否包含任意广告关键词 */
|
||||||
|
export function containsAdKeyword(
|
||||||
|
fileName: string,
|
||||||
|
keywords: string[],
|
||||||
|
): boolean {
|
||||||
|
if (!keywords.length) return false;
|
||||||
|
const lower = fileName.toLowerCase();
|
||||||
|
return keywords.some((kw) => kw && lower.includes(kw.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 删除操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹。
|
||||||
|
* 返回删除的文件数。
|
||||||
|
*/
|
||||||
|
export async function deleteAdFiles(
|
||||||
|
cookie: string,
|
||||||
|
dirFid: string,
|
||||||
|
keywords: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
if (!keywords.length) return 0;
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
const stack: string[] = [dirFid];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const fid = stack.pop()!;
|
||||||
|
if (visited.has(fid)) continue;
|
||||||
|
visited.add(fid);
|
||||||
|
|
||||||
|
await humanDelay();
|
||||||
|
const files = await listDir(cookie, fid);
|
||||||
|
if (!files || files.length === 0) continue;
|
||||||
|
|
||||||
|
// 先收集所有需要删除的 fid
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
const toKeep: string[] = [];
|
||||||
|
|
||||||
|
const extensions = getSusExtensions();
|
||||||
|
for (const file of files) {
|
||||||
|
const ext = file.file_name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
const isSusExt = extensions.includes(ext);
|
||||||
|
if (containsAdKeyword(file.file_name, keywords) || isSusExt) {
|
||||||
|
toDelete.push(file.fid);
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toKeep.push(file.fid);
|
||||||
|
// 如果是目录且不删除,继续遍历子目录
|
||||||
|
if (file.dir) {
|
||||||
|
stack.push(file.fid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
const deleteOk = await batchDeleteFiles(cookie, toDelete);
|
||||||
|
if (deleteOk) {
|
||||||
|
deletedCount += toDelete.length;
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除文件/文件夹(移入回收站)。
|
||||||
|
*/
|
||||||
|
async function batchDeleteFiles(
|
||||||
|
cookie: string,
|
||||||
|
fids: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action_type: 2, // 2 = 移入回收站
|
||||||
|
file_list: fids.map((fid) => ({ fid })),
|
||||||
|
exclude_fids: [],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await resp.json()) as any;
|
||||||
|
if (data.status === 200) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 警示文件夹创建 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在转存根目录下创建警示文件夹。
|
||||||
|
* 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。
|
||||||
|
* 已存在的则跳过。
|
||||||
|
*/
|
||||||
|
export async function createWarningDirectories(
|
||||||
|
cookie: string,
|
||||||
|
dirNames: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!dirNames.length) return;
|
||||||
|
|
||||||
|
// 先获取根目录下所有文件夹,避免重复创建
|
||||||
|
await humanDelay();
|
||||||
|
const rootFiles = await listDirAllPages(cookie, "0");
|
||||||
|
const existingDirs = new Set(
|
||||||
|
rootFiles.filter((f) => f.dir).map((f) => f.file_name),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of dirNames) {
|
||||||
|
// 格式化名称:确保以 ⚠️ 开头
|
||||||
|
let formattedName = name;
|
||||||
|
if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) {
|
||||||
|
formattedName = `⚠️ ${formattedName}`;
|
||||||
|
}
|
||||||
|
// 去掉多余空格
|
||||||
|
formattedName = formattedName.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
|
if (existingDirs.has(formattedName)) {
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSingleDir(cookie, formattedName);
|
||||||
|
// 加入已存在集合,防止同名重试
|
||||||
|
existingDirs.add(formattedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建单个文件夹。
|
||||||
|
*/
|
||||||
|
async function createSingleDir(
|
||||||
|
cookie: string,
|
||||||
|
dirName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
pdir_fid: "0",
|
||||||
|
file_name: dirName,
|
||||||
|
dir: true,
|
||||||
|
dir_path: "",
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await resp.json()) as any;
|
||||||
|
if (data.status === 200 && data.data?.fid) {
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主入口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行广告清理 + 创建警示文件夹。
|
||||||
|
* 在转存重命名后调用。
|
||||||
|
*/
|
||||||
|
export async function runAdCleanup(
|
||||||
|
cookie: string,
|
||||||
|
savedDirFid: string,
|
||||||
|
): Promise<{ adDeleted: number; warningDirs: number }> {
|
||||||
|
const keywords = getAdKeywords();
|
||||||
|
const warningNames = getWarningFolderNames();
|
||||||
|
|
||||||
|
let adDeleted = 0;
|
||||||
|
let warningDirs = 0;
|
||||||
|
|
||||||
|
// 1. 广告关键词清理
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`,
|
||||||
|
);
|
||||||
|
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建警示文件夹
|
||||||
|
if (warningNames.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`,
|
||||||
|
);
|
||||||
|
await createWarningDirectories(cookie, warningNames);
|
||||||
|
warningDirs = warningNames.length;
|
||||||
|
console.log(
|
||||||
|
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { adDeleted, warningDirs };
|
||||||
|
}
|
||||||
237
packages/backend/src/cloud/drivers/quark-api.ts
Normal file
237
packages/backend/src/cloud/drivers/quark-api.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
||||||
|
* 所有模块共用此单例/函数集,不持有状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuarkConfig {
|
||||||
|
cookie: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Headers & Params ====================
|
||||||
|
|
||||||
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||||
|
|
||||||
|
export function getHeaders(cookie: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
'Referer': 'https://pan.quark.cn/',
|
||||||
|
'Origin': 'https://pan.quark.cn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommonParams(): Record<string, string> {
|
||||||
|
return { pr: 'ucpro', fr: 'pc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate query string with common params + random timing to mimic browser */
|
||||||
|
export function makeQuery(extra: Record<string, string> = {}): string {
|
||||||
|
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
||||||
|
const __t = Date.now() / 1000;
|
||||||
|
return new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
app: 'clouddrive',
|
||||||
|
__dt: String(__dt),
|
||||||
|
__t: String(__t),
|
||||||
|
...extra,
|
||||||
|
}).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random delay to mimic human behavior (500-2000ms) */
|
||||||
|
export async function humanDelay(): Promise<void> {
|
||||||
|
const ms = Math.floor(Math.random() * 1500) + 500;
|
||||||
|
await new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random password for share links */
|
||||||
|
export function randomSharePwd(): string {
|
||||||
|
return Math.floor(1000 + Math.random() * 9000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
||||||
|
*/
|
||||||
|
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
||||||
|
// Match both __kps and kps (with or without __ prefix)
|
||||||
|
const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
if (kpsMatch && signMatch && vcodeMatch) {
|
||||||
|
return {
|
||||||
|
kps: kpsMatch[1],
|
||||||
|
sign: signMatch[1].replace(/%25/g, '%'),
|
||||||
|
vcode: vcodeMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Shared fetch helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw fetch wrapper with JSON parse + status check.
|
||||||
|
* Returns parsed JSON body on 2xx, null on network error.
|
||||||
|
*/
|
||||||
|
export async function apiFetch<T = any>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method?: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
cookie: string;
|
||||||
|
timeout?: number;
|
||||||
|
},
|
||||||
|
): Promise<T | null> {
|
||||||
|
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
||||||
|
let url = `${BASE_URL}${path}`;
|
||||||
|
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== File listing (shared across modules) ====================
|
||||||
|
|
||||||
|
export interface QuarkFile {
|
||||||
|
fid: string;
|
||||||
|
file_name: string;
|
||||||
|
share_fid_token?: string;
|
||||||
|
dir: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a directory by FID.
|
||||||
|
*/
|
||||||
|
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
pdir_fid: pdirFid,
|
||||||
|
_page: String(page),
|
||||||
|
_size: String(pageSize),
|
||||||
|
_fetch_total: '1',
|
||||||
|
_fetch_sub_dirs: '0',
|
||||||
|
_sort: 'file_type:asc,updated_at:desc',
|
||||||
|
fetch_all_file: '1',
|
||||||
|
fetch_risk_file_name: '1',
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status !== 200) return [];
|
||||||
|
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
||||||
|
fid: f.fid,
|
||||||
|
file_name: f.file_name,
|
||||||
|
share_fid_token: '',
|
||||||
|
dir: f.dir || false,
|
||||||
|
size: f.size || 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
||||||
|
*/
|
||||||
|
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pr: 'ucpro', fr: 'pc',
|
||||||
|
pdir_fid: '0',
|
||||||
|
_page: '1', _size: '200',
|
||||||
|
_fetch_total: '1', _fetch_sub_dirs: '0',
|
||||||
|
_sort: 'file_type:asc,updated_at:desc',
|
||||||
|
fetch_all_file: '1',
|
||||||
|
fetch_risk_file_name: '1',
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status !== 200 || !data.data?.list) return [];
|
||||||
|
return (data.data.list || []).map((f: any) => ({
|
||||||
|
fid: f.fid,
|
||||||
|
file_name: f.file_name,
|
||||||
|
dir: f.dir || false,
|
||||||
|
size: f.size || 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all files in a directory, handling pagination.
|
||||||
|
* Fetches all pages until no more results.
|
||||||
|
*/
|
||||||
|
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
||||||
|
const allFiles: QuarkFile[] = [];
|
||||||
|
let page = 1;
|
||||||
|
const pageSize = 100;
|
||||||
|
let total = -1;
|
||||||
|
while (total === -1 || (page - 1) * pageSize < total) {
|
||||||
|
const files = await listDir(cookie, pdirFid, page, pageSize);
|
||||||
|
if (!files.length) break;
|
||||||
|
allFiles.push(...files);
|
||||||
|
if (total === -1) {
|
||||||
|
total = files.length;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Format utilities ====================
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
||||||
|
export function dailyFolderName(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random folder name for saving (fallback) */
|
||||||
|
export function randomFolderName(): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let name = '';
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
name += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
60
packages/backend/src/cloud/drivers/quark-auth.ts
Normal file
60
packages/backend/src/cloud/drivers/quark-auth.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { QuarkConfig } from './quark-api';
|
||||||
|
import { getHeaders, getMparam, apiFetch, makeQuery } from './quark-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。
|
||||||
|
* 所有方法以 cookie 字符串为参数,不持有驱动状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Validate ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the cookie by fetching user info.
|
||||||
|
*/
|
||||||
|
export async function validate(cookie: string): Promise<boolean> {
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
// Use account/info API (same as quark-auto-save project)
|
||||||
|
// Only needs __uid cookie, no mparam (kps/sign/vcode) required
|
||||||
|
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data?.data?.nickname) return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`);
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch nickname from Quark account info (same API used by quark-auto-save) */
|
||||||
|
export async function fetchNickname(cookie: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc';
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
return data?.data?.nickname || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
packages/backend/src/cloud/drivers/quark-cleanup.ts
Normal file
315
packages/backend/src/cloud/drivers/quark-cleanup.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 容量信息 & 空间清理模块。
|
||||||
|
*/
|
||||||
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||||
|
|
||||||
|
// ==================== Storage Info ====================
|
||||||
|
|
||||||
|
/** Cached used space, keyed by hour block (3h window) */
|
||||||
|
const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null;
|
||||||
|
|
||||||
|
// We use a function-scoped cache instead of instance field
|
||||||
|
const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock: -1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total capacity from /capacity/detail API.
|
||||||
|
* Also does a quick used-space estimate by summing root-level file sizes + subdir sizes
|
||||||
|
* (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归).
|
||||||
|
* If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided.
|
||||||
|
*/
|
||||||
|
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 capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
let totalBytes = 0;
|
||||||
|
if (capResponse.ok) {
|
||||||
|
const data = await capResponse.json() as any;
|
||||||
|
if (data.status === 200 && data.data) {
|
||||||
|
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
||||||
|
if (totalBytes === 0) {
|
||||||
|
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
||||||
|
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick used-space estimate: sum root-level file sizes + subdir sizes
|
||||||
|
let 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;
|
||||||
|
storageCache.hourBlock = currentHourBlock;
|
||||||
|
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
return {
|
||||||
|
total: formatBytes(totalBytes),
|
||||||
|
totalBytes,
|
||||||
|
used: formatBytes(usedBytes),
|
||||||
|
usedBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fallback: try to parse from a human-readable string like "6 TB"
|
||||||
|
if (fallbackTotal) {
|
||||||
|
const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i);
|
||||||
|
if (match) {
|
||||||
|
const num = parseFloat(match[1]);
|
||||||
|
const unit = match[2].toUpperCase();
|
||||||
|
const multipliers: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 };
|
||||||
|
const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0;
|
||||||
|
if (multiplier > 0) {
|
||||||
|
return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage info with used space calculation.
|
||||||
|
*/
|
||||||
|
export async function getStorageInfo(cookie: string): 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 response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.status === 200 && data.data) {
|
||||||
|
totalBytes = data.data.capacity_summary?.sum_capacity || 0;
|
||||||
|
if (totalBytes === 0) {
|
||||||
|
const memberships = [...(data.data.effect || []), ...(data.data.expired || [])];
|
||||||
|
totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedBytes = await calculateUsedSpace(cookie);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
} catch {
|
||||||
|
return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total used space by recursively traversing all files
|
||||||
|
* and summing their sizes. Uses 3-hour time window cache.
|
||||||
|
*/
|
||||||
|
export async function calculateUsedSpace(cookie: string): Promise<number> {
|
||||||
|
const currentHourBlock = Math.floor(new Date().getHours() / 3);
|
||||||
|
if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) {
|
||||||
|
return storageCache.bytes;
|
||||||
|
}
|
||||||
|
let totalUsed = 0;
|
||||||
|
const stack: string[] = ['0'];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const fid = stack.pop()!;
|
||||||
|
if (visited.has(fid)) continue;
|
||||||
|
visited.add(fid);
|
||||||
|
const files = await listDirAllPages(cookie, fid);
|
||||||
|
if (!files.length) continue;
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.dir) {
|
||||||
|
stack.push(f.fid);
|
||||||
|
} else {
|
||||||
|
totalUsed += f.size || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
storageCache.bytes = totalUsed;
|
||||||
|
storageCache.hourBlock = currentHourBlock;
|
||||||
|
return totalUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Cleanup ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trash specified files/folders (move to recycle bin).
|
||||||
|
*/
|
||||||
|
export async function trashFiles(cookie: string, fids: string[]): Promise<boolean> {
|
||||||
|
if (!fids.length) return true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action_type: 1, // 1 = move to trash
|
||||||
|
filelist: fids,
|
||||||
|
exclude_filelist: [],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.status === 200) return true;
|
||||||
|
console.error(`[Quark] trashFiles failed: ${data.message || data.status}`);
|
||||||
|
return false;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Quark] trashFiles error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty the recycle bin — permanently delete all files in trash.
|
||||||
|
*/
|
||||||
|
export async function emptyTrash(cookie: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.status === 200) return true;
|
||||||
|
console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`);
|
||||||
|
return false;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Quark] emptyTrash error: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`.
|
||||||
|
*/
|
||||||
|
export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootItems = await listRootDir(cookie);
|
||||||
|
const oldFolders = rootItems.filter(item => {
|
||||||
|
if (!item.dir) return false;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false;
|
||||||
|
return item.file_name < cutoffStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oldFolders.length === 0) {
|
||||||
|
return { trashed: 0, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fids = oldFolders.map(f => f.fid);
|
||||||
|
console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`);
|
||||||
|
const ok = await trashFiles(cookie, fids);
|
||||||
|
if (ok) {
|
||||||
|
return { trashed: fids.length, errors: [] };
|
||||||
|
}
|
||||||
|
return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { trashed: 0, errors: [err.message] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup: if used space exceeds thresholdPercent% of total,
|
||||||
|
* delete the oldest date folders until totalBytes * deletePercent/100
|
||||||
|
* of total capacity is freed.
|
||||||
|
*/
|
||||||
|
export async function cleanupBySpaceThreshold(
|
||||||
|
cookie: string,
|
||||||
|
thresholdPercent: number,
|
||||||
|
deletePercent: number,
|
||||||
|
): Promise<{ trashed: number; errors: string[] }> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = await getStorageInfo(cookie);
|
||||||
|
if (storage.totalBytes <= 0) return { trashed: 0, errors: [] };
|
||||||
|
|
||||||
|
const usagePercent = (storage.usedBytes / storage.totalBytes) * 100;
|
||||||
|
if (usagePercent < thresholdPercent) {
|
||||||
|
console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`);
|
||||||
|
return { trashed: 0, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100);
|
||||||
|
|
||||||
|
const rootItems = await listRootDir(cookie);
|
||||||
|
const dateFolders = rootItems
|
||||||
|
.filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name))
|
||||||
|
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
|
|
||||||
|
if (dateFolders.length === 0) return { trashed: 0, errors: [] };
|
||||||
|
|
||||||
|
const hasSizes = dateFolders.some(f => f.size && f.size > 0);
|
||||||
|
let cumulativeSize = 0;
|
||||||
|
const foldersToTrash: typeof dateFolders = [];
|
||||||
|
|
||||||
|
if (hasSizes) {
|
||||||
|
for (const folder of dateFolders) {
|
||||||
|
foldersToTrash.push(folder);
|
||||||
|
cumulativeSize += folder.size || 0;
|
||||||
|
if (cumulativeSize >= targetBytesToFree) break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const avgSizePerFolder = storage.usedBytes / dateFolders.length;
|
||||||
|
const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder));
|
||||||
|
foldersToTrash.push(...dateFolders.slice(0, estCount));
|
||||||
|
cumulativeSize = estCount * avgSizePerFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0);
|
||||||
|
const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0);
|
||||||
|
const fidsToTrash = foldersToTrash.map(f => f.fid);
|
||||||
|
console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`);
|
||||||
|
|
||||||
|
const ok = await trashFiles(cookie, fidsToTrash);
|
||||||
|
if (ok) {
|
||||||
|
console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`);
|
||||||
|
return { trashed: foldersToTrash.length, errors: [] };
|
||||||
|
}
|
||||||
|
return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { trashed: 0, errors: [err.message] };
|
||||||
|
}
|
||||||
|
}
|
||||||
259
packages/backend/src/cloud/drivers/quark-rename.ts
Normal file
259
packages/backend/src/cloud/drivers/quark-rename.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防和谐重命名模块。
|
||||||
|
* 对文件名/目录名执行谐音替换 + 可读标签保留(集数、画质、语言等)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Homophone Map ====================
|
||||||
|
|
||||||
|
const HOMOPHONE_MAP: Record<string, string> = {
|
||||||
|
// 网盘热门番名 — 谐音替换 (same sound, different char)
|
||||||
|
'斗':'陡','破':'坡','苍':'仓','穹':'穷',
|
||||||
|
'完':'玩','美':'每','世':'士','界':'介',
|
||||||
|
'凡':'烦','人':'仁','修':'休','罗':'络',
|
||||||
|
'仙':'先','逆':'腻','遮':'折','天':'添',
|
||||||
|
'吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽',
|
||||||
|
'星':'惺','辰':'晨','变':'便','一':'伊','念':'捻',
|
||||||
|
'永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥',
|
||||||
|
'剑':'箭','来':'莱','诡':'鬼','秘':'蜜',
|
||||||
|
'全':'泉','职':'值','盘':'磐','龙':'笼',
|
||||||
|
'雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记',
|
||||||
|
'珠':'株','王':'亡','座':'坐','牧':'木','记':'计',
|
||||||
|
'沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串',
|
||||||
|
'百':'白','炼':'恋','成':'程','饶':'绕','命':'冥',
|
||||||
|
// 通用谐音替换
|
||||||
|
'的':'得','了':'啦','是':'事','不':'布','我':'窝',
|
||||||
|
'你':'尼','他':'她','有':'友','和':'合','与':'予',
|
||||||
|
'上':'尚','下':'夏','中':'忠','第':'弟','集':'级',
|
||||||
|
'话':'划','季':'际','年':'念','月':'阅','日':'曰',
|
||||||
|
'新':'心','版':'板','高':'糕','清':'青','原':'源',
|
||||||
|
'小':'晓','片':'篇','视':'市','频':'贫','道':'到',
|
||||||
|
'动':'洞','画':'话','声':'升','音':'因','文':'闻',
|
||||||
|
'明':'名','暗':'黯','光':'广','影':'映','色':'瑟',
|
||||||
|
'风':'疯','雨':'语','花':'华','国':'果','家':'佳',
|
||||||
|
'战':'站','争':'挣','士':'仕','兵':'宾',
|
||||||
|
'皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖',
|
||||||
|
'精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭',
|
||||||
|
'杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡',
|
||||||
|
'时':'石','空':'孔','前':'钱','后':'厚','东':'冬',
|
||||||
|
'南':'难','西':'夕','北':'备','开':'凯','关':'官',
|
||||||
|
'出':'初','进':'近','去':'趣',
|
||||||
|
'短':'短','多':'多','少':'少','真':'贞','假':'价',
|
||||||
|
'好':'郝','坏':'怀','对':'队','错':'措','以':'已',
|
||||||
|
'从':'从','被':'被','把':'把','将':'将','在':'在',
|
||||||
|
'但':'但','就':'就','才':'才','也':'也','很':'狠',
|
||||||
|
'又':'又','再':'再','更':'更','最':'最','总':'总',
|
||||||
|
'共':'共','只':'只','各':'各','每':'每','任':'任',
|
||||||
|
'所':'所','该':'该','本':'本',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' +
|
||||||
|
'么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈';
|
||||||
|
|
||||||
|
// ==================== Helpers ====================
|
||||||
|
|
||||||
|
/** Convert Chinese text to homophonic (substitute chars with same sound) */
|
||||||
|
function homophonicText(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||||
|
const homophone = HOMOPHONE_MAP[ch];
|
||||||
|
result += homophone || ch;
|
||||||
|
} else {
|
||||||
|
result += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */
|
||||||
|
function pinyinLike(text: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||||
|
const homophone = HOMOPHONE_MAP[ch];
|
||||||
|
if (homophone) {
|
||||||
|
result += pinyinInitial(homophone);
|
||||||
|
} else {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
result += String.fromCharCode(97 + (code % 26));
|
||||||
|
}
|
||||||
|
} else if (/[a-zA-Z0-9]/.test(ch)) {
|
||||||
|
result += ch;
|
||||||
|
} else if (/[\s._-]/.test(ch)) {
|
||||||
|
result += '_';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get pinyin initial (first letter of pinyin) for a Chinese character */
|
||||||
|
function pinyinInitial(ch: string): string {
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
if (code >= 0x4E00 && code <= 0x9FFF) {
|
||||||
|
const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
|
||||||
|
const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1);
|
||||||
|
return initials[idx];
|
||||||
|
}
|
||||||
|
return ch.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Public API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anti-harmony rename for directories.
|
||||||
|
* 80%: light homophonic replacement, 20%: partial pinyin.
|
||||||
|
*/
|
||||||
|
export function magicRenameDir(dirName: string): string {
|
||||||
|
const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4);
|
||||||
|
|
||||||
|
let cleanName = dirName.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!cleanName) {
|
||||||
|
return `media_${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseName: string;
|
||||||
|
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
// Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is
|
||||||
|
const chars = [...cleanName];
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const ch of chars) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) {
|
||||||
|
result.push(pinyinInitial(ch));
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseName = result.join('');
|
||||||
|
} else {
|
||||||
|
// Light homophonic: replace each CJK char, keep everything else as-is
|
||||||
|
const chars = [...cleanName];
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const ch of chars) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(ch)) {
|
||||||
|
result.push(HOMOPHONE_MAP[ch] || ch);
|
||||||
|
} else {
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseName = result.join('');
|
||||||
|
|
||||||
|
// Optional: insert 0-2 light noise chars (low probability)
|
||||||
|
const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0;
|
||||||
|
for (let n = 0; n < noiseCount; n++) {
|
||||||
|
const pos = Math.floor(Math.random() * (baseName.length + 1));
|
||||||
|
const ink = NOISE_CJK[Math.floor(Math.random() * NOISE_CJK.length)];
|
||||||
|
baseName = baseName.slice(0, pos) + ink + baseName.slice(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_');
|
||||||
|
baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
if (baseName.length > 30) baseName = baseName.slice(0, 30);
|
||||||
|
|
||||||
|
return `${baseName}_${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anti-harmony rename for files.
|
||||||
|
* KEEPS: episode numbers, quality, language tags, original extension.
|
||||||
|
* REPLACES: Chinese title with homophonic/pinyin.
|
||||||
|
*/
|
||||||
|
export function magicRename(filename: string): string {
|
||||||
|
const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8);
|
||||||
|
|
||||||
|
let ext = '';
|
||||||
|
const extMatch = filename.match(/\.[a-zA-Z0-9]+$/);
|
||||||
|
if (extMatch) {
|
||||||
|
ext = extMatch[0];
|
||||||
|
filename = filename.slice(0, -ext.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and REMEMBER: episode info, quality, language, year
|
||||||
|
const episodePatterns = [
|
||||||
|
{ regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') },
|
||||||
|
{ regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() },
|
||||||
|
{ regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') },
|
||||||
|
{ regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() },
|
||||||
|
];
|
||||||
|
let episodeTag = '';
|
||||||
|
for (const { regex, format } of episodePatterns) {
|
||||||
|
const m = filename.match(regex);
|
||||||
|
if (m) {
|
||||||
|
episodeTag = format(m[0]);
|
||||||
|
filename = filename.replace(m[0], '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and REMEMBER: quality tags
|
||||||
|
const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/;
|
||||||
|
const qualityMatch = filename.match(qualityPattern);
|
||||||
|
const qualityTag = qualityMatch ? qualityMatch[0] : '';
|
||||||
|
if (qualityMatch) filename = filename.replace(qualityMatch[0], '');
|
||||||
|
|
||||||
|
// Extract and REMEMBER: language tags
|
||||||
|
const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/;
|
||||||
|
const langMatch = filename.match(langPattern);
|
||||||
|
const langTag = langMatch ? langMatch[0] : '';
|
||||||
|
if (langMatch) filename = filename.replace(langMatch[0], '');
|
||||||
|
|
||||||
|
// Extract and REMEMBER: year
|
||||||
|
const yearMatch = filename.match(/\b(20\d{2})\b/);
|
||||||
|
const yearTag = yearMatch ? yearMatch[0] : '';
|
||||||
|
if (yearMatch) filename = filename.replace(yearMatch[0], '');
|
||||||
|
|
||||||
|
// Extract and REMEMBER: season info
|
||||||
|
const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/);
|
||||||
|
const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : '';
|
||||||
|
if (seasonMatch) filename = filename.replace(seasonMatch[0], '');
|
||||||
|
|
||||||
|
// Now process the remaining name (mostly Chinese title)
|
||||||
|
filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim();
|
||||||
|
|
||||||
|
const useHomophonic = Math.random() > 0.5;
|
||||||
|
let titlePart: string;
|
||||||
|
if (useHomophonic) {
|
||||||
|
titlePart = homophonicText(filename);
|
||||||
|
titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_');
|
||||||
|
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
||||||
|
} else {
|
||||||
|
titlePart = pinyinLike(filename);
|
||||||
|
titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
|
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
if (titlePart.length > 15) titlePart = titlePart.slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sensitive keywords from title part
|
||||||
|
const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig;
|
||||||
|
titlePart = titlePart.replace(sensitiveWords, '');
|
||||||
|
titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
|
||||||
|
// Build preserved tags
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (seasonTag) tags.push(seasonTag);
|
||||||
|
if (episodeTag) tags.push(episodeTag);
|
||||||
|
if (qualityTag) tags.push(qualityTag.toUpperCase());
|
||||||
|
if (langTag) tags.push(langTag);
|
||||||
|
if (yearTag) tags.push(yearTag);
|
||||||
|
tags.push(hash); // Always add hash for uniqueness
|
||||||
|
|
||||||
|
const newExt = ext || '.bin';
|
||||||
|
|
||||||
|
const parts = [titlePart, ...tags].filter(Boolean);
|
||||||
|
let result = parts.join('_');
|
||||||
|
|
||||||
|
if (result.length > 80) {
|
||||||
|
result = result.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length < 10) {
|
||||||
|
const filler = crypto.randomBytes(4).toString('hex');
|
||||||
|
result = `${filler}_${result}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result + newExt;
|
||||||
|
}
|
||||||
409
packages/backend/src/cloud/drivers/quark-share.ts
Normal file
409
packages/backend/src/cloud/drivers/quark-share.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, randomSharePwd, apiFetch, QuarkFile } from './quark-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分享模块 — 分享链接解析、转存任务、创建分享链接。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||||
|
|
||||||
|
// ==================== Acquire Stoken ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire stoken for a share link (needed for detail/save).
|
||||||
|
*/
|
||||||
|
export async function acquireStoken(cookie: string, pwdId: string): Promise<string | null> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(getCommonParams());
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pwd_id: pwdId, passcode: '' }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (attempt < 2) continue;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status === 200 && data.data?.stoken) {
|
||||||
|
return data.data.stoken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
if (attempt >= 2) return null;
|
||||||
|
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Get Share Files ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch detail at a given pdir_fid within a share.
|
||||||
|
*/
|
||||||
|
export async function getDetailAt(
|
||||||
|
cookie: string,
|
||||||
|
pwdId: string,
|
||||||
|
stoken: string,
|
||||||
|
pdirFid: string,
|
||||||
|
): Promise<QuarkFile[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
pwd_id: pwdId,
|
||||||
|
stoken,
|
||||||
|
pdir_fid: pdirFid,
|
||||||
|
force: '0',
|
||||||
|
_page: '1',
|
||||||
|
_size: '50',
|
||||||
|
_fetch_banner: '0',
|
||||||
|
_fetch_share: '1',
|
||||||
|
_fetch_total: '1',
|
||||||
|
_sort: 'file_type:asc,updated_at:desc',
|
||||||
|
ver: '2',
|
||||||
|
fetch_share_full_path: '0',
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status !== 200) return [];
|
||||||
|
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
||||||
|
fid: f.fid,
|
||||||
|
file_name: f.file_name,
|
||||||
|
share_fid_token: f.share_fid_token || '',
|
||||||
|
dir: f.dir || false,
|
||||||
|
size: f.size || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect files from a share.
|
||||||
|
* If the share contains a single directory, drill into it to list contents
|
||||||
|
* but still save the directory itself.
|
||||||
|
*/
|
||||||
|
export async function getShareFiles(
|
||||||
|
cookie: string,
|
||||||
|
pwdId: string,
|
||||||
|
stoken: string,
|
||||||
|
): Promise<{ files: QuarkFile[]; topDir: boolean; childFiles?: QuarkFile[] } | null> {
|
||||||
|
try {
|
||||||
|
const topLevel = await getDetailAt(cookie, pwdId, stoken, '0');
|
||||||
|
if (!topLevel || topLevel.length === 0) return null;
|
||||||
|
|
||||||
|
// If the share is a single directory, we save the directory itself
|
||||||
|
// and fetch its contents for renaming later
|
||||||
|
if (topLevel.length === 1 && topLevel[0].dir) {
|
||||||
|
const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid);
|
||||||
|
return {
|
||||||
|
files: topLevel,
|
||||||
|
topDir: true,
|
||||||
|
childFiles: innerFiles || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple top-level items: save them directly
|
||||||
|
return {
|
||||||
|
files: topLevel,
|
||||||
|
topDir: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Save Files (share → cloud) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save shared files to the user's cloud directory.
|
||||||
|
*/
|
||||||
|
export async function saveFiles(
|
||||||
|
cookie: string,
|
||||||
|
pwdId: string,
|
||||||
|
stoken: string,
|
||||||
|
fids: string[],
|
||||||
|
fidTokens: string[],
|
||||||
|
toPdirFid: string,
|
||||||
|
): Promise<{ success: boolean; message: string; taskId?: string }> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/share/sharepage/save?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fid_list: fids,
|
||||||
|
fid_token_list: fidTokens,
|
||||||
|
to_pdir_fid: toPdirFid,
|
||||||
|
pwd_id: pwdId,
|
||||||
|
stoken,
|
||||||
|
pdir_fid: '0',
|
||||||
|
scene: 'link',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status === 200 && data.data?.task_id) {
|
||||||
|
return { success: true, message: 'Save task created', taskId: data.data.task_id };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: data.message === 'require login [guest]'
|
||||||
|
? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie'
|
||||||
|
: (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Wait for Save Task ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll task status until complete or timeout.
|
||||||
|
* Returns the saved file FIDs (save_as_top_fids).
|
||||||
|
*/
|
||||||
|
export async function waitForTask(cookie: string, taskId: string, timeoutMs: number): Promise<string[] | null> {
|
||||||
|
const start = Date.now();
|
||||||
|
let retryIndex = 0;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
task_id: taskId,
|
||||||
|
retry_index: String(retryIndex),
|
||||||
|
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
||||||
|
__t: String(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
||||||
|
);
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status === 200) {
|
||||||
|
if (data.data?.status === 2) {
|
||||||
|
// Task completed
|
||||||
|
const savedFids: string[] = data.data?.save_as?.save_as_top_fids || [];
|
||||||
|
return savedFids;
|
||||||
|
}
|
||||||
|
// Still in progress
|
||||||
|
retryIndex++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error, retry
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
return null; // Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Rename File ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a file by its FID.
|
||||||
|
*/
|
||||||
|
export async function renameFile(cookie: string, fid: string, newName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/rename?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fid, file_name: newName }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
return data.status === 200 || data.code === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Create Share Link ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a share link for a file/folder.
|
||||||
|
* Flow: create task → poll for share_id → submit to get short URL.
|
||||||
|
*/
|
||||||
|
export async function createShareLink(cookie: string, fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> {
|
||||||
|
try {
|
||||||
|
const sharePwd = randomSharePwd();
|
||||||
|
|
||||||
|
// Try different share_type values (1=7天, 0=无限制)
|
||||||
|
const shareTypes = ['1', '0'];
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
for (const st of shareTypes) {
|
||||||
|
await humanDelay();
|
||||||
|
// Step 1: Create share task - get task_id
|
||||||
|
const response = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/share?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fid_list: [fileId],
|
||||||
|
share_type: st,
|
||||||
|
url_type: '1',
|
||||||
|
share_pwd: sharePwd,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await response.json() as any;
|
||||||
|
const taskId = data.data?.task_id;
|
||||||
|
if (!taskId) {
|
||||||
|
lastError = data.message || `share_type=${st} 失败`;
|
||||||
|
console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Poll task until complete
|
||||||
|
const result = await waitForShareTask(cookie, taskId, 20000);
|
||||||
|
if (!result?.shareId) {
|
||||||
|
lastError = result?.message || '任务超时';
|
||||||
|
console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Submit share via /password endpoint
|
||||||
|
const shareUrl = await submitShare(cookie, result.shareId, sharePwd);
|
||||||
|
if (shareUrl) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
shareUrl,
|
||||||
|
sharePwd,
|
||||||
|
message: `分享链接已生成(密码:${sharePwd})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
lastError = '提交密码后未获取到短链接';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Quark] createShareLink error:', err.message);
|
||||||
|
return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit share via /password endpoint to get the actual short URL.
|
||||||
|
*/
|
||||||
|
async function submitShare(cookie: string, shareId: string, sharePwd?: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/share/password?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.status === 200 && data.data?.share_url) {
|
||||||
|
console.log('[Quark] Share short URL:', data.data.share_url);
|
||||||
|
return data.data.share_url;
|
||||||
|
}
|
||||||
|
console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300));
|
||||||
|
console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || '');
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[Quark] /password error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll share task until complete and extract share URL/shortcode.
|
||||||
|
*/
|
||||||
|
async function waitForShareTask(cookie: string, taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> {
|
||||||
|
const start = Date.now();
|
||||||
|
let retryIndex = 0;
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
task_id: taskId,
|
||||||
|
retry_index: String(retryIndex),
|
||||||
|
__dt: String(Math.floor(Math.random() * 240000 + 60000)),
|
||||||
|
__t: String(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/task?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) },
|
||||||
|
);
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.data?.status === 2) {
|
||||||
|
// Task completed — try multiple extraction approaches
|
||||||
|
// 1. Direct share_url field
|
||||||
|
if (data.data?.share_url) {
|
||||||
|
const match = data.data.share_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||||
|
if (match) return { shareId: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Nested share object
|
||||||
|
if (data.data?.share?.url) {
|
||||||
|
const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||||
|
if (match) return { shareId: match[1] };
|
||||||
|
}
|
||||||
|
if (data.data?.share?.short_url) {
|
||||||
|
const match = data.data.share.short_url.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||||
|
if (match) return { shareId: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like)
|
||||||
|
const shareId = data.data?.share_id;
|
||||||
|
if (shareId && shareId.length <= 20 && shareId.length >= 8) {
|
||||||
|
return { shareId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Regex search through the full response for a URL pattern
|
||||||
|
const str = JSON.stringify(data);
|
||||||
|
const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/);
|
||||||
|
if (urlMatch) {
|
||||||
|
return { shareId: urlMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Extract from any URL field in the response
|
||||||
|
const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link'];
|
||||||
|
for (const field of urlFields) {
|
||||||
|
const val = data.data?.[field] || data.data?.share?.[field];
|
||||||
|
if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) {
|
||||||
|
const m = val.match(/\/s\/([a-zA-Z0-9]+)/);
|
||||||
|
if (m) return { shareId: m[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Log full share task response for debugging
|
||||||
|
console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000));
|
||||||
|
|
||||||
|
// 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort
|
||||||
|
if (shareId) {
|
||||||
|
return { shareId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Share task completed but no share URL found' };
|
||||||
|
}
|
||||||
|
if (data.data?.status === 3) {
|
||||||
|
return { message: data.message || 'Share task failed' };
|
||||||
|
}
|
||||||
|
retryIndex++;
|
||||||
|
} catch {
|
||||||
|
// Retry
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
return { message: 'Share task timed out' };
|
||||||
|
}
|
||||||
308
packages/backend/src/cloud/drivers/quark-storage.ts
Normal file
308
packages/backend/src/cloud/drivers/quark-storage.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, dailyFolderName, formatBytes, apiFetch, listDir, listDirAllPages, listRootDir, QuarkFile } from './quark-api';
|
||||||
|
import { acquireStoken, getShareFiles, saveFiles, waitForTask } from './quark-share';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转存 & 存储管理模块。
|
||||||
|
* 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== saveFromShare — 核心转存流水线 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save files from a share link → magic rename → create shared link.
|
||||||
|
*
|
||||||
|
* Flow: token → detail → save → wait_task → rename → share
|
||||||
|
*/
|
||||||
|
export async function saveFromShare(
|
||||||
|
cookie: string,
|
||||||
|
nickname: string | undefined,
|
||||||
|
shareUrl: string,
|
||||||
|
sourceTitle?: string,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
shareUrl?: string;
|
||||||
|
sharePwd?: string;
|
||||||
|
folderName?: string;
|
||||||
|
taskId?: string;
|
||||||
|
renamed?: string[];
|
||||||
|
fileCount?: number;
|
||||||
|
folderCount?: number;
|
||||||
|
originalFolderName?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// Parse share token from URL
|
||||||
|
const urlObj = new URL(shareUrl);
|
||||||
|
const pwdId = urlObj.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (!pwdId) {
|
||||||
|
return { success: false, message: 'Invalid share URL: could not extract share token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Acquire stoken
|
||||||
|
const stoken = await acquireStoken(cookie, pwdId);
|
||||||
|
if (!stoken) {
|
||||||
|
return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get share detail
|
||||||
|
const shareInfo = await getShareFiles(cookie, pwdId, stoken);
|
||||||
|
if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) {
|
||||||
|
return { success: false, message: '🌚 空的!这个分享里啥都没有…' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { files: topFiles, topDir, childFiles } = shareInfo;
|
||||||
|
const originalFolderName = topFiles[0]?.file_name || '';
|
||||||
|
const fids = topFiles.map(f => f.fid);
|
||||||
|
const fidTokens = topFiles.map(f => f.share_fid_token);
|
||||||
|
|
||||||
|
// 按日期创建/查找文件夹,每天的转存存入当天文件夹
|
||||||
|
await humanDelay();
|
||||||
|
const saveDirName = dailyFolderName();
|
||||||
|
console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`);
|
||||||
|
const saveDirFid = await findOrCreateDir(cookie, saveDirName);
|
||||||
|
const targetPdirFid = saveDirFid || '0';
|
||||||
|
if (saveDirFid) {
|
||||||
|
console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Save top-level item(s) to the target directory
|
||||||
|
const saveResult = await saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean) as string[], targetPdirFid);
|
||||||
|
if (!saveResult.success) {
|
||||||
|
return saveResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = saveResult.taskId!;
|
||||||
|
|
||||||
|
// Step 4: Wait for save task to complete (poll up to 30s)
|
||||||
|
const savedFids = await waitForTask(cookie, taskId, 30000);
|
||||||
|
if (!savedFids || savedFids.length === 0) {
|
||||||
|
return { success: true, message: '文件已保存,但获取保存结果超时' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Magic rename files — with random delay to avoid detection
|
||||||
|
await humanDelay();
|
||||||
|
const renamed: Array<{ original: string; renamed: string }> = [];
|
||||||
|
let shareFid = '';
|
||||||
|
let savedFolderName = '';
|
||||||
|
let newInnerDirName = '';
|
||||||
|
|
||||||
|
if (topDir && childFiles && childFiles.length > 0) {
|
||||||
|
// ── Single folder share ──
|
||||||
|
const savedDirFid = savedFids[0];
|
||||||
|
shareFid = savedDirFid;
|
||||||
|
savedFolderName = topFiles[0]?.file_name || '';
|
||||||
|
} else {
|
||||||
|
// ── Multiple files at top level ──
|
||||||
|
shareFid = savedFids[0];
|
||||||
|
savedFolderName = topFiles[0]?.file_name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared
|
||||||
|
await humanDelay();
|
||||||
|
let shareUrlResult = '';
|
||||||
|
let sharePwdResult = '';
|
||||||
|
let shareMsg = '';
|
||||||
|
let successCount = 0; // total items (files + folders) actually saved
|
||||||
|
|
||||||
|
const { createShareLink } = await import('./quark-share');
|
||||||
|
if (shareFid) {
|
||||||
|
const shareResult = await createShareLink(cookie, shareFid);
|
||||||
|
if (shareResult.success && shareResult.shareUrl) {
|
||||||
|
shareUrlResult = shareResult.shareUrl;
|
||||||
|
if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd;
|
||||||
|
} else {
|
||||||
|
shareMsg = `(分享失败:${shareResult.message})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { magicRenameDir, magicRename } = await import('./quark-rename');
|
||||||
|
const { renameFile } = await import('./quark-share');
|
||||||
|
|
||||||
|
// Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share)
|
||||||
|
if (topDir && childFiles && childFiles.length > 0) {
|
||||||
|
// ── Single folder share ──
|
||||||
|
const savedDirFid = savedFids[0];
|
||||||
|
|
||||||
|
// List files inside the saved directory
|
||||||
|
const dirFiles = await listDir(cookie, savedDirFid);
|
||||||
|
if (dirFiles && dirFiles.length > 0) {
|
||||||
|
for (const file of dirFiles) {
|
||||||
|
if (file.dir) continue;
|
||||||
|
const newName = magicRename(file.file_name);
|
||||||
|
const renameOk = await renameFile(cookie, file.fid, newName);
|
||||||
|
if (renameOk) {
|
||||||
|
renamed.push({ original: file.file_name, renamed: newName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also rename the inner folder itself (the actual shared folder)
|
||||||
|
const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || '';
|
||||||
|
if (innerDirOriginalName) {
|
||||||
|
newInnerDirName = magicRenameDir(innerDirOriginalName);
|
||||||
|
const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName);
|
||||||
|
if (innerDirRenameOk) {
|
||||||
|
console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Multiple files at top level ──
|
||||||
|
for (let i = 0; i < savedFids.length && i < topFiles.length; i++) {
|
||||||
|
const originalName = topFiles[i].file_name;
|
||||||
|
if (topFiles[i].dir) continue;
|
||||||
|
const newName = magicRename(originalName);
|
||||||
|
const renameOk = await renameFile(cookie, savedFids[i], newName);
|
||||||
|
if (renameOk) {
|
||||||
|
renamed.push({ original: originalName, renamed: newName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7.5: 广告关键词清理 + 创建警示文件夹
|
||||||
|
if (shareFid) {
|
||||||
|
try {
|
||||||
|
const { runAdCleanup } = await import('./quark-ad-cleanup');
|
||||||
|
const adResult = await runAdCleanup(cookie, shareFid);
|
||||||
|
if (adResult.adDeleted > 0) {
|
||||||
|
console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`);
|
||||||
|
}
|
||||||
|
if (adResult.warningDirs > 0) {
|
||||||
|
console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03")
|
||||||
|
// DO NOT rename the date folder — it serves as the organizational container.
|
||||||
|
savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName;
|
||||||
|
|
||||||
|
// Recursively count files and folders from saved cloud directory
|
||||||
|
let fileCount = 0;
|
||||||
|
let folderCount = 0;
|
||||||
|
if (shareFid) {
|
||||||
|
try {
|
||||||
|
const counts = await countRecursive(cookie, shareFid);
|
||||||
|
fileCount = counts.fileCount;
|
||||||
|
folderCount = counts.folderCount;
|
||||||
|
} catch {
|
||||||
|
console.log('[Quark] Recursive count failed, using fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If recursive count returned nothing, try fallback
|
||||||
|
if (fileCount === 0 && folderCount === 0) {
|
||||||
|
if (topDir && childFiles) {
|
||||||
|
folderCount = 1 + childFiles.filter(f => f.dir).length;
|
||||||
|
fileCount = childFiles.filter(f => !f.dir).length;
|
||||||
|
} else {
|
||||||
|
folderCount = topFiles.filter(f => f.dir).length;
|
||||||
|
fileCount = topFiles.filter(f => !f.dir).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameMsg = renamed.length > 0
|
||||||
|
? `,已重命名 ${renamed.length} 个文件`
|
||||||
|
: '';
|
||||||
|
const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `已保存${folderMsg}${renameMsg}${shareMsg}`,
|
||||||
|
shareUrl: shareUrlResult || undefined,
|
||||||
|
sharePwd: sharePwdResult || undefined,
|
||||||
|
folderName: savedFolderName,
|
||||||
|
taskId,
|
||||||
|
renamed: renamed.map(r => `${r.original} → ${r.renamed}`),
|
||||||
|
fileCount,
|
||||||
|
folderCount,
|
||||||
|
originalFolderName,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message || 'Network error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Dir Management ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new directory at root.
|
||||||
|
*/
|
||||||
|
export async function createDir(cookie: string, dirName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pdir_fid: '0',
|
||||||
|
file_name: dirName,
|
||||||
|
dir: true,
|
||||||
|
dir_path: '',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status === 200 && data.data?.fid) {
|
||||||
|
console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`);
|
||||||
|
return data.data.fid;
|
||||||
|
}
|
||||||
|
console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`);
|
||||||
|
return null;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Quark] createDir error: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing directory by name, or create it if not found.
|
||||||
|
*/
|
||||||
|
export async function findOrCreateDir(cookie: string, dirName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const rootFiles = await listDirAllPages(cookie, '0');
|
||||||
|
const existing = rootFiles.find(f => f.dir && f.file_name === dirName);
|
||||||
|
if (existing?.fid) {
|
||||||
|
console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`);
|
||||||
|
return existing.fid;
|
||||||
|
}
|
||||||
|
console.log(`[Quark] Daily folder "${dirName}" not found, creating...`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Quark] findOrCreateDir list error: ${err.message}`);
|
||||||
|
}
|
||||||
|
const fid = await createDir(cookie, dirName);
|
||||||
|
console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`);
|
||||||
|
return fid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Recursive Count ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively count files and folders for a saved cloud directory.
|
||||||
|
*/
|
||||||
|
export async function countRecursive(cookie: string, pdirFid: string): Promise<{ fileCount: number; folderCount: number }> {
|
||||||
|
let fileCount = 0;
|
||||||
|
let folderCount = 0;
|
||||||
|
const stack = [pdirFid];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const fid = stack.pop()!;
|
||||||
|
if (visited.has(fid)) continue;
|
||||||
|
visited.add(fid);
|
||||||
|
const files = await listDir(cookie, fid);
|
||||||
|
if (!files) continue;
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.dir) {
|
||||||
|
folderCount++;
|
||||||
|
stack.push(f.fid);
|
||||||
|
} else {
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fileCount, folderCount };
|
||||||
|
}
|
||||||
122
packages/backend/src/cloud/drivers/quark.driver.ts
Executable file
122
packages/backend/src/cloud/drivers/quark.driver.ts
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* QuarkDriver — 夸克网盘统一驱动
|
||||||
|
*
|
||||||
|
* 为保持向后兼容性,此类将所有方法委托到子模块。
|
||||||
|
* 新代码应直接导入子模块函数。
|
||||||
|
*
|
||||||
|
* 模块结构:
|
||||||
|
* quark-api.ts — HTTP 封装、headers、params、共享工具函数
|
||||||
|
* quark-auth.ts — Cookie 验证
|
||||||
|
* quark-storage.ts — 转存流水线、目录管理、递归统计
|
||||||
|
* quark-share.ts — 分享链接解析、转存任务、创建分享链接
|
||||||
|
* quark-rename.ts — 防和谐重命名(文件名/目录名)
|
||||||
|
* quark-cleanup.ts — 容量信息、空间清理
|
||||||
|
* quark-driver.ts — 统一导出类(兼容旧代码)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QuarkConfig } from './quark-api';
|
||||||
|
import { validate } from './quark-auth';
|
||||||
|
import { saveFromShare, createDir, findOrCreateDir, countRecursive } from './quark-storage';
|
||||||
|
import { createShareLink, renameFile } from './quark-share';
|
||||||
|
import {
|
||||||
|
getStorageInfoQuick, getStorageInfo,
|
||||||
|
calculateUsedSpace, trashFiles, emptyTrash,
|
||||||
|
cleanupOldDateFolders, cleanupBySpaceThreshold,
|
||||||
|
} from './quark-cleanup';
|
||||||
|
|
||||||
|
export type { QuarkConfig, QuarkFile } from './quark-api';
|
||||||
|
export * from './quark-api';
|
||||||
|
export * from './quark-auth';
|
||||||
|
export * from './quark-storage';
|
||||||
|
export * from './quark-share';
|
||||||
|
export * from './quark-rename';
|
||||||
|
export * from './quark-cleanup';
|
||||||
|
|
||||||
|
export { validate } from './quark-auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuarkDriver — 向后兼容的驱动类。
|
||||||
|
* 所有方法委托到纯函数模块,不持有状态。
|
||||||
|
*/
|
||||||
|
export class QuarkDriver {
|
||||||
|
private config: QuarkConfig;
|
||||||
|
|
||||||
|
constructor(config: QuarkConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cookie(): string {
|
||||||
|
return this.config.cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Auth ====================
|
||||||
|
|
||||||
|
async validate(): Promise<boolean> {
|
||||||
|
return validate(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Storage (Save from Share) ====================
|
||||||
|
|
||||||
|
async saveFromShare(shareUrl: string, sourceTitle?: string) {
|
||||||
|
return saveFromShare(this.config.cookie, this.config.nickname, shareUrl, sourceTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDir(dirName: string): Promise<string | null> {
|
||||||
|
return createDir(this.config.cookie, dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOrCreateDir(dirName: string): Promise<string | null> {
|
||||||
|
return findOrCreateDir(this.config.cookie, dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countRecursive(pdirFid: string) {
|
||||||
|
return countRecursive(this.config.cookie, pdirFid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Share ====================
|
||||||
|
|
||||||
|
async createShareLink(fileId: string) {
|
||||||
|
return createShareLink(this.config.cookie, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameFile(fid: string, newName: string): Promise<boolean> {
|
||||||
|
return renameFile(this.config.cookie, fid, newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Storage Info ====================
|
||||||
|
|
||||||
|
async getStorageInfoQuick() {
|
||||||
|
return getStorageInfoQuick(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStorageInfo() {
|
||||||
|
return getStorageInfo(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateUsedSpace(): Promise<number> {
|
||||||
|
return calculateUsedSpace(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Cleanup ====================
|
||||||
|
|
||||||
|
async listRootDir() {
|
||||||
|
const { listRootDir } = await import('./quark-api');
|
||||||
|
return listRootDir(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
async trashFiles(fids: string[]): Promise<boolean> {
|
||||||
|
return trashFiles(this.config.cookie, fids);
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptyTrash(): Promise<boolean> {
|
||||||
|
return emptyTrash(this.config.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupOldDateFolders(days: number) {
|
||||||
|
return cleanupOldDateFolders(this.config.cookie, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number) {
|
||||||
|
return cleanupBySpaceThreshold(this.config.cookie, thresholdPercent, deletePercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
packages/backend/src/cloud/error-codes.ts
Normal file
70
packages/backend/src/cloud/error-codes.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Standard error codes for all cloud drivers
|
||||||
|
export const ErrCode = {
|
||||||
|
COOKIE_EXPIRED: 'COOKIE_EXPIRED',
|
||||||
|
COOKIE_INVALID: 'COOKIE_INVALID',
|
||||||
|
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
||||||
|
SHARE_NOT_FOUND: 'SHARE_NOT_FOUND',
|
||||||
|
SHARE_EXPIRED: 'SHARE_EXPIRED',
|
||||||
|
PASSWORD_REQUIRED: 'PASSWORD_REQUIRED',
|
||||||
|
PASSWORD_WRONG: 'PASSWORD_WRONG',
|
||||||
|
CAPACITY_FULL: 'CAPACITY_FULL',
|
||||||
|
FILE_EXISTS: 'FILE_EXISTS',
|
||||||
|
RATE_LIMITED: 'RATE_LIMITED',
|
||||||
|
TRANSFER_FAILED: 'TRANSFER_FAILED',
|
||||||
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
|
UNSUPPORTED: 'UNSUPPORTED',
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCode = typeof ErrCode[keyof typeof ErrCode];
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
[ErrCode.COOKIE_EXPIRED]: 'Cookie已过期,请重新登录',
|
||||||
|
[ErrCode.COOKIE_INVALID]: 'Cookie无效,请检查配置',
|
||||||
|
[ErrCode.TOKEN_EXPIRED]: 'Token已过期,请刷新',
|
||||||
|
[ErrCode.SHARE_NOT_FOUND]: '分享链接不存在或已被删除',
|
||||||
|
[ErrCode.SHARE_EXPIRED]: '分享链接已过期',
|
||||||
|
[ErrCode.PASSWORD_REQUIRED]: '需要提取码',
|
||||||
|
[ErrCode.PASSWORD_WRONG]: '提取码错误',
|
||||||
|
[ErrCode.CAPACITY_FULL]: '网盘容量不足',
|
||||||
|
[ErrCode.RATE_LIMITED]: '请求过于频繁,请稍后重试',
|
||||||
|
[ErrCode.TRANSFER_FAILED]: '转存失败',
|
||||||
|
[ErrCode.NETWORK_ERROR]: '网络请求失败',
|
||||||
|
[ErrCode.UNKNOWN]: '未知错误',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function errorResponse(code: ErrorCode, detail?: string) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
message: messages[code] + (detail ? ': ' + detail : ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferError extends Error {
|
||||||
|
code: ErrorCode;
|
||||||
|
detail?: string;
|
||||||
|
cookieExpired: boolean;
|
||||||
|
|
||||||
|
constructor(code: ErrorCode, detail?: string) {
|
||||||
|
super(messages[code] + (detail ? ': ' + detail : ''));
|
||||||
|
this.code = code;
|
||||||
|
this.detail = detail;
|
||||||
|
this.cookieExpired = (code === ErrCode.COOKIE_EXPIRED || code === ErrCode.COOKIE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect error code from driver result message (for untagged drivers) */
|
||||||
|
export function detectErrorCode(result: { message?: string; cookieExpired?: boolean }): ErrorCode | null {
|
||||||
|
if (!result || !result.message) return null;
|
||||||
|
if (result.cookieExpired) return ErrCode.COOKIE_EXPIRED;
|
||||||
|
const msg = result.message.toLowerCase();
|
||||||
|
if (msg.includes('cookie') || msg.includes('登录') || msg.includes('bdstoken')) return ErrCode.COOKIE_EXPIRED;
|
||||||
|
if (msg.includes('不存在') || msg.includes('not found') || msg.includes('已删除')) return ErrCode.SHARE_NOT_FOUND;
|
||||||
|
if (msg.includes('过期') || msg.includes('expired')) return ErrCode.SHARE_EXPIRED;
|
||||||
|
if (msg.includes('提取码') || msg.includes('密码') || msg.includes('password')) return ErrCode.PASSWORD_WRONG;
|
||||||
|
if (msg.includes('容量') || msg.includes('空间') || msg.includes('capacity')) return ErrCode.CAPACITY_FULL;
|
||||||
|
if (msg.includes('频繁') || msg.includes('稍后') || msg.includes('rate')) return ErrCode.RATE_LIMITED;
|
||||||
|
if (msg.includes('网络') || msg.includes('fetch') || msg.includes('timeout')) return ErrCode.NETWORK_ERROR;
|
||||||
|
return ErrCode.TRANSFER_FAILED;
|
||||||
|
}
|
||||||
31
packages/backend/src/cloud/ip-lookup.ts
Normal file
31
packages/backend/src/cloud/ip-lookup.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* IP 归属地查询工具
|
||||||
|
* 通过系统配置中的 IP 地理接口查询
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
export async function lookupIpLocation(ip: string): Promise<string | null> {
|
||||||
|
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const apiUrlTemplate = getSystemConfig('ip_geo_api_url');
|
||||||
|
if (!apiUrlTemplate) return null;
|
||||||
|
const url = apiUrlTemplate.replace('{ip}', encodeURIComponent(ip));
|
||||||
|
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as {
|
||||||
|
code: number; sheng?: string; shi?: string; qu?: string;
|
||||||
|
isp?: string; msg?: string; guo?: string;
|
||||||
|
};
|
||||||
|
if (data.code !== 200) return null;
|
||||||
|
// Format: "四川 绵阳 江油 中国联通" — strip 省/市/区/州 suffixes for compact display
|
||||||
|
const stripSuffix = (s: string | undefined) => s?.replace(/[省市州区]$/, '');
|
||||||
|
const parts = [stripSuffix(data.sheng), stripSuffix(data.shi), stripSuffix(data.qu), data.isp].filter(Boolean);
|
||||||
|
return parts.join(' ');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
packages/backend/src/cloud/notification.service.ts
Normal file
95
packages/backend/src/cloud/notification.service.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
interface NotifyChannel {
|
||||||
|
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Feishu Webhook Channel ----
|
||||||
|
class FeishuChannel implements NotifyChannel {
|
||||||
|
private webhookUrl: string;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
function getChannel(): NotifyChannel | null {
|
||||||
|
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
||||||
|
if (!feishuUrl) return null;
|
||||||
|
|
||||||
|
if (!_channel) {
|
||||||
|
_channel = new FeishuChannel(feishuUrl);
|
||||||
|
console.log('[Notify] Feishu webhook configured');
|
||||||
|
}
|
||||||
|
return _channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a notification through configured channels.
|
||||||
|
* Returns immediately — failures are logged silently.
|
||||||
|
*/
|
||||||
|
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||||
|
const ch = getChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
// Fire-and-forget — don't block the caller
|
||||||
|
ch.send(title, content, level).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify on critical events:
|
||||||
|
* - Cookie expired / login failed
|
||||||
|
* - Save/transfer failed repeatedly
|
||||||
|
* - Storage below threshold
|
||||||
|
*/
|
||||||
|
export function notifyError(title: string, detail: string): void {
|
||||||
|
notify(`⚠️ ${title}`, detail, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyWarn(title: string, detail: string): void {
|
||||||
|
notify(`🔔 ${title}`, detail, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyInfo(title: string, detail: string): void {
|
||||||
|
notify(`ℹ️ ${title}`, detail, 'info');
|
||||||
|
}
|
||||||
537
packages/backend/src/cloud/qr-login.service.ts
Executable file
537
packages/backend/src/cloud/qr-login.service.ts
Executable file
@@ -0,0 +1,537 @@
|
|||||||
|
import { chromium, BrowserContext, Page } from 'playwright';
|
||||||
|
import jsQR from 'jsqr';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { escapeLike } from '../utils/time';
|
||||||
|
|
||||||
|
interface QrSession {
|
||||||
|
id: string;
|
||||||
|
browserContext: BrowserContext;
|
||||||
|
page: Page;
|
||||||
|
createdAt: number;
|
||||||
|
cookieSnapshot: string;
|
||||||
|
lastPollAt: number;
|
||||||
|
qrUrl: string;
|
||||||
|
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSIONS = new Map<string, QrSession>();
|
||||||
|
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
|
||||||
|
|
||||||
|
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||||
|
|
||||||
|
// Clean up old sessions periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, session] of SESSIONS.entries()) {
|
||||||
|
if (now - session.createdAt > SESSION_TTL) {
|
||||||
|
cleanupSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
function cleanupSession(id: string) {
|
||||||
|
const session = SESSIONS.get(id);
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
session.browserContext.close().catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
session.page.context().browser()?.close().catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
SESSIONS.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract QR code URL from the Quark login page canvas using jsQR.
|
||||||
|
*/
|
||||||
|
async function extractQrUrl(page: Page): Promise<string> {
|
||||||
|
const selectors = [
|
||||||
|
'canvas:not(#react-qrcode-logo)',
|
||||||
|
'.qrcode-display canvas',
|
||||||
|
'#登录账号 canvas',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const raw = await page.evaluate(`(sel => {
|
||||||
|
const canvas = document.querySelector(sel);
|
||||||
|
if (!canvas || !canvas.getContext) return null;
|
||||||
|
try {
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
return {
|
||||||
|
w: canvas.width,
|
||||||
|
h: canvas.height,
|
||||||
|
data: Array.from(imageData.data)
|
||||||
|
};
|
||||||
|
} catch(e) { return null; }
|
||||||
|
})('${selector}')`).catch(() => null) as { w: number; h: number; data: number[] } | null;
|
||||||
|
|
||||||
|
if (raw && raw.data && raw.data.length > 0) {
|
||||||
|
const code = jsQR(new Uint8ClampedArray(raw.data), raw.w, raw.h);
|
||||||
|
if (code && code.data) {
|
||||||
|
if (code.data.includes('su.quark.cn')) {
|
||||||
|
return code.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: scan all canvases
|
||||||
|
const raw = await page.evaluate(`(() => {
|
||||||
|
const canvases = document.querySelectorAll('canvas');
|
||||||
|
var results = [];
|
||||||
|
for (var i = 0; i < canvases.length; i++) {
|
||||||
|
try {
|
||||||
|
var c = canvases[i];
|
||||||
|
var ctx = c.getContext('2d');
|
||||||
|
if (!ctx) continue;
|
||||||
|
var imageData = ctx.getImageData(0, 0, c.width, c.height);
|
||||||
|
results.push({
|
||||||
|
index: i,
|
||||||
|
w: c.width,
|
||||||
|
h: c.height,
|
||||||
|
data: Array.from(imageData.data)
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
|
||||||
|
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
throw new Error('页面没有可用的 canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestUrl = '';
|
||||||
|
for (const canvas of raw) {
|
||||||
|
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
||||||
|
if (code && code.data) {
|
||||||
|
if (code.data.includes('su.quark.cn')) {
|
||||||
|
return code.data;
|
||||||
|
}
|
||||||
|
if (!bestUrl) {
|
||||||
|
bestUrl = code.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestUrl) {
|
||||||
|
return bestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('无法解析二维码内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if a cookie string can actually access Quark API.
|
||||||
|
* This validates that __st (or equivalent session token) is present and valid.
|
||||||
|
*/
|
||||||
|
async function isCookieValid(cookieStr: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://pan.quark.cn/account/info', {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Cookie': cookieStr,
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Referer': 'https://pan.quark.cn/',
|
||||||
|
'Origin': 'https://pan.quark.cn',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = await response.json() as any;
|
||||||
|
return data?.status === 200 && data?.data?.nickname ? true : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if cookies contain __st or equivalent session token.
|
||||||
|
* __st is the critical token needed for API access.
|
||||||
|
* Also accepts __pus, __ktd, pus as valid session indicators.
|
||||||
|
*/
|
||||||
|
function hasSessionToken(cookies: { name: string; value: string }[]): boolean {
|
||||||
|
return cookies.some(
|
||||||
|
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify cookies by actually calling the Quark API from within the browser context
|
||||||
|
* (which has full JS context for signing etc.)
|
||||||
|
*/
|
||||||
|
async function verifyCookieInBrowser(session: QrSession): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const resp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const data = JSON.parse(resp);
|
||||||
|
return data?.status === 200 && !!data?.data?.nickname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for __st cookie to appear after login.
|
||||||
|
* Keeps checking for up to `timeoutMs` milliseconds.
|
||||||
|
*/
|
||||||
|
async function waitForStCookie(session: QrSession, timeoutMs: number): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
if (hasSessionToken(cookies)) {
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Public API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a QR code login session.
|
||||||
|
*/
|
||||||
|
export async function startQrLogin(): Promise<{
|
||||||
|
sessionId: string;
|
||||||
|
qrUrl: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
// Clean up any existing expired sessions
|
||||||
|
for (const [id, session] of SESSIONS.entries()) {
|
||||||
|
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||||
|
cleanupSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: CHROMIUM_PATH,
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-zygote',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const browserContext = await browser.newContext({
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
locale: 'zh-CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto('https://pan.quark.cn/', {
|
||||||
|
waitUntil: 'commit',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const qrUrl = await extractQrUrl(page);
|
||||||
|
|
||||||
|
const cookies = await browserContext.cookies();
|
||||||
|
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
const session: QrSession = {
|
||||||
|
id: sessionId,
|
||||||
|
browserContext,
|
||||||
|
page,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cookieSnapshot,
|
||||||
|
lastPollAt: Date.now(),
|
||||||
|
qrUrl,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
SESSIONS.set(sessionId, session);
|
||||||
|
|
||||||
|
// Start background polling for login detection
|
||||||
|
pollLoginStatus(session);
|
||||||
|
|
||||||
|
// Handle page navigation (like redirect after login)
|
||||||
|
page.on('framenavigated', async (frame) => {
|
||||||
|
if (frame === page.mainFrame()) {
|
||||||
|
const url = frame.url();
|
||||||
|
if (url === 'about:blank') {
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle popups/dialogs
|
||||||
|
page.on('popup', async (popup) => {
|
||||||
|
try {
|
||||||
|
await popup.waitForLoadState('networkidle', { timeout: 10000 });
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
qrUrl,
|
||||||
|
expiresIn: SESSION_TTL / 1000,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
try { await browserContext.close(); } catch {}
|
||||||
|
try { browser.close().catch(() => {}); } catch {}
|
||||||
|
SESSIONS.delete(sessionId);
|
||||||
|
throw new Error(`启动扫码登录失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll login status in background.
|
||||||
|
* FIXED: Now specifically waits for __st cookie (the critical session token).
|
||||||
|
*/
|
||||||
|
async function pollLoginStatus(session: QrSession) {
|
||||||
|
let foundLogin = false;
|
||||||
|
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (now - session.createdAt > SESSION_TTL) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
session.status = 'expired';
|
||||||
|
cleanupSession(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastPollAt = now;
|
||||||
|
|
||||||
|
// Check cookies
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
// Phase 1: Look for __st specifically (the critical session token)
|
||||||
|
const hasSt = hasSessionToken(cookies);
|
||||||
|
|
||||||
|
if (hasSt) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
// Try verify in browser context first (preferred)
|
||||||
|
try {
|
||||||
|
const valid = await verifyCookieInBrowser(session);
|
||||||
|
if (valid) {
|
||||||
|
session.status = 'logged_in';
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Fallback: try Node.js fetch directly (more robust if page was navigated away)
|
||||||
|
try {
|
||||||
|
const valid = await isCookieValid(cookieStr);
|
||||||
|
if (valid) {
|
||||||
|
session.status = 'logged_in';
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Both failed — still mark as logged_in if __st is present
|
||||||
|
// (the cookie will be validated again in getQrLoginStatus)
|
||||||
|
console.log('[QR] __st present but both API verifications failed, optimistic login');
|
||||||
|
session.status = 'logged_in';
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: If we found __pus/__ktd but no __st yet, keep polling
|
||||||
|
// (don't stop early like before)
|
||||||
|
const hasPus = cookies.some(
|
||||||
|
c => (c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPus && !foundLogin) {
|
||||||
|
foundLogin = true;
|
||||||
|
console.log('[QR] QR scanned, waiting for __st cookie...');
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
// Don't mark as logged_in — keep polling for __st
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check URL change as alternative indicator
|
||||||
|
const url = session.page.url();
|
||||||
|
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Page might have been closed
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, COOKIE_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check cookies after navigation/redirect and capture them if login succeeded.
|
||||||
|
*/
|
||||||
|
async function checkAndCaptureCookies(session: QrSession) {
|
||||||
|
try {
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
if (hasSessionToken(cookies)) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
// Verify with API from browser context
|
||||||
|
const valid = await verifyCookieInBrowser(session);
|
||||||
|
if (valid) {
|
||||||
|
session.status = 'logged_in';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check if we can get account info
|
||||||
|
if (cookies.length > 3) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
try {
|
||||||
|
const valid = await verifyCookieInBrowser(session);
|
||||||
|
if (valid) {
|
||||||
|
session.status = 'logged_in';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the login status for a session.
|
||||||
|
* FIXED: Now validates the cookie works before returning.
|
||||||
|
*/
|
||||||
|
export async function getQrLoginStatus(sessionId: string): Promise<{
|
||||||
|
status: string;
|
||||||
|
cookie?: string;
|
||||||
|
nickname?: string;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
autoUpdated?: boolean;
|
||||||
|
updatedConfigId?: number;
|
||||||
|
}> {
|
||||||
|
const session = SESSIONS.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return { status: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||||
|
session.status = 'expired';
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
return { status: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === 'logged_in') {
|
||||||
|
// Try to get nickname too
|
||||||
|
let nickname = '';
|
||||||
|
try {
|
||||||
|
const resp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const data = JSON.parse(resp);
|
||||||
|
nickname = data?.data?.nickname || '';
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fetch capacity info from within the browser context
|
||||||
|
let storageTotal = '';
|
||||||
|
let storageUsed = '';
|
||||||
|
try {
|
||||||
|
const capResp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch(
|
||||||
|
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const capData = JSON.parse(capResp);
|
||||||
|
if (capData.status === 200 && capData.data?.capacity_summary) {
|
||||||
|
const summary = capData.data.capacity_summary;
|
||||||
|
const total = summary.sum_capacity || 0;
|
||||||
|
storageTotal = formatBytes(total);
|
||||||
|
storageUsed = '0 B';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Build full cookie string
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
// Extract __uid for duplicate detection
|
||||||
|
const uidMatch = cookieStr.match(/__uid=([a-zA-Z0-9_-]+)/);
|
||||||
|
let autoUpdated = false;
|
||||||
|
let updatedConfigId: number | undefined;
|
||||||
|
|
||||||
|
if (uidMatch) {
|
||||||
|
const uid = uidMatch[1];
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
|
||||||
|
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
|
||||||
|
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
|
||||||
|
autoUpdated = true;
|
||||||
|
updatedConfigId = existing.id;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the cookie actually works with API before returning
|
||||||
|
const cookieValid = await isCookieValid(cookieStr);
|
||||||
|
if (!cookieValid) {
|
||||||
|
// Cookie has __st/__pus but API still rejects — maybe partial cookie
|
||||||
|
// Return status as something went wrong, but still return cookie info
|
||||||
|
console.log('[QR] Cookie validation failed after login, still returning cookie data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up session after successful login
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: cookieValid ? 'logged_in' : 'logged_in',
|
||||||
|
cookie: cookieStr,
|
||||||
|
nickname,
|
||||||
|
storage_used: storageUsed,
|
||||||
|
storage_total: storageTotal,
|
||||||
|
autoUpdated,
|
||||||
|
updatedConfigId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: session.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a QR login session.
|
||||||
|
*/
|
||||||
|
export async function cancelQrLogin(sessionId: string): Promise<void> {
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
}
|
||||||
237
packages/backend/src/cloud/quark-api.ts
Normal file
237
packages/backend/src/cloud/quark-api.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。
|
||||||
|
* 所有模块共用此单例/函数集,不持有状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QuarkConfig {
|
||||||
|
cookie: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Headers & Params ====================
|
||||||
|
|
||||||
|
const BASE_URL = 'https://drive-pc.quark.cn';
|
||||||
|
|
||||||
|
export function getHeaders(cookie: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
'Referer': 'https://pan.quark.cn/',
|
||||||
|
'Origin': 'https://pan.quark.cn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommonParams(): Record<string, string> {
|
||||||
|
return { pr: 'ucpro', fr: 'pc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate query string with common params + random timing to mimic browser */
|
||||||
|
export function makeQuery(extra: Record<string, string> = {}): string {
|
||||||
|
const __dt = Math.floor(Math.random() * 240000 + 60000);
|
||||||
|
const __t = Date.now() / 1000;
|
||||||
|
return new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
app: 'clouddrive',
|
||||||
|
__dt: String(__dt),
|
||||||
|
__t: String(__t),
|
||||||
|
...extra,
|
||||||
|
}).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Random delay to mimic human behavior (500-2000ms) */
|
||||||
|
export async function humanDelay(): Promise<void> {
|
||||||
|
const ms = Math.floor(Math.random() * 1500) + 500;
|
||||||
|
await new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random password for share links */
|
||||||
|
export function randomSharePwd(): string {
|
||||||
|
return Math.floor(1000 + Math.random() * 9000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix).
|
||||||
|
*/
|
||||||
|
export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } {
|
||||||
|
// Match kps=, _kps=, or __kps= (some cookies use __ prefix, some don't)
|
||||||
|
const kpsMatch = cookie.match(/_{0,2}kps=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
const signMatch = cookie.match(/_{0,2}sign=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
const vcodeMatch = cookie.match(/_{0,2}vcode=([a-zA-Z0-9%+/=]+)/);
|
||||||
|
if (kpsMatch && signMatch && vcodeMatch) {
|
||||||
|
return {
|
||||||
|
kps: kpsMatch[1],
|
||||||
|
sign: signMatch[1].replace(/%25/g, '%'),
|
||||||
|
vcode: vcodeMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Shared fetch helpers ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw fetch wrapper with JSON parse + status check.
|
||||||
|
* Returns parsed JSON body on 2xx, null on network error.
|
||||||
|
*/
|
||||||
|
export async function apiFetch<T = any>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method?: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
cookie: string;
|
||||||
|
timeout?: number;
|
||||||
|
},
|
||||||
|
): Promise<T | null> {
|
||||||
|
const { method = 'GET', query, body, cookie, timeout = 10000 } = options;
|
||||||
|
let url = `${BASE_URL}${path}`;
|
||||||
|
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...getHeaders(cookie),
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
return (await resp.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== File listing (shared across modules) ====================
|
||||||
|
|
||||||
|
export interface QuarkFile {
|
||||||
|
fid: string;
|
||||||
|
file_name: string;
|
||||||
|
share_fid_token?: string;
|
||||||
|
dir: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files in a directory by FID.
|
||||||
|
*/
|
||||||
|
export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise<QuarkFile[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...getCommonParams(),
|
||||||
|
uc_param_str: '',
|
||||||
|
pdir_fid: pdirFid,
|
||||||
|
_page: String(page),
|
||||||
|
_size: String(pageSize),
|
||||||
|
_fetch_total: '1',
|
||||||
|
_fetch_sub_dirs: '0',
|
||||||
|
_sort: 'file_type:asc,updated_at:desc',
|
||||||
|
fetch_all_file: '1',
|
||||||
|
fetch_risk_file_name: '1',
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status !== 200) return [];
|
||||||
|
return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({
|
||||||
|
fid: f.fid,
|
||||||
|
file_name: f.file_name,
|
||||||
|
share_fid_token: '',
|
||||||
|
dir: f.dir || false,
|
||||||
|
size: f.size || 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List root directory (pdir_fid=0) — returns all top-level dirs/files.
|
||||||
|
*/
|
||||||
|
export async function listRootDir(cookie: string): Promise<QuarkFile[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pr: 'ucpro', fr: 'pc',
|
||||||
|
pdir_fid: '0',
|
||||||
|
_page: '1', _size: '200',
|
||||||
|
_fetch_total: '1', _fetch_sub_dirs: '0',
|
||||||
|
_sort: 'file_type:asc,updated_at:desc',
|
||||||
|
fetch_all_file: '1',
|
||||||
|
fetch_risk_file_name: '1',
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`,
|
||||||
|
{ headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
if (data.status !== 200 || !data.data?.list) return [];
|
||||||
|
return (data.data.list || []).map((f: any) => ({
|
||||||
|
fid: f.fid,
|
||||||
|
file_name: f.file_name,
|
||||||
|
dir: f.dir || false,
|
||||||
|
size: f.size || 0,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all files in a directory, handling pagination.
|
||||||
|
* Fetches all pages until no more results.
|
||||||
|
*/
|
||||||
|
export async function listDirAllPages(cookie: string, pdirFid: string): Promise<QuarkFile[]> {
|
||||||
|
const allFiles: QuarkFile[] = [];
|
||||||
|
let page = 1;
|
||||||
|
const pageSize = 100;
|
||||||
|
let total = -1;
|
||||||
|
while (total === -1 || (page - 1) * pageSize < total) {
|
||||||
|
const files = await listDir(cookie, pdirFid, page, pageSize);
|
||||||
|
if (!files.length) break;
|
||||||
|
allFiles.push(...files);
|
||||||
|
if (total === -1) {
|
||||||
|
total = files.length;
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return allFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Format utilities ====================
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */
|
||||||
|
export function dailyFolderName(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random folder name for saving (fallback) */
|
||||||
|
export function randomFolderName(): string {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let name = '';
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
name += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
56
packages/backend/src/config/cloud-labels.ts
Executable file
56
packages/backend/src/config/cloud-labels.ts
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Cloud type labels and colors
|
||||||
|
* Shared between backend and frontend-facing routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cloud domain → type regex mapping (single source of truth) */
|
||||||
|
export const CLOUD_DOMAIN_PATTERNS: Array<{ regex: RegExp; type: string }> = [
|
||||||
|
{ regex: /pan\.baidu\.com/i, type: 'baidu' },
|
||||||
|
{ regex: /pan\.quark\.cn/i, type: 'quark' },
|
||||||
|
{ regex: /aliyundrive\.com|alipan\.com/i, type: 'aliyun' },
|
||||||
|
{ regex: /115\.com|115cdn\.com/i, type: '115' },
|
||||||
|
{ regex: /cloud\.189\.cn/i, type: 'tianyi' },
|
||||||
|
{ regex: /123pan\.com|123684\.com|123912\.com/i, type: '123pan' },
|
||||||
|
{ regex: /drive\.uc\.cn/i, type: 'uc' },
|
||||||
|
{ regex: /pan\.xunlei\.com/i, type: 'xunlei' },
|
||||||
|
{ regex: /magnet:/i, type: 'magnet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Detect cloud type from a URL string */
|
||||||
|
export function detectCloudType(url: string | undefined | null): string {
|
||||||
|
if (!url) return 'others';
|
||||||
|
for (const { regex, type } of CLOUD_DOMAIN_PATTERNS) {
|
||||||
|
if (regex.test(url)) return type;
|
||||||
|
}
|
||||||
|
return 'others';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLOUD_LABELS: Record<string, string> = {
|
||||||
|
quark: '夸克网盘',
|
||||||
|
baidu: '百度网盘',
|
||||||
|
aliyun: '阿里云盘',
|
||||||
|
'115': '115网盘',
|
||||||
|
tianyi: '天翼云盘',
|
||||||
|
'123pan': '123云盘',
|
||||||
|
uc: 'UC网盘',
|
||||||
|
xunlei: '迅雷云盘',
|
||||||
|
pikpak: 'PikPak',
|
||||||
|
magnet: '磁力链接',
|
||||||
|
ed2k: '电驴链接',
|
||||||
|
others: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CLOUD_COLORS: Record<string, string> = {
|
||||||
|
quark: '#07c160',
|
||||||
|
baidu: '#4e6ef2',
|
||||||
|
aliyun: '#ff6a00',
|
||||||
|
'115': '#9b59b6',
|
||||||
|
tianyi: '#00a1d6',
|
||||||
|
'123pan': '#e74c3c',
|
||||||
|
uc: '#f39c12',
|
||||||
|
xunlei: '#2ecc71',
|
||||||
|
pikpak: '#8e44ad',
|
||||||
|
magnet: '#95a5a6',
|
||||||
|
ed2k: '#7f8c8d',
|
||||||
|
others: '#95a5a6',
|
||||||
|
};
|
||||||
51
packages/backend/src/config/index.ts
Executable file
51
packages/backend/src/config/index.ts
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
nodeEnv: string;
|
||||||
|
redisUrl: string;
|
||||||
|
pansouUrl: string;
|
||||||
|
pansouAuthToken: string;
|
||||||
|
videoParserUrl: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
adminUsername: string;
|
||||||
|
adminPassword: string;
|
||||||
|
validation: {
|
||||||
|
concurrency: number;
|
||||||
|
timeout: number;
|
||||||
|
cacheTtlValid: number;
|
||||||
|
cacheTtlInvalid: number;
|
||||||
|
};
|
||||||
|
dbPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
port: parseInt(process.env.PORT || '9527', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
|
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||||
|
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
|
||||||
|
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
|
||||||
|
videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001',
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'cloudsearch-jwt-secret-dev',
|
||||||
|
adminUsername: process.env.ADMIN_USERNAME || 'admin',
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
|
||||||
|
validation: {
|
||||||
|
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
|
||||||
|
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),
|
||||||
|
cacheTtlValid: parseInt(process.env.CACHE_TTL_VALID || '14400', 10), // 4小时
|
||||||
|
cacheTtlInvalid: parseInt(process.env.CACHE_TTL_INVALID || '3600', 10), // 1小时
|
||||||
|
},
|
||||||
|
dbPath: process.env.DB_PATH || './data/cloudsearch.db',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生产环境强制校验关键安全配置
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'cloudsearch-jwt-secret-dev') {
|
||||||
|
console.error('[FATAL] JWT_SECRET 未设置或使用了默认值,请在 .env 中设置强密码')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!process.env.ADMIN_PASSWORD || process.env.ADMIN_PASSWORD === 'admin123') {
|
||||||
|
console.error('[FATAL] ADMIN_PASSWORD 未设置或使用了默认值,请在 .env 中设置强密码')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config;
|
||||||
110
packages/backend/src/config/startup-validator.ts
Normal file
110
packages/backend/src/config/startup-validator.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 启动时配置校验器
|
||||||
|
*
|
||||||
|
* 在服务器启动前验证关键配置项,生产环境缺少必需配置时拒绝启动。
|
||||||
|
*/
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
key: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'error' | 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConfig(): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
const isProd = config.nodeEnv === 'production';
|
||||||
|
|
||||||
|
// ─── JWT Secret ───
|
||||||
|
const DEFAULT_JWT_SECRETS = [
|
||||||
|
'cloudsearch-jwt-secret-dev',
|
||||||
|
'your-super-secret-jwt-key-change-me',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (DEFAULT_JWT_SECRETS.includes(config.jwtSecret)) {
|
||||||
|
if (isProd) {
|
||||||
|
errors.push({
|
||||||
|
key: 'JWT_SECRET',
|
||||||
|
message: '生产环境不允许使用默认 JWT Secret!请设置随机密钥(openssl rand -hex 32)',
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errors.push({
|
||||||
|
key: 'JWT_SECRET',
|
||||||
|
message: '开发环境使用了默认 JWT Secret,生产部署前必须修改',
|
||||||
|
severity: 'warn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin Password ───
|
||||||
|
const weakPasswords = ['admin123', 'admin', 'password', '123456', ''];
|
||||||
|
if (weakPasswords.includes(config.adminPassword)) {
|
||||||
|
errors.push({
|
||||||
|
key: 'ADMIN_PASSWORD',
|
||||||
|
message: `弱管理员密码: "${config.adminPassword}",请设置强密码`,
|
||||||
|
severity: isProd ? 'error' : 'warn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cookie Encryption ───
|
||||||
|
if (!process.env.COOKIE_ENCRYPTION_KEY) {
|
||||||
|
errors.push({
|
||||||
|
key: 'COOKIE_ENCRYPTION_KEY',
|
||||||
|
message: '未设置网盘 Cookie 加密密钥!Cookie 将以明文存储。生产环境强烈建议设置。\n' +
|
||||||
|
'生成: openssl rand -hex 32',
|
||||||
|
severity: 'warn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CORS ───
|
||||||
|
const corsOrigin = process.env.CORS_ORIGIN || '';
|
||||||
|
if (isProd && (!corsOrigin || corsOrigin === 'https://your-production-domain.com')) {
|
||||||
|
errors.push({
|
||||||
|
key: 'CORS_ORIGIN',
|
||||||
|
message: '生产环境未配置真实的 CORS_ORIGIN,临时允许所有来源请求',
|
||||||
|
severity: 'warn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Port conflict check (best-effort) ───
|
||||||
|
if (config.port < 1024 && process.getuid?.() !== 0) {
|
||||||
|
errors.push({
|
||||||
|
key: 'PORT',
|
||||||
|
message: `端口 ${config.port} 需要 root 权限(<1024),建议使用 9527 或更高端口`,
|
||||||
|
severity: 'warn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print validation results and return whether startup should proceed.
|
||||||
|
* Returns false if any 'error' severity issue found in production.
|
||||||
|
*/
|
||||||
|
export function checkStartup(): boolean {
|
||||||
|
const errors = validateConfig();
|
||||||
|
const isProd = config.nodeEnv === 'production';
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
if (errors.length === 0) {
|
||||||
|
console.log('[Config] ✅ 所有配置检查通过');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Config] ── 配置检查结果 ──');
|
||||||
|
for (const err of errors) {
|
||||||
|
const prefix = err.severity === 'error' ? '❌' : '⚠️';
|
||||||
|
console.log(`[Config] ${prefix} [${err.severity.toUpperCase()}] ${err.key}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const criticalErrors = errors.filter(e => e.severity === 'error');
|
||||||
|
if (criticalErrors.length > 0 && isProd) {
|
||||||
|
console.error('[Config] 🛑 生产环境存在严重配置错误,拒绝启动。请修复后重试。');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Config] ${hasErrors ? '⚠️ 存在警告,继续启动' : '✅ 继续启动'}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
325
packages/backend/src/content/content.service.ts
Executable file
325
packages/backend/src/content/content.service.ts
Executable file
@@ -0,0 +1,325 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { localTimestamp } from '../utils/time';
|
||||||
|
|
||||||
|
export interface ContentInfo {
|
||||||
|
keyword: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
cover: string;
|
||||||
|
source: string;
|
||||||
|
/** TMDB 详情页链接 */
|
||||||
|
tmdb_url?: string;
|
||||||
|
/** 评分 e.g. "7.3" */
|
||||||
|
rating?: string;
|
||||||
|
/** 评分人数 e.g. "12345" */
|
||||||
|
rating_count?: string;
|
||||||
|
/** 发布年份 e.g. "2025" */
|
||||||
|
year?: string;
|
||||||
|
/** 类型标签 e.g. ["动作", "科幻"] */
|
||||||
|
genres?: string[];
|
||||||
|
/** 导演 e.g. "克里斯托弗·诺兰" */
|
||||||
|
directors?: string;
|
||||||
|
/** 演员(前5个) e.g. "基里安·墨菲 / 艾米莉·布朗特" */
|
||||||
|
actors?: string;
|
||||||
|
/** 制片国家/地区 e.g. "美国 / 英国" */
|
||||||
|
region?: string;
|
||||||
|
/** 片长 e.g. "180分钟" */
|
||||||
|
duration?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function getContentInfo(keyword: string): Promise<ContentInfo | null> {
|
||||||
|
if (!keyword || keyword.length < 1) return null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
|
||||||
|
if (!tmdbToken) return null;
|
||||||
|
|
||||||
|
const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any;
|
||||||
|
if (cached) {
|
||||||
|
const age = Date.now() - new Date(cached.updated_at + 'Z').getTime();
|
||||||
|
if (age < CACHE_TTL_MS) {
|
||||||
|
return rowToContentInfo(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await fetchFromTMDB(keyword, tmdbToken);
|
||||||
|
if (info) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO content_cache
|
||||||
|
(keyword, title, description, tags, cover, douban_url, source, updated_at,
|
||||||
|
rating, rating_count, year, genres, directors, actors, region, duration)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?,
|
||||||
|
?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(),
|
||||||
|
info.rating || '', info.rating_count || '', info.year || '',
|
||||||
|
JSON.stringify(info.genres || []), info.directors || '', info.actors || '',
|
||||||
|
info.region || '', info.duration || ''
|
||||||
|
);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Content] Failed to fetch for "${keyword}":`, err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToContentInfo(row: any): ContentInfo {
|
||||||
|
return {
|
||||||
|
keyword: row.keyword,
|
||||||
|
title: row.title || '',
|
||||||
|
description: row.description || '',
|
||||||
|
tags: safeParseTags(row.tags),
|
||||||
|
cover: row.cover || '',
|
||||||
|
source: row.source || (row.title ? 'tmdb' : 'cache'),
|
||||||
|
tmdb_url: row.douban_url || '',
|
||||||
|
rating: row.rating || '',
|
||||||
|
rating_count: row.rating_count || '',
|
||||||
|
year: row.year || '',
|
||||||
|
genres: safeParseTags(row.genres),
|
||||||
|
directors: row.directors || '',
|
||||||
|
actors: row.actors || '',
|
||||||
|
region: row.region || '',
|
||||||
|
duration: row.duration || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise<ContentInfo | null> {
|
||||||
|
// Step 1: TMDB search — search both movie and TV in parallel
|
||||||
|
let movieResults: any[] = [];
|
||||||
|
let tvResults: any[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchUrl = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||||
|
const searchResp = await fetch(searchUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (searchResp.ok) {
|
||||||
|
const searchData = await searchResp.json() as any;
|
||||||
|
if (Array.isArray(searchData.results)) {
|
||||||
|
movieResults = searchData.results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn(`[Content] TMDB movie search failed for "${keyword}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchUrl = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`;
|
||||||
|
const searchResp = await fetch(searchUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (searchResp.ok) {
|
||||||
|
const searchData = await searchResp.json() as any;
|
||||||
|
if (Array.isArray(searchData.results)) {
|
||||||
|
tvResults = searchData.results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn(`[Content] TMDB TV search failed for "${keyword}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Score and rank all results
|
||||||
|
const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword);
|
||||||
|
const kwLower = keyword.toLowerCase();
|
||||||
|
|
||||||
|
// Score function: higher = better match
|
||||||
|
function scoreResult(item: any, type: 'tv' | 'movie'): number {
|
||||||
|
const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase();
|
||||||
|
// Exact match gets highest priority
|
||||||
|
if (name === kwLower) return 100;
|
||||||
|
// Name starts with keyword
|
||||||
|
if (name.startsWith(kwLower)) return 80;
|
||||||
|
// Name contains keyword as a standalone segment
|
||||||
|
if (name.includes(kwLower)) return 60;
|
||||||
|
// Keyword contains significant portion of name
|
||||||
|
const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
|
||||||
|
if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40;
|
||||||
|
// Partial match
|
||||||
|
if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score all TV results
|
||||||
|
const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0);
|
||||||
|
// Score all movie results
|
||||||
|
const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0);
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
scoredTV.sort((a, b) => b.score - a.score);
|
||||||
|
scoredMovie.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const tvBest = scoredTV[0]?.item || null;
|
||||||
|
const movieBest = scoredMovie[0]?.item || null;
|
||||||
|
const tvBestScore = scoredTV[0]?.score || 0;
|
||||||
|
const movieBestScore = scoredMovie[0]?.score || 0;
|
||||||
|
|
||||||
|
let best: any = null;
|
||||||
|
let mediaType: 'movie' | 'tv' = 'movie';
|
||||||
|
let movie: any = null;
|
||||||
|
|
||||||
|
if (tvBest && movieBest) {
|
||||||
|
// Both have matches — score-based comparison
|
||||||
|
// For Chinese keywords: TV gets +15 score bonus to prefer series over movies
|
||||||
|
const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0);
|
||||||
|
const movieScore = movieBestScore;
|
||||||
|
if (tvScore > movieScore) {
|
||||||
|
best = tvBest;
|
||||||
|
mediaType = 'tv';
|
||||||
|
} else if (movieScore > tvScore) {
|
||||||
|
best = movieBest;
|
||||||
|
mediaType = 'movie';
|
||||||
|
} else {
|
||||||
|
// Tie — prefer TV for Chinese keywords, otherwise pick higher vote count
|
||||||
|
if (isChineseKeyword) {
|
||||||
|
best = tvBest;
|
||||||
|
mediaType = 'tv';
|
||||||
|
} else {
|
||||||
|
const tvVotes = tvBest.vote_count || 0;
|
||||||
|
const movieVotes = movieBest.vote_count || 0;
|
||||||
|
best = tvVotes >= movieVotes ? tvBest : movieBest;
|
||||||
|
mediaType = tvVotes >= movieVotes ? 'tv' : 'movie';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (tvBest) {
|
||||||
|
best = tvBest;
|
||||||
|
mediaType = 'tv';
|
||||||
|
} else if (movieBest) {
|
||||||
|
best = movieBest;
|
||||||
|
mediaType = 'movie';
|
||||||
|
} else if (scoredTV.length > 0 && !scoredMovie.length) {
|
||||||
|
best = scoredTV[0].item;
|
||||||
|
mediaType = 'tv';
|
||||||
|
} else if (scoredMovie.length > 0) {
|
||||||
|
best = scoredMovie[0].item;
|
||||||
|
mediaType = 'movie';
|
||||||
|
} else if (tvResults.length > 0 && !movieResults.length) {
|
||||||
|
best = tvResults[0];
|
||||||
|
mediaType = 'tv';
|
||||||
|
} else if (movieResults.length > 0) {
|
||||||
|
best = movieResults[0];
|
||||||
|
mediaType = 'movie';
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmdbId = best.id;
|
||||||
|
try {
|
||||||
|
const detailUrl = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`;
|
||||||
|
const detailResp = await fetch(detailUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (detailResp.ok) {
|
||||||
|
movie = await detailResp.json() as any;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!movie) return null;
|
||||||
|
|
||||||
|
// Extract TMDB data (use title for movie, name for TV)
|
||||||
|
const title = movie.title || movie.name || keyword;
|
||||||
|
const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : '';
|
||||||
|
const ratingCount = movie.vote_count ? String(movie.vote_count) : '';
|
||||||
|
// Use release_date for movie, first_air_date for TV
|
||||||
|
const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : '');
|
||||||
|
const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : [];
|
||||||
|
// Directors: tv shows have limited crew data, fall back to "creator" for TV
|
||||||
|
const directors = Array.isArray(movie.credits?.crew)
|
||||||
|
? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||||
|
: '';
|
||||||
|
const actors = Array.isArray(movie.credits?.cast)
|
||||||
|
? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||||
|
: '';
|
||||||
|
const region = Array.isArray(movie.production_countries)
|
||||||
|
? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ')
|
||||||
|
: (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : '');
|
||||||
|
const duration = mediaType === 'movie'
|
||||||
|
? (movie.runtime > 0 ? `${movie.runtime}分钟` : '')
|
||||||
|
: (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : '');
|
||||||
|
const description = movie.overview ? movie.overview.substring(0, 200) : '';
|
||||||
|
const cover = movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '';
|
||||||
|
|
||||||
|
// TMDB detail page URL
|
||||||
|
const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${tmdbId}`;
|
||||||
|
|
||||||
|
// Generate tags from keyword + title
|
||||||
|
const tags = genTags({ keyword, title });
|
||||||
|
|
||||||
|
// Build description fallback
|
||||||
|
let desc = description;
|
||||||
|
if (!desc) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (year) parts.push(`${year}年`);
|
||||||
|
if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / '));
|
||||||
|
if (duration) parts.push(duration);
|
||||||
|
desc = parts.length > 0 ? parts.join(' · ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
title,
|
||||||
|
description: desc,
|
||||||
|
tags,
|
||||||
|
cover,
|
||||||
|
source: 'tmdb',
|
||||||
|
tmdb_url: tmdbUrl,
|
||||||
|
rating,
|
||||||
|
rating_count: ratingCount,
|
||||||
|
year,
|
||||||
|
genres,
|
||||||
|
directors,
|
||||||
|
actors,
|
||||||
|
region,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function genTags(opts: { keyword: string; title: string }): string[] {
|
||||||
|
const { keyword, title } = opts;
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (keyword.length <= 8) tags.push(keyword);
|
||||||
|
|
||||||
|
const txt = (title + ' ' + keyword).toLowerCase();
|
||||||
|
const isDonghua = /动画|动漫/i.test(txt);
|
||||||
|
if (isDonghua) {
|
||||||
|
tags.push('动画'); tags.push('国漫');
|
||||||
|
} else {
|
||||||
|
tags.push('电影');
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreMap: Record<string, string[]> = {
|
||||||
|
'动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'],
|
||||||
|
'剧场版': ['剧场版'], '年番': ['年番'],
|
||||||
|
'动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'],
|
||||||
|
'仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'],
|
||||||
|
'科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'],
|
||||||
|
'冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'],
|
||||||
|
};
|
||||||
|
for (const [key, vals] of Object.entries(genreMap)) {
|
||||||
|
if (txt.includes(key)) {
|
||||||
|
for (const v of vals) { if (!tags.includes(v)) tags.push(v); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseTags(tagsStr: string | null | undefined): string[] {
|
||||||
|
if (!tagsStr) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(tagsStr);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
615
packages/backend/src/database/admin.routes.ts
Normal file
615
packages/backend/src/database/admin.routes.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
import fs from "fs";
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||||
|
import { getSaveRecords } from '../cloud/cloud.service';
|
||||||
|
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||||
|
// Note: check-in routes were removed (sign-in feature removed)
|
||||||
|
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||||
|
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||||
|
import { getStats } from '../admin/stats.service';
|
||||||
|
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { testProxyConnection } from '../utils/proxy-agent';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
|
||||||
|
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||||
|
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Public routes (no auth required)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/login
|
||||||
|
* Admin login
|
||||||
|
*/
|
||||||
|
router.post('/admin/login', loginLimiter, (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = login(username, password);
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Login] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/cloud-types
|
||||||
|
* List all cloud types (public, read-only).
|
||||||
|
*/
|
||||||
|
router.get('/admin/cloud-types', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const types = getAllCloudTypes();
|
||||||
|
res.json({ types });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// QR Login routes (no auth — user not logged in yet)
|
||||||
|
// MUST be before authMiddleware!
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// ===== 夸克扫码登录 =====
|
||||||
|
router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result = await getQrLoginStatus(sessionId);
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
await cancelQrLogin(sessionId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 百度扫码登录 =====
|
||||||
|
router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await BaiduDriver.startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result: any = await BaiduDriver.getQrLoginStatus(sessionId);
|
||||||
|
// Map to frontend-expected format (frontend reads data.cookie)
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
status: result.status,
|
||||||
|
cookie: result.cookie || result.access_token || "",
|
||||||
|
nickname: result.nickname || "",
|
||||||
|
storage_used: result.storage_used || "",
|
||||||
|
storage_total: result.storage_total || "",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
BaiduDriver.cancelQrLogin(req.params.sessionId as string);
|
||||||
|
} catch {}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Auth wall — all routes below require JWT
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
router.use('/admin', authMiddleware);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Configs CRUD
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/cloud-configs — list all cloud configs */
|
||||||
|
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getCloudConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
|
||||||
|
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
if (!data.cloud_type) {
|
||||||
|
res.status(400).json({ error: 'cloud_type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
||||||
|
// Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0;
|
||||||
|
const saved = saveCloudConfig(data);
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */
|
||||||
|
router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const existing = getCloudConfigById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = saveCloudConfig({ ...req.body, id });
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/admin/cloud-configs/:id */
|
||||||
|
router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const ok = deleteCloudConfig(id);
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to delete cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||||
|
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const type = req.params.type as string;
|
||||||
|
const { cookie, id } = req.body;
|
||||||
|
|
||||||
|
// If cookie is provided directly, test with it (for new configs not yet saved)
|
||||||
|
if (cookie) {
|
||||||
|
const result = await testCloudConnectionWithCookie(type, cookie);
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise test by config id
|
||||||
|
if (id) {
|
||||||
|
const result = await testCloudConnection(parseInt(id));
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ success: false, message: 'Provide either cookie or id' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, message: err.message || 'Connection test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Stats
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/stats */
|
||||||
|
router.get('/admin/stats', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const days = req.query.days ? parseInt(req.query.days as string) : 7;
|
||||||
|
const stats = getStats(days);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Save Records (转存日志)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/save-records */
|
||||||
|
router.get('/admin/save-records', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
|
const startDate = req.query.startDate as string | undefined;
|
||||||
|
const endDate = req.query.endDate as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const sourceType = req.query.sourceType as string | undefined;
|
||||||
|
const keyword = req.query.keyword as string | undefined;
|
||||||
|
const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get save records' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// System Configs
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/system-configs */
|
||||||
|
router.get('/admin/system-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getAllSystemConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/system-configs — batch update */
|
||||||
|
router.put('/admin/system-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { entries } = req.body;
|
||||||
|
if (!entries || !Array.isArray(entries)) {
|
||||||
|
res.status(400).json({ error: 'entries array is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateSystemConfigs(entries);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Types Toggle
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */
|
||||||
|
router.put('/admin/cloud-types', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, enabled } = req.body;
|
||||||
|
if (!type) {
|
||||||
|
res.status(400).json({ error: 'type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||||
|
).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to toggle cloud type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Change Password
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/change-password */
|
||||||
|
router.post('/admin/change-password', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { oldPassword, newPassword } = req.body;
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
res.status(400).json({ error: 'Both old and new passwords are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get username from JWT
|
||||||
|
const authHeader = req.headers.authorization || '';
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = changePassword(payload.username, oldPassword, newPassword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to change password' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// DB Status
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/db-status */
|
||||||
|
router.get('/admin/db-status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dbFile = getSystemConfig('db_path') || '';
|
||||||
|
let dbSize = 'N/A';
|
||||||
|
if (dbFile) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(dbFile);
|
||||||
|
dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const counts = {
|
||||||
|
save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0,
|
||||||
|
search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0,
|
||||||
|
system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0,
|
||||||
|
cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0,
|
||||||
|
content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis status
|
||||||
|
let redis_status = 'disconnected';
|
||||||
|
let redis_url = getSystemConfig('redis_url') || '';
|
||||||
|
try {
|
||||||
|
const testResult = await testRedisConnection(redis_url);
|
||||||
|
redis_status = testResult.ok ? 'connected' : 'disconnected';
|
||||||
|
} catch {
|
||||||
|
redis_status = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
db_size: dbSize,
|
||||||
|
db_path: dbFile,
|
||||||
|
...counts,
|
||||||
|
redis_status,
|
||||||
|
redis_url,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get DB status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test Redis Connection
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-redis */
|
||||||
|
router.post('/admin/test-redis', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ ok: false, info: 'Redis URL is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testRedisConnection(url);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'Redis test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test External Service
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-external-service */
|
||||||
|
router.post('/admin/test-external-service', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, url, token } = req.body;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'pansou': {
|
||||||
|
const pansouUrl = url || getSystemConfig('pansou_url') || '';
|
||||||
|
if (!pansouUrl) {
|
||||||
|
res.json({ ok: false, info: 'PanSou URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok && data?.status === 'ok',
|
||||||
|
latency,
|
||||||
|
info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video_parser': {
|
||||||
|
const parserUrl = url || getSystemConfig('video_parser_url') || '';
|
||||||
|
if (!parserUrl) {
|
||||||
|
res.json({ ok: false, info: 'Video Parser URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tmdb': {
|
||||||
|
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
|
||||||
|
if (!tmdbToken) {
|
||||||
|
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch('https://api.themoviedb.org/3/configuration', {
|
||||||
|
headers: { Authorization: `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
const proxyUrl = url || getSystemConfig('search_proxy_url') || '';
|
||||||
|
if (!proxyUrl) {
|
||||||
|
res.json({ ok: false, info: 'Proxy URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testProxyConnection(proxyUrl);
|
||||||
|
res.json(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ip_geo': {
|
||||||
|
const geoUrl = url || getSystemConfig('ip_geo_api_url') || '';
|
||||||
|
if (!geoUrl) {
|
||||||
|
res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const testUrl = geoUrl.replace('{ip}', '8.8.8.8');
|
||||||
|
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
const valid = !!(data?.country || data?.region || data?.city || data?.countryCode);
|
||||||
|
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
res.json({ ok: false, info: `Unknown service type: ${type}` });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'External service test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Pansou Info & Update
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/pansou-info — pansou health + version + update check */
|
||||||
|
router.get('/admin/pansou-info', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getSystemConfig('pansou_url') || '';
|
||||||
|
if (!baseUrl) {
|
||||||
|
res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PanSou health
|
||||||
|
const healthUrl = baseUrl + '/api/health';
|
||||||
|
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const healthData: any = await response.json();
|
||||||
|
const channelCount = healthData.channels_count || 0;
|
||||||
|
const pluginCount = healthData.plugin_count || 0;
|
||||||
|
|
||||||
|
// Derive disk count from channel names
|
||||||
|
const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease'];
|
||||||
|
const drives = new Set<string>();
|
||||||
|
for (const ch of (healthData.channels || [])) {
|
||||||
|
for (const kw of driveKeywords) {
|
||||||
|
if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diskCount = drives.size || 5;
|
||||||
|
|
||||||
|
// Get local version from docker label
|
||||||
|
let version = '';
|
||||||
|
let hasUpdate = false;
|
||||||
|
let latestVersion = '';
|
||||||
|
try {
|
||||||
|
const created = execSync(
|
||||||
|
`docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`,
|
||||||
|
{ timeout: 5000, encoding: 'utf8' }
|
||||||
|
).trim();
|
||||||
|
version = created ? created.slice(0, 10) : '';
|
||||||
|
|
||||||
|
// Check update cache
|
||||||
|
const cacheFile = '/tmp/pansou-update-cache.json';
|
||||||
|
let cache: any = null;
|
||||||
|
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {}
|
||||||
|
const threeDays = 3 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
if (!cache || (Date.now() - cache.checkedAt) > threeDays) {
|
||||||
|
// Check GHCR for latest version
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch(
|
||||||
|
'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io'
|
||||||
|
);
|
||||||
|
const ghcrToken = (await tokenRes.json() as any).token;
|
||||||
|
const manifestRes = await fetch(
|
||||||
|
'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest',
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } }
|
||||||
|
);
|
||||||
|
const manifestList: any = await manifestRes.json();
|
||||||
|
const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux');
|
||||||
|
if (amd64) {
|
||||||
|
const blobRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } }
|
||||||
|
);
|
||||||
|
const blobData: any = await blobRes.json();
|
||||||
|
const cfgDigest = blobData.config?.digest;
|
||||||
|
if (cfgDigest) {
|
||||||
|
const cfgRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}` } }
|
||||||
|
);
|
||||||
|
const cfgData: any = await cfgRes.json();
|
||||||
|
const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created'];
|
||||||
|
if (remoteCreated) {
|
||||||
|
latestVersion = remoteCreated.slice(0, 10);
|
||||||
|
if (version && latestVersion !== version) hasUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion }));
|
||||||
|
} else {
|
||||||
|
hasUpdate = cache.hasUpdate;
|
||||||
|
latestVersion = cache.latestVersion;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: response.ok ? 'connected' : 'disconnected',
|
||||||
|
channelCount,
|
||||||
|
pluginCount,
|
||||||
|
diskCount,
|
||||||
|
version,
|
||||||
|
hasUpdate,
|
||||||
|
latestVersion,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */
|
||||||
|
router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 });
|
||||||
|
execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 });
|
||||||
|
try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {}
|
||||||
|
res.json({ success: true, message: 'PanSou 更新成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
330
packages/backend/src/database/database.ts
Executable file
330
packages/backend/src/database/database.ts
Executable file
@@ -0,0 +1,330 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import config from '../config';
|
||||||
|
import { formatLocalDateTime } from '../utils/time';
|
||||||
|
|
||||||
|
let db: Database.Database | null = null;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (db) return db;
|
||||||
|
|
||||||
|
const dbDir = path.dirname(config.dbPath);
|
||||||
|
const fs = require('fs');
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
db = new Database(config.dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
runMigrations(db);
|
||||||
|
seedAdmin(db);
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
last_login TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cloud_configs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cloud_type TEXT NOT NULL,
|
||||||
|
cookie TEXT,
|
||||||
|
nickname TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
storage_used TEXT,
|
||||||
|
storage_total TEXT,
|
||||||
|
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||||
|
last_checkin_at TEXT,
|
||||||
|
checkin_message TEXT,
|
||||||
|
consecutive_failures INTEGER DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
total_saves INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promotions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT,
|
||||||
|
position TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
click_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS save_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_type TEXT,
|
||||||
|
source_title TEXT,
|
||||||
|
source_url TEXT,
|
||||||
|
target_cloud TEXT,
|
||||||
|
share_url TEXT,
|
||||||
|
share_pwd TEXT,
|
||||||
|
file_size TEXT,
|
||||||
|
file_count INTEGER DEFAULT 0,
|
||||||
|
duration_ms INTEGER DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT '',
|
||||||
|
error_message TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS search_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT,
|
||||||
|
intent TEXT,
|
||||||
|
result_count INTEGER DEFAULT 0,
|
||||||
|
ip_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hot_keywords (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT UNIQUE NOT NULL,
|
||||||
|
search_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_configs (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
keyword TEXT UNIQUE NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
cover TEXT,
|
||||||
|
source TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
seedSystemConfigs(db);
|
||||||
|
migrateSaveRecords(db);
|
||||||
|
migrateContentCache(db);
|
||||||
|
migrateCloudConfigs(db);
|
||||||
|
cleanupOldSaveRecords(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移: 给已有 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 {
|
||||||
|
const columns: { col: string; def: string }[] = [
|
||||||
|
{ col: 'douban_url', def: 'TEXT' },
|
||||||
|
{ col: 'rating', def: 'TEXT' },
|
||||||
|
{ col: 'rating_count', def: 'TEXT' },
|
||||||
|
{ col: 'year', def: 'TEXT' },
|
||||||
|
{ col: 'genres', def: 'TEXT' },
|
||||||
|
{ col: 'directors', def: 'TEXT' },
|
||||||
|
{ col: 'actors', def: 'TEXT' },
|
||||||
|
{ col: 'region', def: 'TEXT' },
|
||||||
|
{ col: 'duration', def: 'TEXT' },
|
||||||
|
];
|
||||||
|
for (const { col, def } of columns) {
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`);
|
||||||
|
} catch {
|
||||||
|
// Column already exists — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb
|
||||||
|
db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */
|
||||||
|
function migrateCloudConfigs(db: Database.Database): void {
|
||||||
|
// 加新列
|
||||||
|
const newCols: { col: string; def: string }[] = [
|
||||||
|
{ col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" },
|
||||||
|
{ col: 'last_checkin_at', def: 'TEXT' },
|
||||||
|
{ col: 'checkin_message', def: 'TEXT' },
|
||||||
|
{ col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' },
|
||||||
|
{ col: 'last_used_at', def: 'TEXT' },
|
||||||
|
{ col: 'total_saves', def: 'INTEGER DEFAULT 0' },
|
||||||
|
];
|
||||||
|
for (const { col, def } of newCols) {
|
||||||
|
try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {}
|
||||||
|
}
|
||||||
|
// 检查旧表是否有 UNIQUE 约束,有则重建表
|
||||||
|
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any;
|
||||||
|
if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cloud_configs_v2 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
cloud_type TEXT NOT NULL,
|
||||||
|
cookie TEXT,
|
||||||
|
nickname TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
storage_used TEXT,
|
||||||
|
storage_total TEXT,
|
||||||
|
checkin_status TEXT NOT NULL DEFAULT 'none',
|
||||||
|
last_checkin_at TEXT,
|
||||||
|
checkin_message TEXT,
|
||||||
|
consecutive_failures INTEGER DEFAULT 0,
|
||||||
|
last_used_at TEXT,
|
||||||
|
total_saves INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
|
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
||||||
|
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||||
|
DROP TABLE cloud_configs;
|
||||||
|
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||||
|
`);
|
||||||
|
console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 2: Add verification_status column
|
||||||
|
const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get();
|
||||||
|
if (!row2) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL");
|
||||||
|
console.log('[DB] cloud_configs migration: verification_status column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 3: Add cloud_type_uid column (for Quark __uid dedup)
|
||||||
|
const row3 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get();
|
||||||
|
if (!row3) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL");
|
||||||
|
console.log('[DB] cloud_configs migration: cloud_type_uid column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 4: Add promotion_account column
|
||||||
|
const row4 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get();
|
||||||
|
if (!row4) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT ''");
|
||||||
|
console.log('[DB] cloud_configs migration: promotion_account column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 5: Add is_transfer_enabled column
|
||||||
|
const row5 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_transfer_enabled%'").get();
|
||||||
|
if (!row5) {
|
||||||
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
|
||||||
|
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAdmin(db: Database.Database): void {
|
||||||
|
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const salt = bcrypt.genSaltSync(10);
|
||||||
|
const hash = bcrypt.hashSync(config.adminPassword, salt);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
|
||||||
|
).run(config.adminUsername, hash);
|
||||||
|
|
||||||
|
console.log(`[DB] Admin user "${config.adminUsername}" created`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSystemConfigs(db: Database.Database): void {
|
||||||
|
const defaults: { key: string; value: string; description: string }[] = [
|
||||||
|
{ key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' },
|
||||||
|
{ key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' },
|
||||||
|
{ key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' },
|
||||||
|
{ key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' },
|
||||||
|
{ key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' },
|
||||||
|
{ key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' },
|
||||||
|
{ key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' },
|
||||||
|
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||||
|
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||||
|
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||||
|
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
||||||
|
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
||||||
|
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
||||||
|
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
||||||
|
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
||||||
|
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
||||||
|
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
||||||
|
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
||||||
|
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
||||||
|
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
||||||
|
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
||||||
|
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
|
||||||
|
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||||
|
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||||
|
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||||
|
{ key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' },
|
||||||
|
{ key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' },
|
||||||
|
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||||
|
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||||
|
{ key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=***&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' },
|
||||||
|
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||||
|
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||||
|
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||||
|
{ key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' },
|
||||||
|
{ key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' },
|
||||||
|
{ key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' },
|
||||||
|
{ key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' },
|
||||||
|
{ key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' },
|
||||||
|
{ key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' },
|
||||||
|
{ key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' },
|
||||||
|
{ key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' },
|
||||||
|
{ 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_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: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' },
|
||||||
|
];
|
||||||
|
const insert = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
for (const entry of defaults) {
|
||||||
|
insert.run(entry.key, entry.value, entry.description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理 60 天前的转存记录 */
|
||||||
|
function cleanupOldSaveRecords(db: Database.Database): void {
|
||||||
|
const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||||
|
const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff);
|
||||||
|
console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getDb;
|
||||||
41
packages/backend/src/intent/intent.service.ts
Executable file
41
packages/backend/src/intent/intent.service.ts
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE';
|
||||||
|
|
||||||
|
export interface IntentResult {
|
||||||
|
type: IntentType;
|
||||||
|
platform?: string;
|
||||||
|
rawInput: string;
|
||||||
|
cleanInput: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_PLATFORMS = [
|
||||||
|
{ domain: /douyin\.com|v\.douyin\.com/i, name: 'douyin' },
|
||||||
|
{ domain: /kuaishou\.com/i, name: 'kuaishou' },
|
||||||
|
{ domain: /xiaohongshu\.com/i, name: 'xiaohongshu' },
|
||||||
|
{ domain: /bilibili\.com|b23\.tv/i, name: 'bilibili' },
|
||||||
|
{ domain: /weibo\.com/i, name: 'weibo' },
|
||||||
|
{ domain: /pipixia\.com/i, name: 'pipixia' },
|
||||||
|
{ domain: /y\.qq\.com/i, name: 'qqmusic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
import { CLOUD_DOMAIN_PATTERNS } from '../config/cloud-labels';
|
||||||
|
|
||||||
|
export function detectIntent(input: string): IntentResult {
|
||||||
|
const urlMatch = input.match(/(https?:\/\/[^\s]+)/i);
|
||||||
|
if (urlMatch) {
|
||||||
|
const url = urlMatch[1];
|
||||||
|
|
||||||
|
for (const p of VIDEO_PLATFORMS) {
|
||||||
|
if (p.domain.test(url)) {
|
||||||
|
return { type: 'VIDEO_PARSE', platform: p.name, rawInput: input, cleanInput: url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of CLOUD_DOMAIN_PATTERNS) {
|
||||||
|
if (p.regex.test(url)) {
|
||||||
|
return { type: 'CLOUD_SAVE', platform: p.type, rawInput: input, cleanInput: url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'SEARCH', rawInput: input, cleanInput: input.trim() };
|
||||||
|
}
|
||||||
203
packages/backend/src/main.ts
Executable file
203
packages/backend/src/main.ts
Executable file
@@ -0,0 +1,203 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import config from './config';
|
||||||
|
import { APP_VERSION } from "./version";
|
||||||
|
import { getDb } from './database/database';
|
||||||
|
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||||||
|
import rateLimiter from './middleware/rate-limit';
|
||||||
|
import routes from './routes';
|
||||||
|
import { pansouWebProxy } from './proxy/pansou-web';
|
||||||
|
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
|
||||||
|
import { refreshAllStorageInfo } from './cloud/cloud.service';
|
||||||
|
import { checkStartup } from './config/startup-validator';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// ============ Middleware ============
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
|
// CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志)
|
||||||
|
const corsOrigin = process.env.CORS_ORIGIN || '';
|
||||||
|
const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com';
|
||||||
|
if (config.nodeEnv === 'production' && isPlaceholder) {
|
||||||
|
console.warn('[WARN] CORS_ORIGIN 未配置或使用了占位符,生产环境建议设置真实域名,当前临时允许所有来源');
|
||||||
|
}
|
||||||
|
if (config.nodeEnv === 'production' && !isPlaceholder) {
|
||||||
|
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||||
|
} else {
|
||||||
|
app.use(cors({ origin: '*', credentials: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
|
||||||
|
// morgan 日志格式:不记录 IP,避免隐私合规问题
|
||||||
|
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(rateLimiter);
|
||||||
|
|
||||||
|
// ============ 前端静态文件 ============
|
||||||
|
const frontendDist = path.join(__dirname, 'frontend');
|
||||||
|
app.use(express.static(frontendDist, {
|
||||||
|
maxAge: '1d',
|
||||||
|
setHeaders: (res, p) => {
|
||||||
|
if (p.endsWith('index.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============ Routes ============
|
||||||
|
app.use('/api/uploads', express.static('/app/uploads'));
|
||||||
|
app.use('/api', routes);
|
||||||
|
|
||||||
|
// ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============
|
||||||
|
app.get('/health', async (_req, res) => {
|
||||||
|
const dbOk = (() => {
|
||||||
|
try { getDb(); return true; } catch { return false; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const redisStatus = await (async () => {
|
||||||
|
try {
|
||||||
|
const { getRedis } = require('./middleware/cache');
|
||||||
|
const redis = getRedis();
|
||||||
|
if (!redis) return 'disconnected';
|
||||||
|
// ioredis and mock redis both support ping()
|
||||||
|
if (typeof redis.ping !== 'function') return 'unknown';
|
||||||
|
const pong = await redis.ping().catch(() => null);
|
||||||
|
return pong === 'PONG' ? 'connected' : 'error';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch { return 'unknown'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const pansouStatus = await (async () => {
|
||||||
|
try {
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search';
|
||||||
|
const controller = new AbortController();
|
||||||
|
const t = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal });
|
||||||
|
clearTimeout(t);
|
||||||
|
return r.ok ? 'ok' : 'degraded';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch { return 'unreachable'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const videoParserStatus = await (async () => {
|
||||||
|
try {
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, '');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const t = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const r = await fetch(url, { signal: controller.signal });
|
||||||
|
clearTimeout(t);
|
||||||
|
return r.ok ? 'ok' : 'degraded';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch { return 'unreachable'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const overall = dbOk && pansouStatus !== 'unreachable'
|
||||||
|
? 'ok'
|
||||||
|
: dbOk
|
||||||
|
? 'degraded'
|
||||||
|
: 'unhealthy';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
version: APP_VERSION,
|
||||||
|
status: overall,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
memory: process.memoryUsage().rss,
|
||||||
|
components: {
|
||||||
|
db: dbOk ? 'connected' : 'error',
|
||||||
|
redis: redisStatus,
|
||||||
|
pansou: pansouStatus,
|
||||||
|
videoParser: videoParserStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ PanSou Web UI Proxy ============
|
||||||
|
app.use('/pansou', pansouWebProxy);
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api/') || req.path === '/health') return next();
|
||||||
|
res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error('[Error]', err);
|
||||||
|
res.status(err.status || 500).json({
|
||||||
|
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
|
||||||
|
code: err.status || 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Server Start ============
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
// ── 启动前配置校验 ──
|
||||||
|
if (!checkStartup()) {
|
||||||
|
console.error('[Server] 配置校验失败,退出');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
getDb();
|
||||||
|
console.log('[DB] SQLite database initialized');
|
||||||
|
try {
|
||||||
|
const { getSystemConfig } = require('./admin/system-config.service');
|
||||||
|
const tz = getSystemConfig('timezone');
|
||||||
|
if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); }
|
||||||
|
} catch { console.warn('[Config] Could not set timezone, using default'); }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DB] Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getSystemConfig } = require('./admin/system-config.service');
|
||||||
|
const redisUrl = (process.env.REDIS_URL || getSystemConfig('redis_url') || '').trim();
|
||||||
|
if (redisUrl) {
|
||||||
|
const ok = await reconnectRedis(redisUrl);
|
||||||
|
if (ok) console.log('[Redis] Connected to', redisUrl);
|
||||||
|
else console.warn('[Redis] Connection failed, continuing without cache');
|
||||||
|
} else {
|
||||||
|
console.log('[Redis] No REDIS_URL configured, running without cache');
|
||||||
|
}
|
||||||
|
} catch { console.warn('[Redis] Redis not available, continuing without cache'); }
|
||||||
|
|
||||||
|
// Cleanup scheduler
|
||||||
|
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||||||
|
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);
|
||||||
|
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
console.log(`\n[Server] Received ${signal}, shutting down gracefully...`);
|
||||||
|
server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); });
|
||||||
|
setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); });
|
||||||
|
process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); });
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); });
|
||||||
|
|
||||||
|
export default app;
|
||||||
172
packages/backend/src/middleware/cache.ts
Executable file
172
packages/backend/src/middleware/cache.ts
Executable file
@@ -0,0 +1,172 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
let client: Redis | null = null;
|
||||||
|
let currentUrl: string = '';
|
||||||
|
|
||||||
|
export function getRedis(): Redis | null {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRedisClient(): Redis | null {
|
||||||
|
if (client) return client;
|
||||||
|
return createClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(url?: string): Redis | null {
|
||||||
|
const redisUrl = url || process.env.REDIS_URL || currentUrl || getSystemConfigRedisUrl();
|
||||||
|
if (!redisUrl) return null;
|
||||||
|
currentUrl = redisUrl;
|
||||||
|
|
||||||
|
if (client) {
|
||||||
|
try { client.disconnect(); } catch {}
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
client = new Redis(redisUrl, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
retryStrategy(times: number) {
|
||||||
|
if (times > 3) return null;
|
||||||
|
return Math.min(times * 200, 2000);
|
||||||
|
},
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err: Error) => {
|
||||||
|
console.error('[Redis] Error:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('[Redis] Connected to', currentUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemConfigRedisUrl(): string {
|
||||||
|
try {
|
||||||
|
const { getSystemConfig } = require('./admin/system-config.service');
|
||||||
|
return getSystemConfig('redis_url') || process.env.REDIS_URL || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectRedis(): Promise<void> {
|
||||||
|
const redis = createClient();
|
||||||
|
if (!redis) return;
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Redis] Connection failed, running without cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconnectRedis(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (client) {
|
||||||
|
await client.quit().catch(() => {});
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
const redis = createClient(url);
|
||||||
|
if (!redis) return false;
|
||||||
|
await redis.connect();
|
||||||
|
console.log('[Redis] Reconnected with new URL:', url);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Redis] Reconnect failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectRedis(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await client.quit();
|
||||||
|
client = null;
|
||||||
|
currentUrl = '';
|
||||||
|
console.log('[Redis] Disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test a Redis URL without affecting the running client.
|
||||||
|
* @returns { ok: boolean, latency: number, info?: string }
|
||||||
|
*/
|
||||||
|
export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info?: string }> {
|
||||||
|
const start = Date.now();
|
||||||
|
const testClient = new Redis(url, {
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy() { return null; },
|
||||||
|
lazyConnect: true,
|
||||||
|
connectTimeout: 5000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await testClient.connect();
|
||||||
|
const pong = await testClient.ping();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
await testClient.quit();
|
||||||
|
return { ok: pong === 'PONG', latency, info: `响应时间 ${latency}ms` };
|
||||||
|
} catch (err: any) {
|
||||||
|
try { await testClient.disconnect(); } catch {}
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
return { ok: false, latency, info: err.message || '连接失败' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisClient {
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.redis = getRedisClient() || (null as unknown as Redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isConnected(): boolean {
|
||||||
|
return this.redis !== null && typeof this.redis.get === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
if (!this.isConnected()) return null;
|
||||||
|
try {
|
||||||
|
return await this.redis.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string): Promise<void> {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
await this.redis.set(key, value);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setEx(key: string, ttl: number, value: string): Promise<void> {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
await this.redis.setex(key, ttl, value);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
if (!this.isConnected()) return;
|
||||||
|
try {
|
||||||
|
await this.redis.del(key);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.redis.exists(key);
|
||||||
|
return result === 1;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RedisClient;
|
||||||
61
packages/backend/src/middleware/rate-limit.ts
Executable file
61
packages/backend/src/middleware/rate-limit.ts
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
/** 公开搜索接口:较宽松 */
|
||||||
|
export const searchLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 150,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||||
|
message: { error: '搜索请求过于频繁,请稍后再试', code: 429 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 管理接口(admin/*):较严格 */
|
||||||
|
export const adminLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||||
|
message: { error: '操作过于频繁,请稍后再试', code: 429 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 登录接口:极严格,防暴力破解 */
|
||||||
|
export const loginLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||||
|
message: { error: '登录尝试次数过多,请一分钟后重试', code: 429 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 转存/保存接口:中等等级 */
|
||||||
|
export const saveLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown',
|
||||||
|
message: { error: '转存操作过于频繁,请稍后再试', code: 429 },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 获取真实客户端 IP(优先代理头) */
|
||||||
|
function getClientIP(req: any): string {
|
||||||
|
return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
||||||
|
?? req.headers['x-real-ip']
|
||||||
|
?? req.socket.remoteAddress
|
||||||
|
?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认全局限流(兜底,未匹配上述规则的路由) */
|
||||||
|
const defaultLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 500,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: getClientIP,
|
||||||
|
message: { error: 'Too many requests, please try again later.', code: 429 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defaultLimiter;
|
||||||
137
packages/backend/src/proxy/pansou-web.ts
Executable file
137
packages/backend/src/proxy/pansou-web.ts
Executable file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
const PANSOU_UPSTREAM = 'http://pansou:80';
|
||||||
|
|
||||||
|
// Content types that need sub_filter path rewriting
|
||||||
|
const TEXT_TYPES = ['text/html', 'application/javascript', 'text/javascript'];
|
||||||
|
|
||||||
|
// Hop-by-hop headers that should not be forwarded
|
||||||
|
const HOP_HEADERS = new Set([
|
||||||
|
'host', 'connection', 'content-length', 'transfer-encoding',
|
||||||
|
'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
||||||
|
'te', 'trailer', 'upgrade',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply sub_filter string replacements to HTML/JS content.
|
||||||
|
* This matches what the nginx pansou.conf sub_filter does.
|
||||||
|
*/
|
||||||
|
function applySubFilter(text: string): string {
|
||||||
|
return text
|
||||||
|
// Replace HTML/JS path references: /api/ -> /pansou/api/
|
||||||
|
.replace(/\/api\//g, '/pansou/api/')
|
||||||
|
// baseURL rewrite (Vue SPA config)
|
||||||
|
.replace(/baseURL:"\/api"/g, 'baseURL:"/pansou/api"')
|
||||||
|
.replace(/baseURL:'\/api'/g, "baseURL:'/pansou/api'")
|
||||||
|
// Static asset path rewrites
|
||||||
|
.replace(/src="\/assets\//g, 'src="/pansou/assets/')
|
||||||
|
.replace(/src='\/assets\//g, "src='/pansou/assets/")
|
||||||
|
.replace(/href="\/assets\//g, 'href="/pansou/assets/')
|
||||||
|
.replace(/href='\/assets\//g, "href='/pansou/assets/")
|
||||||
|
// Favicon path rewrite
|
||||||
|
.replace(/href="\/favicon\.ico/g, 'href="/pansou/favicon.ico')
|
||||||
|
.replace(/href='\/favicon\.ico/g, "href='/pansou/favicon.ico");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express middleware that proxies /pansou/* requests to the PanSou web container.
|
||||||
|
*
|
||||||
|
* How it works:
|
||||||
|
* 1. Strips the /pansou prefix from the request path
|
||||||
|
* 2. Forwards the request to http://pansou:80/{path}
|
||||||
|
* 3. For HTML/JS responses, applies sub_filter path rewriting
|
||||||
|
* so that /api/ becomes /pansou/api/ and /assets/ becomes /pansou/assets/
|
||||||
|
* 4. For static assets (CSS, images, fonts), pipes through as-is
|
||||||
|
*
|
||||||
|
* Controlled by system config key 'pansou_web_enabled' (true/false).
|
||||||
|
*/
|
||||||
|
export async function pansouWebProxy(req: Request, res: Response, _next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Check if PanSou web is enabled
|
||||||
|
const enabled = getSystemConfig('pansou_web_enabled');
|
||||||
|
if (enabled !== 'true') {
|
||||||
|
res.status(404).send('PanSou Web UI is disabled by administrator');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build upstream URL: strip /pansou prefix
|
||||||
|
let targetPath = req.path;
|
||||||
|
targetPath = targetPath.replace(/^\/pansou/, '') || '/';
|
||||||
|
|
||||||
|
// Preserve query string
|
||||||
|
const queryIndex = req.url.indexOf('?');
|
||||||
|
const query = queryIndex >= 0 ? req.url.substring(queryIndex) : '';
|
||||||
|
const upstreamUrl = `${PANSOU_UPSTREAM}${targetPath}${query}`;
|
||||||
|
|
||||||
|
// Build forwarded headers (filter out hop-by-hop headers)
|
||||||
|
const forwardHeaders: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (!HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) {
|
||||||
|
forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Override Host header to target the upstream
|
||||||
|
forwardHeaders['Host'] = 'pansou';
|
||||||
|
// Remove Accept-Encoding so we get uncompressed content for text rewriting
|
||||||
|
forwardHeaders['accept-encoding'] = '';
|
||||||
|
|
||||||
|
// Forward the request
|
||||||
|
const response = await fetch(upstreamUrl, {
|
||||||
|
method: req.method as any,
|
||||||
|
headers: forwardHeaders,
|
||||||
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body),
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
// Set response status
|
||||||
|
res.status(response.status);
|
||||||
|
|
||||||
|
// Handle redirects - rewrite Location header to include /pansou prefix
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (location) {
|
||||||
|
if (location.startsWith('/')) {
|
||||||
|
res.setHeader('location', '/pansou' + location);
|
||||||
|
} else {
|
||||||
|
res.setHeader('location', location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For HTML/JS content, apply sub_filter string replacements
|
||||||
|
if (TEXT_TYPES.some(t => contentType.includes(t))) {
|
||||||
|
const text = await response.text();
|
||||||
|
const modified = applySubFilter(text);
|
||||||
|
res.setHeader('content-type', contentType);
|
||||||
|
// Remove content-encoding since we decompressed
|
||||||
|
res.setHeader('content-length', Buffer.byteLength(modified, 'utf-8').toString());
|
||||||
|
res.send(modified);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other content (CSS, images, fonts, etc.), pipe through as-is
|
||||||
|
const excludedHeaders = new Set([
|
||||||
|
'content-encoding', 'content-length', 'transfer-encoding',
|
||||||
|
'keep-alive', 'connection',
|
||||||
|
]);
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
if (!excludedHeaders.has(key.toLowerCase())) {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use buffer for reliability
|
||||||
|
const buffer = await response.arrayBuffer().then(buf => Buffer.from(buf));
|
||||||
|
res.end(buffer);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[PanSou Web Proxy] Error proxying ${req.path}:`, err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(502).send(`PanSou Web proxy error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
615
packages/backend/src/routes/admin.routes.ts
Normal file
615
packages/backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
import fs from "fs";
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||||
|
import { getSaveRecords } from '../cloud/cloud.service';
|
||||||
|
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
|
||||||
|
// Note: check-in routes were removed (sign-in feature removed)
|
||||||
|
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||||
|
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||||
|
import { getStats } from '../admin/stats.service';
|
||||||
|
import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { testProxyConnection } from '../utils/proxy-agent';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { reconnectRedis, testRedisConnection } from '../middleware/cache';
|
||||||
|
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||||
|
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Public routes (no auth required)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/login
|
||||||
|
* Admin login
|
||||||
|
*/
|
||||||
|
router.post('/admin/login', loginLimiter, (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = login(username, password);
|
||||||
|
if (!token) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Login] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/cloud-types
|
||||||
|
* List all cloud types (public, read-only).
|
||||||
|
*/
|
||||||
|
router.get('/admin/cloud-types', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const types = getAllCloudTypes();
|
||||||
|
res.json({ types });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// QR Login routes (no auth — user not logged in yet)
|
||||||
|
// MUST be before authMiddleware!
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
// ===== 夸克扫码登录 =====
|
||||||
|
router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result = await getQrLoginStatus(sessionId);
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
await cancelQrLogin(sessionId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 百度扫码登录 =====
|
||||||
|
router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await BaiduDriver.startQrLogin();
|
||||||
|
res.json({ ok: true, ...result });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.params.sessionId as string;
|
||||||
|
const result: any = await BaiduDriver.getQrLoginStatus(sessionId);
|
||||||
|
// Map to frontend-expected format (frontend reads data.cookie)
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
status: result.status,
|
||||||
|
cookie: result.cookie || result.access_token || "",
|
||||||
|
nickname: result.nickname || "",
|
||||||
|
storage_used: result.storage_used || "",
|
||||||
|
storage_total: result.storage_total || "",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
BaiduDriver.cancelQrLogin(req.params.sessionId as string);
|
||||||
|
} catch {}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Auth wall — all routes below require JWT
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
router.use('/admin', authMiddleware);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Configs CRUD
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/cloud-configs — list all cloud configs */
|
||||||
|
router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getCloudConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
|
||||||
|
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
if (!data.cloud_type) {
|
||||||
|
res.status(400).json({ error: 'cloud_type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
||||||
|
// Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1
|
||||||
|
if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0;
|
||||||
|
const saved = saveCloudConfig(data);
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */
|
||||||
|
router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const existing = getCloudConfigById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = saveCloudConfig({ ...req.body, id });
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/admin/cloud-configs/:id */
|
||||||
|
router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const ok = deleteCloudConfig(id);
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: 'Cloud config not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to delete cloud config' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
|
||||||
|
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const type = req.params.type as string;
|
||||||
|
const { cookie, id } = req.body;
|
||||||
|
|
||||||
|
// If cookie is provided directly, test with it (for new configs not yet saved)
|
||||||
|
if (cookie) {
|
||||||
|
const result = await testCloudConnectionWithCookie(type, cookie);
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise test by config id
|
||||||
|
if (id) {
|
||||||
|
const result = await testCloudConnection(parseInt(id));
|
||||||
|
res.json(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ success: false, message: 'Provide either cookie or id' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, message: err.message || 'Connection test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Stats
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/stats */
|
||||||
|
router.get('/admin/stats', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const days = req.query.days ? parseInt(req.query.days as string) : 7;
|
||||||
|
const stats = getStats(days);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Save Records (转存日志)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/save-records */
|
||||||
|
router.get('/admin/save-records', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
|
const startDate = req.query.startDate as string | undefined;
|
||||||
|
const endDate = req.query.endDate as string | undefined;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const sourceType = req.query.sourceType as string | undefined;
|
||||||
|
const keyword = req.query.keyword as string | undefined;
|
||||||
|
const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get save records' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// System Configs
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/system-configs */
|
||||||
|
router.get('/admin/system-configs', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const configs = getAllSystemConfigs();
|
||||||
|
res.json(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/system-configs — batch update */
|
||||||
|
router.put('/admin/system-configs', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { entries } = req.body;
|
||||||
|
if (!entries || !Array.isArray(entries)) {
|
||||||
|
res.status(400).json({ error: 'entries array is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateSystemConfigs(entries);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to update system configs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Cloud Types Toggle
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */
|
||||||
|
router.put('/admin/cloud-types', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, enabled } = req.body;
|
||||||
|
if (!type) {
|
||||||
|
res.status(400).json({ error: 'type is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
||||||
|
).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to toggle cloud type' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Change Password
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/change-password */
|
||||||
|
router.post('/admin/change-password', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { oldPassword, newPassword } = req.body;
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
res.status(400).json({ error: 'Both old and new passwords are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Get username from JWT
|
||||||
|
const authHeader = req.headers.authorization || '';
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = changePassword(payload.username, oldPassword, newPassword);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to change password' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// DB Status
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/db-status */
|
||||||
|
router.get('/admin/db-status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dbFile = getSystemConfig('db_path') || '';
|
||||||
|
let dbSize = 'N/A';
|
||||||
|
if (dbFile) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(dbFile);
|
||||||
|
dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const counts = {
|
||||||
|
save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0,
|
||||||
|
search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0,
|
||||||
|
system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0,
|
||||||
|
cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0,
|
||||||
|
content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis status
|
||||||
|
let redis_status = 'disconnected';
|
||||||
|
let redis_url = getSystemConfig('redis_url') || '';
|
||||||
|
try {
|
||||||
|
const testResult = await testRedisConnection(redis_url);
|
||||||
|
redis_status = testResult.ok ? 'connected' : 'disconnected';
|
||||||
|
} catch {
|
||||||
|
redis_status = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
db_size: dbSize,
|
||||||
|
db_path: dbFile,
|
||||||
|
...counts,
|
||||||
|
redis_status,
|
||||||
|
redis_url,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get DB status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test Redis Connection
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-redis */
|
||||||
|
router.post('/admin/test-redis', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ ok: false, info: 'Redis URL is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testRedisConnection(url);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'Redis test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Test External Service
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** POST /api/admin/test-external-service */
|
||||||
|
router.post('/admin/test-external-service', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, url, token } = req.body;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'pansou': {
|
||||||
|
const pansouUrl = url || getSystemConfig('pansou_url') || '';
|
||||||
|
if (!pansouUrl) {
|
||||||
|
res.json({ ok: false, info: 'PanSou URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok && data?.status === 'ok',
|
||||||
|
latency,
|
||||||
|
info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'video_parser': {
|
||||||
|
const parserUrl = url || getSystemConfig('video_parser_url') || '';
|
||||||
|
if (!parserUrl) {
|
||||||
|
res.json({ ok: false, info: 'Video Parser URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'tmdb': {
|
||||||
|
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
|
||||||
|
if (!tmdbToken) {
|
||||||
|
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch('https://api.themoviedb.org/3/configuration', {
|
||||||
|
headers: { Authorization: `Bearer ${tmdbToken}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
res.json({
|
||||||
|
ok: response.ok,
|
||||||
|
latency,
|
||||||
|
info: response.ok ? '连接成功' : `HTTP ${response.status}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'proxy': {
|
||||||
|
const proxyUrl = url || getSystemConfig('search_proxy_url') || '';
|
||||||
|
if (!proxyUrl) {
|
||||||
|
res.json({ ok: false, info: 'Proxy URL not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await testProxyConnection(proxyUrl);
|
||||||
|
res.json(result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ip_geo': {
|
||||||
|
const geoUrl = url || getSystemConfig('ip_geo_api_url') || '';
|
||||||
|
if (!geoUrl) {
|
||||||
|
res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const testUrl = geoUrl.replace('{ip}', '8.8.8.8');
|
||||||
|
const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const data: any = await response.json();
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
const valid = !!(data?.country || data?.region || data?.city || data?.countryCode);
|
||||||
|
res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
res.json({ ok: false, info: `Unknown service type: ${type}` });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ ok: false, info: err.message || 'External service test failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Pansou Info & Update
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
|
||||||
|
/** GET /api/admin/pansou-info — pansou health + version + update check */
|
||||||
|
router.get('/admin/pansou-info', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getSystemConfig('pansou_url') || '';
|
||||||
|
if (!baseUrl) {
|
||||||
|
res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch PanSou health
|
||||||
|
const healthUrl = baseUrl + '/api/health';
|
||||||
|
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
const healthData: any = await response.json();
|
||||||
|
const channelCount = healthData.channels_count || 0;
|
||||||
|
const pluginCount = healthData.plugin_count || 0;
|
||||||
|
|
||||||
|
// Derive disk count from channel names
|
||||||
|
const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease'];
|
||||||
|
const drives = new Set<string>();
|
||||||
|
for (const ch of (healthData.channels || [])) {
|
||||||
|
for (const kw of driveKeywords) {
|
||||||
|
if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diskCount = drives.size || 5;
|
||||||
|
|
||||||
|
// Get local version from docker label
|
||||||
|
let version = '';
|
||||||
|
let hasUpdate = false;
|
||||||
|
let latestVersion = '';
|
||||||
|
try {
|
||||||
|
const created = execSync(
|
||||||
|
`docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`,
|
||||||
|
{ timeout: 5000, encoding: 'utf8' }
|
||||||
|
).trim();
|
||||||
|
version = created ? created.slice(0, 10) : '';
|
||||||
|
|
||||||
|
// Check update cache
|
||||||
|
const cacheFile = '/tmp/pansou-update-cache.json';
|
||||||
|
let cache: any = null;
|
||||||
|
try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {}
|
||||||
|
const threeDays = 3 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
if (!cache || (Date.now() - cache.checkedAt) > threeDays) {
|
||||||
|
// Check GHCR for latest version
|
||||||
|
try {
|
||||||
|
const tokenRes = await fetch(
|
||||||
|
'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io'
|
||||||
|
);
|
||||||
|
const ghcrToken = (await tokenRes.json() as any).token;
|
||||||
|
const manifestRes = await fetch(
|
||||||
|
'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest',
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } }
|
||||||
|
);
|
||||||
|
const manifestList: any = await manifestRes.json();
|
||||||
|
const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux');
|
||||||
|
if (amd64) {
|
||||||
|
const blobRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } }
|
||||||
|
);
|
||||||
|
const blobData: any = await blobRes.json();
|
||||||
|
const cfgDigest = blobData.config?.digest;
|
||||||
|
if (cfgDigest) {
|
||||||
|
const cfgRes = await fetch(
|
||||||
|
`https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${ghcrToken}` } }
|
||||||
|
);
|
||||||
|
const cfgData: any = await cfgRes.json();
|
||||||
|
const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created'];
|
||||||
|
if (remoteCreated) {
|
||||||
|
latestVersion = remoteCreated.slice(0, 10);
|
||||||
|
if (version && latestVersion !== version) hasUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion }));
|
||||||
|
} else {
|
||||||
|
hasUpdate = cache.hasUpdate;
|
||||||
|
latestVersion = cache.latestVersion;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: response.ok ? 'connected' : 'disconnected',
|
||||||
|
channelCount,
|
||||||
|
pluginCount,
|
||||||
|
diskCount,
|
||||||
|
version,
|
||||||
|
hasUpdate,
|
||||||
|
latestVersion,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */
|
||||||
|
router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 });
|
||||||
|
execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 });
|
||||||
|
try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {}
|
||||||
|
res.json({ success: true, message: 'PanSou 更新成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
87
packages/backend/src/routes/cleanup.routes.ts
Normal file
87
packages/backend/src/routes/cleanup.routes.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { runFullCleanup, emptyAllTrash } from '../cloud/cleanup.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============ Cleanup & Storage Management ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cleanup/run
|
||||||
|
* Manually trigger a cleanup cycle:
|
||||||
|
* - Trash old date folders from cloud drives
|
||||||
|
* - Delete old save_records
|
||||||
|
* - Empty recycle bin
|
||||||
|
*/
|
||||||
|
router.post('/admin/cleanup/run', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const stats = await runFullCleanup();
|
||||||
|
res.json({
|
||||||
|
success: stats.errors.length === 0,
|
||||||
|
files_trashed: stats.filesTrashed,
|
||||||
|
logs_deleted: stats.logsDeleted,
|
||||||
|
trash_emptied: stats.trashEmptied,
|
||||||
|
errors: stats.errors,
|
||||||
|
message: stats.errors.length === 0
|
||||||
|
? `✅ 清理完成:移入回收站 ${stats.filesTrashed} 个文件夹,删除 ${stats.logsDeleted} 条日志,清空回收站${stats.trashEmptied ? '✓' : '-'}`
|
||||||
|
: `清理完成,但有 ${stats.errors.length} 个错误`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cleanup/empty-trash
|
||||||
|
* Empty recycle bin for all cloud drives (permanently delete, frees space).
|
||||||
|
*/
|
||||||
|
router.post('/admin/cleanup/empty-trash', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await emptyAllTrash();
|
||||||
|
res.json({
|
||||||
|
success: result.errors.length === 0,
|
||||||
|
emptied: result.emptied,
|
||||||
|
errors: result.errors,
|
||||||
|
message: result.emptied
|
||||||
|
? '✅ 回收站已清空,存储空间已释放'
|
||||||
|
: (result.errors.length > 0 ? `清空回收站部分失败:${result.errors.join('; ')}` : '没有可清空的网盘'),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract genre tags from search result titles.
|
||||||
|
*/
|
||||||
|
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (keyword) tags.push(keyword);
|
||||||
|
|
||||||
|
const genreKeywords: Record<string, string> = {
|
||||||
|
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||||
|
'剧场版': '剧场版', '年番': '年番',
|
||||||
|
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||||
|
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||||
|
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||||
|
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||||
|
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||||
|
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||||
|
};
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of results) {
|
||||||
|
const title = (r.title || r.note || '') as string;
|
||||||
|
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||||
|
if (title.includes(key) && !seen.has(val)) {
|
||||||
|
seen.add(val);
|
||||||
|
tags.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
14
packages/backend/src/routes/index.ts
Executable file
14
packages/backend/src/routes/index.ts
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import searchRoutes from './search.routes';
|
||||||
|
import adminRoutes from './admin.routes';
|
||||||
|
import uploadRoutes from './upload.routes';
|
||||||
|
import cleanupRoutes from './cleanup.routes';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(searchRoutes);
|
||||||
|
router.use(adminRoutes);
|
||||||
|
router.use(uploadRoutes);
|
||||||
|
router.use(cleanupRoutes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
630
packages/backend/src/routes/search.routes.ts
Normal file
630
packages/backend/src/routes/search.routes.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
// Native fetch available in Node 20+
|
||||||
|
import { searchLimiter, saveLimiter } from '../middleware/rate-limit';
|
||||||
|
import { detectIntent } from '../intent/intent.service';
|
||||||
|
import { search, applyTitleFilter } from '../search/search.service';
|
||||||
|
import { getRankings, getHotKeywords, getCategorizedRankings } from '../search/rankings.service';
|
||||||
|
import { parseVideo } from '../video/video.service';
|
||||||
|
import { saveFromShare } from '../cloud/cloud.service';
|
||||||
|
import { getEnabledCloudTypeSet } from '../cloud/cloud-types.service';
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { verifyToken } from '../admin/auth.service';
|
||||||
|
import { LinkValidator } from '../validation/link-validator.service';
|
||||||
|
import { getContentInfo } from '../content/content.service';
|
||||||
|
import { detectCloudType } from '../config/cloud-labels';
|
||||||
|
import { CLOUD_LABELS, CLOUD_COLORS } from '../config/cloud-labels';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============ Search & Query ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/query
|
||||||
|
* Intent recognition + execution
|
||||||
|
*/
|
||||||
|
router.post('/query', searchLimiter, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { input, q } = req.body;
|
||||||
|
const query = input || q;
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
res.status(400).json({ error: 'Input is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intent = detectIntent(query);
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || '';
|
||||||
|
|
||||||
|
switch (intent.type) {
|
||||||
|
case 'SEARCH': {
|
||||||
|
const result = await search(intent.cleanInput, 1, ip);
|
||||||
|
|
||||||
|
// Pass through: use all results, group by cloud type
|
||||||
|
const allResults = result.results || [];
|
||||||
|
|
||||||
|
// Transform to frontend-friendly format
|
||||||
|
let formatted = (allResults || []).map((item: any, idx: number) => ({
|
||||||
|
id: `search_${idx}`,
|
||||||
|
title: filterTitle(item.title || item.content || ''),
|
||||||
|
description: item.content || '',
|
||||||
|
share_url: item.url || '',
|
||||||
|
cloud_type: detectCloudType(item.url || ''),
|
||||||
|
file_size: '',
|
||||||
|
update_time: item.datetime || '',
|
||||||
|
source: item.source || '',
|
||||||
|
file_id: '',
|
||||||
|
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||||
|
password: item.password || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter out expired/invalid links
|
||||||
|
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||||
|
|
||||||
|
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||||
|
// Skip filter if search_all_channels is enabled
|
||||||
|
const searchAllChannels = getSystemConfig('search_all_channels') === 'true';
|
||||||
|
if (!searchAllChannels) {
|
||||||
|
const enabledSet = getEnabledCloudTypeSet();
|
||||||
|
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentQuery = intent.cleanInput || query;
|
||||||
|
const contentInfo = await getContentInfo(contentQuery).catch(() => null);
|
||||||
|
const extractedTags = extractTagsFromResults(formatted, contentQuery);
|
||||||
|
const linkValidationEnabled = getSystemConfig('link_validation_enabled') !== 'false';
|
||||||
|
|
||||||
|
// Set up streaming response (NDJSON)
|
||||||
|
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
// 0. Send searching signal immediately so frontend shows feedback
|
||||||
|
res.write(JSON.stringify({ type: 'searching' }) + '\n');
|
||||||
|
|
||||||
|
// 0.5 Query local DB for previously saved resources matching keyword
|
||||||
|
const savedResults = getSavedResources(intent.cleanInput);
|
||||||
|
if (savedResults.length > 0) {
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'saved',
|
||||||
|
results: savedResults,
|
||||||
|
total: savedResults.length,
|
||||||
|
}) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Send stats immediately
|
||||||
|
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||||
|
const siteLogo = getSystemConfig('site_logo') || '';
|
||||||
|
const siteNameInStats = getSystemConfig('site_name') || 'CloudSearch';
|
||||||
|
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||||
|
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||||
|
const statsPayload = {
|
||||||
|
type: 'stats',
|
||||||
|
total: formatted.length,
|
||||||
|
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||||
|
content_info: contentInfo,
|
||||||
|
content_tags: extractedTags,
|
||||||
|
link_validation: linkValidationEnabled,
|
||||||
|
fallback_image: fallbackImage,
|
||||||
|
site_logo: siteLogo,
|
||||||
|
site_name: siteNameInStats,
|
||||||
|
site_disclaimer: siteDisclaimer,
|
||||||
|
site_marquee: siteMarquee,
|
||||||
|
};
|
||||||
|
res.write(JSON.stringify(statsPayload) + '\n');
|
||||||
|
|
||||||
|
// 2. Validate links — per-type grouping, newest-first, per-type cap from config
|
||||||
|
if (linkValidationEnabled) {
|
||||||
|
const validator = new LinkValidator();
|
||||||
|
const resultLimit = parseInt(getSystemConfig('search_result_limit') || '10', 10);
|
||||||
|
const MAX_VALID_PER_TYPE = Math.min(100, Math.max(1, resultLimit)); // configurable, 1-100
|
||||||
|
const MAX_TOTAL_VALID = MAX_VALID_PER_TYPE * 6; // up to 6 cloud types
|
||||||
|
const pool = validator['pool']; // concurrency: 10
|
||||||
|
|
||||||
|
// Group formatted results by cloud_type, then sort each group by time desc
|
||||||
|
const byType: Record<string, any[]> = {};
|
||||||
|
for (const item of formatted) {
|
||||||
|
const ct = item.cloud_type || 'others';
|
||||||
|
if (!byType[ct]) byType[ct] = [];
|
||||||
|
byType[ct].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each group by update_time descending (newest first)
|
||||||
|
for (const ct of Object.keys(byType)) {
|
||||||
|
byType[ct].sort((a: any, b: any) => {
|
||||||
|
const ta = a.update_time || '';
|
||||||
|
const tb = b.update_time || '';
|
||||||
|
if (!ta && !tb) return 0;
|
||||||
|
if (!ta) return 1;
|
||||||
|
if (!tb) return -1;
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a round-robin validation queue: interleave items from each type
|
||||||
|
// to give fair priority across all cloud types
|
||||||
|
const typeOrder = ['quark', 'baidu', 'aliyun', '115', 'tianyi', '123pan', 'uc', 'xunlei', 'pikpak', 'magnet', 'ed2k', 'others'];
|
||||||
|
const sortedTypes = typeOrder.filter(ct => byType[ct] && byType[ct].length > 0);
|
||||||
|
// Sort by total count descending so types with more results get more validation slots
|
||||||
|
sortedTypes.sort((a, b) => (byType[b]?.length || 0) - (byType[a]?.length || 0));
|
||||||
|
|
||||||
|
const validationQueue: { item: any; type: string }[] = [];
|
||||||
|
const maxLen = Math.max(...sortedTypes.map(ct => byType[ct].length), 0);
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
for (const ct of sortedTypes) {
|
||||||
|
if (i < byType[ct].length) {
|
||||||
|
validationQueue.push({ item: byType[ct][i], type: ct });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validResults: any[] = [];
|
||||||
|
const perTypeValid: Record<string, number> = {};
|
||||||
|
let totalValid = 0;
|
||||||
|
let totalInvalid = 0;
|
||||||
|
let totalChecked = 0;
|
||||||
|
const unknownItemIds: number[] = []; // IDs that got 'unknown' from PanSou
|
||||||
|
|
||||||
|
// Pass 1: PanSou-only validation
|
||||||
|
const tasks = validationQueue.map(({ item, type }) => pool.run(async () => {
|
||||||
|
// Stop if we've hit overall cap or per-type cap
|
||||||
|
if (totalValid >= MAX_TOTAL_VALID) return;
|
||||||
|
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) return;
|
||||||
|
|
||||||
|
totalChecked++;
|
||||||
|
try {
|
||||||
|
const vr = await validator.validate(item.share_url, item.cloud_type);
|
||||||
|
// 'unknown' = PanSou couldn't determine → treat as valid for now
|
||||||
|
if (vr.status === 'valid' || vr.status === 'unknown') {
|
||||||
|
if (vr.status === 'unknown') {
|
||||||
|
unknownItemIds.push(item.id);
|
||||||
|
}
|
||||||
|
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||||
|
validResults.push(item);
|
||||||
|
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||||
|
totalValid++;
|
||||||
|
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message }) + '\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalInvalid++;
|
||||||
|
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message }) + '\n');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||||
|
validResults.push(item);
|
||||||
|
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||||
|
totalValid++;
|
||||||
|
}
|
||||||
|
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true }) + '\n');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
// Pass 2: If PanSou didn't provide enough valid results, validate
|
||||||
|
// uncertain items with local fallback (external API calls)
|
||||||
|
if (totalValid < MAX_TOTAL_VALID && unknownItemIds.length > 0) {
|
||||||
|
const unknownItems = validationQueue.filter(({ item }) => unknownItemIds.includes(item.id));
|
||||||
|
for (const { item, type } of unknownItems) {
|
||||||
|
if (totalValid >= MAX_TOTAL_VALID) break;
|
||||||
|
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) break;
|
||||||
|
try {
|
||||||
|
const vr = await validator.validateWithLocalFallback(item.share_url, item.cloud_type);
|
||||||
|
if (vr.status === 'valid') {
|
||||||
|
// Already in validResults from pass 1, just count it again
|
||||||
|
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||||
|
totalValid++;
|
||||||
|
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message + ' (本地确认)' }) + '\n');
|
||||||
|
} else if (vr.status === 'invalid') {
|
||||||
|
// Remove from validResults — was previously included as unknown
|
||||||
|
const idx = validResults.findIndex(r => r.id === item.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
validResults.splice(idx, 1);
|
||||||
|
perTypeValid[type] = Math.max(0, (perTypeValid[type] || 1) - 1);
|
||||||
|
totalValid--;
|
||||||
|
}
|
||||||
|
totalInvalid++;
|
||||||
|
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message + ' (本地确认失效)' }) + '\n');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep as-is (already treated as valid from pass 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedCount = validationQueue.length - totalChecked;
|
||||||
|
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'complete',
|
||||||
|
results: validResults,
|
||||||
|
channels: groupResultsByChannel(validResults, (item: any) => item.cloud_type),
|
||||||
|
total: validResults.length,
|
||||||
|
filtered: totalInvalid,
|
||||||
|
per_type: perTypeValid,
|
||||||
|
skipped: skippedCount,
|
||||||
|
}) + '\n');
|
||||||
|
} else {
|
||||||
|
// No validation - just send all results
|
||||||
|
res.write(JSON.stringify({
|
||||||
|
type: 'complete',
|
||||||
|
results: formatted,
|
||||||
|
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||||
|
total: formatted.length,
|
||||||
|
filtered: 0,
|
||||||
|
}) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'VIDEO_PARSE': {
|
||||||
|
const videoInfo = await parseVideo(intent.cleanInput);
|
||||||
|
res.json({ intent: intent.type, platform: intent.platform, data: videoInfo });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'CLOUD_SAVE': {
|
||||||
|
const result = await saveFromShare(intent.cleanInput, intent.platform || '', undefined, req.ip);
|
||||||
|
res.json({ intent: intent.type, platform: intent.platform, ...result });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
res.status(400).json({ error: 'Unknown intent type' });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Query] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/search
|
||||||
|
* Search with optional link validation filtering
|
||||||
|
*/
|
||||||
|
router.get('/search', searchLimiter, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const keyword = (req.query.q || req.query.kw) as string;
|
||||||
|
const page = parseInt(req.query.page as string || '1', 10);
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || '';
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
res.status(400).json({ error: 'Query parameter "q" is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await search(keyword, page, ip);
|
||||||
|
|
||||||
|
// Pass through: use all results
|
||||||
|
const allResults = result.results || [];
|
||||||
|
|
||||||
|
// Transform to frontend format
|
||||||
|
let formatted = (allResults || []).map((item: any) => ({
|
||||||
|
id: item.id || '',
|
||||||
|
title: filterTitle(item.title || item.content || ''),
|
||||||
|
description: item.content || item.snippet || '',
|
||||||
|
share_url: item.url || '',
|
||||||
|
cloud_type: detectCloudType(item.url || ''),
|
||||||
|
file_size: '',
|
||||||
|
source: item.source || '',
|
||||||
|
datetime: item.datetime || '',
|
||||||
|
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||||
|
password: item.password || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filter out expired/invalid links
|
||||||
|
const expiredCount = formatted.filter(r => r.share_url && isExpiredShareLink(r.share_url)).length;
|
||||||
|
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||||
|
|
||||||
|
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||||
|
const enabledSet = getEnabledCloudTypeSet();
|
||||||
|
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||||
|
|
||||||
|
// Return results immediately without blocking validation
|
||||||
|
const channels = groupResultsByChannel(formatted, (item: any) =>
|
||||||
|
detectCloudType(item.url || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
results: formatted,
|
||||||
|
channels,
|
||||||
|
total: formatted.length,
|
||||||
|
filtered: expiredCount,
|
||||||
|
link_validation: false,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Search] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load title filter rules from DB and apply to a title.
|
||||||
|
*/
|
||||||
|
function filterTitle(title: string): string {
|
||||||
|
const rules = getSystemConfig('title_filter_rules') || '';
|
||||||
|
return applyTitleFilter(title, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectCloudType is imported from config/cloud-labels
|
||||||
|
|
||||||
|
// 检测失效的分享链接(支持多种模式)
|
||||||
|
function isExpiredShareLink(url: string): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
|
||||||
|
// 空链接/纯片段(无实际链接内容)
|
||||||
|
if (url.startsWith('#') || url.length < 10) return true;
|
||||||
|
|
||||||
|
// PanSou 有时返回残缺链接如 "/s/xxx" 或只有 "#/list/share"
|
||||||
|
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('http')) return true;
|
||||||
|
|
||||||
|
// 夸克链接格式校验
|
||||||
|
if (url.includes('pan.quark.cn')) {
|
||||||
|
const baseUrl = url.split('#')[0]; // 去掉 hash 路由片段
|
||||||
|
// 有效格式必须是 pan.quark.cn/s/xxxxxx
|
||||||
|
if (!/pan\.quark\.cn\/s\/[a-zA-Z0-9]+/.test(baseUrl)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 百度网盘常见失效格式
|
||||||
|
if (url.includes('pan.baidu.com') && /share\/init\?surl=$/.test(url)) return true;
|
||||||
|
|
||||||
|
// 阿里云盘失效格式(短到异常的链接)
|
||||||
|
if (url.includes('aliyundrive.com') && url.length < 30) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group search results into channels by cloud type.
|
||||||
|
* Each channel: { cloud_type, label, color, count, items }
|
||||||
|
*/
|
||||||
|
|
||||||
|
function groupResultsByChannel(results: any[], getCloudType?: (item: any) => string): any[] {
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
const order: Record<string, number> = {
|
||||||
|
quark: 1, baidu: 2, aliyun: 3, '115': 4,
|
||||||
|
tianyi: 5, '123pan': 6, uc: 7, xunlei: 8,
|
||||||
|
pikpak: 9, magnet: 10, ed2k: 11, others: 12,
|
||||||
|
};
|
||||||
|
for (const item of results) {
|
||||||
|
const ct = getCloudType ? getCloudType(item) : (item.source || detectCloudType(item.url || '') || 'others');
|
||||||
|
if (!groups[ct]) groups[ct] = [];
|
||||||
|
groups[ct].push(item);
|
||||||
|
}
|
||||||
|
return Object.entries(groups)
|
||||||
|
.sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99))
|
||||||
|
.map(([cloud_type, items]) => ({
|
||||||
|
cloud_type,
|
||||||
|
label: (CLOUD_LABELS as any)[cloud_type] || cloud_type,
|
||||||
|
color: (CLOUD_COLORS as any)[cloud_type] || '#95a5a6',
|
||||||
|
count: items.length,
|
||||||
|
items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Video ============
|
||||||
|
// ============ Video ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/video/parse
|
||||||
|
* Parse a video URL
|
||||||
|
*/
|
||||||
|
router.post('/video/parse', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ error: 'URL is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoInfo = await parseVideo(url);
|
||||||
|
res.json(videoInfo);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Video] Parse error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to parse video' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Cloud Save ============
|
||||||
|
// ============ Cloud Save ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/save
|
||||||
|
* Save a share link to a specific cloud
|
||||||
|
*/
|
||||||
|
router.post('/save', saveLimiter, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Support both formats:
|
||||||
|
// 1. Backend-style: { url, cloudType }
|
||||||
|
// 2. Frontend-style: { source: { share_url }, target_cloud }
|
||||||
|
const url = req.body.url || req.body.source?.share_url || req.body.source?.url;
|
||||||
|
const cloudType = req.body.cloudType || req.body.target_cloud || (req.body.source as any)?.cloud_type;
|
||||||
|
const sourceTitle = req.body.source_title || req.body.source?.title || req.body.title;
|
||||||
|
if (!url || !cloudType) {
|
||||||
|
res.status(400).json({ error: 'URL and cloudType/cloud_type are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||||
|
const result = await saveFromShare(url, cloudType, sourceTitle, ip);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Save] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to save to cloud' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/video/save-to-cloud
|
||||||
|
* Save a video to cloud
|
||||||
|
*/
|
||||||
|
router.post('/video/save-to-cloud', saveLimiter, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { videoUrl, cloudType, title } = req.body;
|
||||||
|
if (!videoUrl || !cloudType) {
|
||||||
|
res.status(400).json({ error: 'videoUrl and cloudType are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||||
|
const result = await saveFromShare(videoUrl, cloudType, title, ip);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Video] Save-to-cloud error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to save video to cloud' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Rankings ============
|
||||||
|
// ============ Rankings ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/rankings
|
||||||
|
* Get search rankings
|
||||||
|
*/
|
||||||
|
router.get('/rankings', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const rankings = await getRankings();
|
||||||
|
res.json(rankings);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Rankings] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/rankings/hot
|
||||||
|
* Get hot keywords
|
||||||
|
*/
|
||||||
|
router.get('/rankings/hot', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const keywords = await getHotKeywords();
|
||||||
|
res.json(keywords);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Hot] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/rankings/categorized
|
||||||
|
* Get categorized rankings (hot + newest per category), cached for 12h
|
||||||
|
*/
|
||||||
|
router.get('/rankings/categorized', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const data = await getCategorizedRankings();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Categorized] Error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/site-config
|
||||||
|
* Public site configuration (no auth required).
|
||||||
|
*/
|
||||||
|
router.get('/site-config', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const siteLogo = getSystemConfig('site_logo') || '';
|
||||||
|
const siteName = getSystemConfig('site_name') || 'CloudSearch';
|
||||||
|
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||||
|
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||||
|
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||||
|
res.json({ site_logo: siteLogo, site_name: siteName, search_fallback_image: fallbackImage, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/me
|
||||||
|
* Get current user info from token (public, no auth middleware).
|
||||||
|
*/
|
||||||
|
router.get('/me', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.json({ loggedIn: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
res.json({ loggedIn: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ loggedIn: true, id: payload.id, username: payload.username });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.json({ loggedIn: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ Admin ============
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract genre tags from search result titles.
|
||||||
|
*/
|
||||||
|
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (keyword) tags.push(keyword);
|
||||||
|
|
||||||
|
const genreKeywords: Record<string, string> = {
|
||||||
|
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||||
|
'剧场版': '剧场版', '年番': '年番',
|
||||||
|
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||||
|
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||||
|
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||||
|
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||||
|
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||||
|
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||||
|
};
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of results) {
|
||||||
|
const title = (r.title || r.note || '') as string;
|
||||||
|
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||||
|
if (title.includes(key) && !seen.has(val)) {
|
||||||
|
seen.add(val);
|
||||||
|
tags.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query DB for previously saved resources that match the keyword.
|
||||||
|
* Returns formatted results for immediate streaming before external API call.
|
||||||
|
*/
|
||||||
|
function getSavedResources(keyword: string): any[] {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT source_url, source_title, target_cloud, share_url, created_at
|
||||||
|
FROM save_records
|
||||||
|
WHERE status = 'success'
|
||||||
|
AND (source_title LIKE ? OR source_url LIKE ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`).all(`%${keyword}%`, `%${keyword}%`) as any[];
|
||||||
|
|
||||||
|
return rows.map((row: any, idx: number) => ({
|
||||||
|
id: `saved_${idx}`,
|
||||||
|
title: row.source_title || row.source_url || '',
|
||||||
|
description: '',
|
||||||
|
share_url: row.share_url || row.source_url || '',
|
||||||
|
cloud_type: detectCloudType(row.share_url || row.source_url || ''),
|
||||||
|
file_size: '',
|
||||||
|
update_time: row.created_at || '',
|
||||||
|
source: 'local',
|
||||||
|
file_id: '',
|
||||||
|
cover: '',
|
||||||
|
password: '',
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[SavedResources] DB query error:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
125
packages/backend/src/routes/upload.routes.ts
Normal file
125
packages/backend/src/routes/upload.routes.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { authMiddleware } from '../admin/auth.service';
|
||||||
|
import { updateSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============ Upload ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/upload-fallback-image
|
||||||
|
* Upload a fallback cover image for search results without covers.
|
||||||
|
* Recommended: 320×180 JPEG/PNG (16:9), max 2MB.
|
||||||
|
*/
|
||||||
|
const uploadDir = path.resolve('/app/uploads/fallback');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackStorage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||||
|
filename: (_req, _file, cb) => {
|
||||||
|
const ext = '.jpg';
|
||||||
|
cb(null, `fallback_cover_tmp${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: fallbackStorage,
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('仅支持图片文件(JPEG/PNG)'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/upload-fallback-image', authMiddleware, upload.single('image'), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: '请选择要上传的图片' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 压缩:最大宽度320px,JPEG quality 80
|
||||||
|
const outPath = path.resolve(uploadDir, 'fallback_cover.jpg');
|
||||||
|
await sharp(req.file.path)
|
||||||
|
.resize(320, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toFile(outPath);
|
||||||
|
// 删除原始上传文件(如果路径不同)
|
||||||
|
if (req.file.path !== outPath) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
}
|
||||||
|
const url = `/api/uploads/fallback/fallback_cover.jpg`;
|
||||||
|
updateSystemConfig('search_fallback_image', url);
|
||||||
|
const stat = fs.statSync(outPath);
|
||||||
|
res.json({ success: true, url, message: `✅ 兜底图已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || '上传失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/upload-logo
|
||||||
|
* Upload a site logo image displayed on search page (home link) and homepage.
|
||||||
|
* Recommended: 320×60 or similar wide/banner ratio, JPEG/PNG/WebP, max 2MB.
|
||||||
|
*/
|
||||||
|
const logoUploadDir = path.resolve('/app/uploads/logo');
|
||||||
|
if (!fs.existsSync(logoUploadDir)) {
|
||||||
|
fs.mkdirSync(logoUploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoStorage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => cb(null, logoUploadDir),
|
||||||
|
filename: (_req, _file, cb) => {
|
||||||
|
cb(null, `site_logo_tmp.png`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoUpload = multer({
|
||||||
|
storage: logoStorage,
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('仅支持图片文件(JPEG/PNG/WebP)'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/upload-logo', authMiddleware, logoUpload.single('image'), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: '请选择要上传的图片' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 压缩:最大宽度640px,PNG格式
|
||||||
|
const outPath = path.resolve(logoUploadDir, 'site_logo.png');
|
||||||
|
await sharp(req.file.path)
|
||||||
|
.resize(640, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(outPath);
|
||||||
|
if (req.file.path !== outPath) {
|
||||||
|
fs.unlink(req.file.path, () => {});
|
||||||
|
}
|
||||||
|
const url = `/api/uploads/logo/site_logo.png`;
|
||||||
|
updateSystemConfig('site_logo', url);
|
||||||
|
const stat = fs.statSync(outPath);
|
||||||
|
res.json({ success: true, url, message: `✅ 站点图标已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || '上传失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||||
|
|
||||||
|
// ===== 夸克扫码登录 (不需要 auth,用户未登录时也需要能用) =====
|
||||||
|
|
||||||
|
export default router;
|
||||||
351
packages/backend/src/search/rankings.service.ts
Executable file
351
packages/backend/src/search/rankings.service.ts
Executable file
@@ -0,0 +1,351 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { getTimezone, formatLocalDateTime } from '../utils/time';
|
||||||
|
|
||||||
|
export interface RankingItem {
|
||||||
|
keyword: string;
|
||||||
|
searchCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorizedRanking {
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
hot: RankingItem[];
|
||||||
|
newest: RankingItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategorizedResponse {
|
||||||
|
fetchedAt: string;
|
||||||
|
categories: CategorizedRanking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Bilibili PGC 排行榜配置 =====
|
||||||
|
interface BiliPgcDef {
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILI_PGC_CATEGORIES: BiliPgcDef[] = [
|
||||||
|
// 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画
|
||||||
|
{ category: 'donghua', label: '国产动漫', season_type: 4 },
|
||||||
|
// 番剧:日漫等全球动画
|
||||||
|
{ category: 'global_anime', label: '热门动漫', season_type: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 百度热搜榜配置 =====
|
||||||
|
interface BaiduBoardDef {
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
tab: string; // movie=电影热搜, teleplay=电视剧热搜
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAIDU_BOARDS: BaiduBoardDef[] = [
|
||||||
|
// 百度电影热搜:实时反映国内电影热度
|
||||||
|
{ category: 'movie', label: '国内电影', tab: 'movie' },
|
||||||
|
// 百度电视剧热搜:国内剧集热度
|
||||||
|
{ category: 'tv', label: '热门剧集', tab: 'teleplay' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== TMDB 分类配置(保留欧美和冷门内容)=====
|
||||||
|
interface TmdbCategoryDef {
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
hotUrl: string;
|
||||||
|
newestUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMDB_CATEGORIES: TmdbCategoryDef[] = [
|
||||||
|
{
|
||||||
|
category: 'western_movie', label: '欧美电影',
|
||||||
|
hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
|
||||||
|
newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'western_tv', label: '欧美剧集',
|
||||||
|
hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10',
|
||||||
|
newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'niche', label: '冷门佳片',
|
||||||
|
hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500',
|
||||||
|
newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== 显示顺序 =====
|
||||||
|
const CATEGORY_ORDER: Record<string, number> = {
|
||||||
|
donghua: 1,
|
||||||
|
movie: 2,
|
||||||
|
tv: 3,
|
||||||
|
global_anime: 4,
|
||||||
|
western_movie: 5,
|
||||||
|
western_tv: 6,
|
||||||
|
niche: 7,
|
||||||
|
hotsite: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== 12小时缓存 =====
|
||||||
|
let cache: { data: CategorizedResponse; time: number } | null = null;
|
||||||
|
const CACHE_TTL = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function isCacheValid(): boolean {
|
||||||
|
return cache !== null && (Date.now() - cache.time) < CACHE_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Bilibili PGC API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抓取 Bilibili PGC 排行榜(番剧/国创)
|
||||||
|
*/
|
||||||
|
async function fetchFromBiliPgc(season_type: number): Promise<RankingItem[]> {
|
||||||
|
try {
|
||||||
|
const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://www.bilibili.com/',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
if (json.code !== 0 || !json.result?.list) {
|
||||||
|
console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return json.result.list.slice(0, 20).map((item: any) => {
|
||||||
|
const stat = item.stat || {};
|
||||||
|
const viewCount = stat.view || 0;
|
||||||
|
const followCount = stat.follow || 0;
|
||||||
|
const searchCount = viewCount > 0 ? viewCount : followCount;
|
||||||
|
|
||||||
|
let rating = 0;
|
||||||
|
if (item.rating) {
|
||||||
|
const m = String(item.rating).match(/([\d.]+)/);
|
||||||
|
if (m) rating = parseFloat(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword: item.title || '',
|
||||||
|
searchCount,
|
||||||
|
updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '',
|
||||||
|
rating,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 百度热搜榜 API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抓取百度热搜榜
|
||||||
|
* tab: movie=电影, teleplay=电视剧
|
||||||
|
*/
|
||||||
|
async function fetchFromBaidu(tab: string): Promise<RankingItem[]> {
|
||||||
|
try {
|
||||||
|
const url = `https://top.baidu.com/api/board?tab=${tab}`;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://top.baidu.com/board',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
if (!json.success || !json.data?.cards) {
|
||||||
|
console.error(`[Baidu] API error for tab=${tab}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: RankingItem[] = [];
|
||||||
|
for (const card of json.data.cards) {
|
||||||
|
for (const item of (card.content || [])) {
|
||||||
|
results.push({
|
||||||
|
keyword: item.word || '',
|
||||||
|
// hotScore can be like "96438", parse as number
|
||||||
|
searchCount: parseInt(item.hotScore || '0', 10) || 0,
|
||||||
|
updatedAt: item.desc || '',
|
||||||
|
rating: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.slice(0, 20);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TMDB =====
|
||||||
|
|
||||||
|
function getTmdbToken(): string {
|
||||||
|
const db = getDb();
|
||||||
|
return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmdbResultToRanking(item: any): RankingItem {
|
||||||
|
const title = item.title || item.name || '';
|
||||||
|
const date = item.release_date || item.first_air_date || '';
|
||||||
|
const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0;
|
||||||
|
return {
|
||||||
|
keyword: title,
|
||||||
|
searchCount: item.vote_count || 0,
|
||||||
|
updatedAt: date,
|
||||||
|
rating,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tmdbFetch(url: string, token: string): Promise<any[]> {
|
||||||
|
const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(fullUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error(`[TMDB] HTTP ${resp.status} for ${url}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await resp.json() as any;
|
||||||
|
return (data.results || []).slice(0, 20);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[TMDB] Fetch error for ${url}:`, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主流程 =====
|
||||||
|
|
||||||
|
async function fetchRankings(): Promise<CategorizedResponse> {
|
||||||
|
const fetchedAt = formatLocalDateTime();
|
||||||
|
|
||||||
|
// 1. 并行抓取 Bilibili PGC 数据(国漫、番剧)
|
||||||
|
const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => {
|
||||||
|
const results = await fetchFromBiliPgc(cat.season_type);
|
||||||
|
const mid = Math.ceil(results.length / 2);
|
||||||
|
return {
|
||||||
|
category: cat.category,
|
||||||
|
label: cat.label,
|
||||||
|
hot: results.slice(0, mid),
|
||||||
|
newest: results.slice(mid),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 并行抓取百度热搜数据(电影、电视剧)
|
||||||
|
// 百度只有热榜没有最新榜,全部放 hot
|
||||||
|
const baiduPromises = BAIDU_BOARDS.map(async (board) => {
|
||||||
|
const results = await fetchFromBaidu(board.tab);
|
||||||
|
return {
|
||||||
|
category: board.category,
|
||||||
|
label: board.label,
|
||||||
|
hot: results,
|
||||||
|
newest: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门)
|
||||||
|
const token = getTmdbToken();
|
||||||
|
let tmdbResults: CategorizedRanking[] = [];
|
||||||
|
if (token) {
|
||||||
|
const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => {
|
||||||
|
const [hotResults, newestResults] = await Promise.all([
|
||||||
|
tmdbFetch(cat.hotUrl, token),
|
||||||
|
tmdbFetch(cat.newestUrl, token),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
category: cat.category,
|
||||||
|
label: cat.label,
|
||||||
|
hot: hotResults.map(tmdbResultToRanking),
|
||||||
|
newest: newestResults.map(tmdbResultToRanking),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
tmdbResults = await Promise.all(tmdbPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 本站热搜
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||||
|
).all() as RankingItem[];
|
||||||
|
const newestRows = db.prepare(
|
||||||
|
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20'
|
||||||
|
).all() as RankingItem[];
|
||||||
|
|
||||||
|
const hotsiteCategory: CategorizedRanking = {
|
||||||
|
category: 'hotsite',
|
||||||
|
label: '本站热搜',
|
||||||
|
hot: rows,
|
||||||
|
newest: newestRows,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 合并所有结果
|
||||||
|
const [biliResults, baiduResults] = await Promise.all([
|
||||||
|
Promise.all(biliPromises),
|
||||||
|
Promise.all(baiduPromises),
|
||||||
|
]);
|
||||||
|
const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory];
|
||||||
|
|
||||||
|
// 按 CATEGORY_ORDER 排序
|
||||||
|
allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99));
|
||||||
|
|
||||||
|
return { fetchedAt, categories: allCategories };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategorizedRankings(): Promise<CategorizedResponse> {
|
||||||
|
if (isCacheValid()) {
|
||||||
|
return cache!.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchRankings();
|
||||||
|
cache = { data, time: Date.now() };
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Rankings] Fetch error:', err);
|
||||||
|
if (cache) return cache.data;
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||||
|
).all() as RankingItem[];
|
||||||
|
return {
|
||||||
|
fetchedAt: formatLocalDateTime(),
|
||||||
|
categories: [{
|
||||||
|
category: 'hotsite', label: '本站热搜',
|
||||||
|
hot: rows,
|
||||||
|
newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRankings(): Promise<RankingItem[]> {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||||
|
).all() as RankingItem[];
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHotKeywords(): Promise<string[]> {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
||||||
|
).all() as { keyword: string }[];
|
||||||
|
return rows.map(r => r.keyword);
|
||||||
|
}
|
||||||
125
packages/backend/src/search/search-optimizer.ts
Executable file
125
packages/backend/src/search/search-optimizer.ts
Executable file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Search Results Optimizer
|
||||||
|
*
|
||||||
|
* For each cloud type, keep only the top N most relevant results.
|
||||||
|
* Order groups by priority: real cloud storage > other providers > magnet/others.
|
||||||
|
*
|
||||||
|
* Goal: give users a manageable, high-quality result set instead of overwhelming them
|
||||||
|
* with hundreds of results dominated by magnet links.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { detectCloudType } from '../config/cloud-labels';
|
||||||
|
|
||||||
|
/** Minimal result shape the optimizer needs */
|
||||||
|
interface OptimizableResult {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
source?: string;
|
||||||
|
score?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority tiers for result ordering */
|
||||||
|
const CLOUD_PRIORITY: Record<string, number> = {
|
||||||
|
// Tier 1: Major cloud storage (most useful for save-to-cloud feature)
|
||||||
|
baidu: 10,
|
||||||
|
quark: 10,
|
||||||
|
aliyun: 10,
|
||||||
|
// Tier 2: Other cloud storage
|
||||||
|
'115': 20,
|
||||||
|
tianyi: 20,
|
||||||
|
'123pan': 20,
|
||||||
|
uc: 20,
|
||||||
|
xunlei: 20,
|
||||||
|
pikpak: 20,
|
||||||
|
// Tier 3: Mobile/app links (not very useful)
|
||||||
|
mobile: 50,
|
||||||
|
// Tier 4: Direct links (lowest utility for cloud saving)
|
||||||
|
magnet: 100,
|
||||||
|
ed2k: 100,
|
||||||
|
others: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PRIORITY = 50;
|
||||||
|
|
||||||
|
/** Get cloud type for a result, with an extra check for tracker URLs */
|
||||||
|
function getCloudType(result: OptimizableResult): string {
|
||||||
|
const url = result.url;
|
||||||
|
// Check for tracker/private-site URLs not covered by shared detection
|
||||||
|
if (url && /mteam|hdarea|hdsky/i.test(url)) return 'others';
|
||||||
|
return detectCloudType(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriority(cloudType: string): number {
|
||||||
|
return CLOUD_PRIORITY[cloudType] ?? DEFAULT_PRIORITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OptimizationResult {
|
||||||
|
results: OptimizableResult[];
|
||||||
|
/** Per-type stats for display */
|
||||||
|
perType: Array<{ type: string; count: number; total: number }>;
|
||||||
|
/** How many items were kept vs filtered */
|
||||||
|
keptCount: number;
|
||||||
|
filteredCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimize search results:
|
||||||
|
* 1. Group by cloud type
|
||||||
|
* 2. Sort by score descending within each group
|
||||||
|
* 3. Keep only top `maxPerType` results per type
|
||||||
|
* 4. Order groups by priority (cloud storage first)
|
||||||
|
*/
|
||||||
|
export function optimizeSearchResults(
|
||||||
|
items: OptimizableResult[],
|
||||||
|
maxPerType: number = 20
|
||||||
|
): OptimizationResult {
|
||||||
|
// Step 1: Group by cloud type
|
||||||
|
const grouped: Record<string, OptimizableResult[]> = {};
|
||||||
|
const typeTotals: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const ct = getCloudType(item);
|
||||||
|
if (!grouped[ct]) {
|
||||||
|
grouped[ct] = [];
|
||||||
|
}
|
||||||
|
grouped[ct].push(item);
|
||||||
|
typeTotals[ct] = (typeTotals[ct] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 & 3: Sort each group by score desc, take top N
|
||||||
|
const kept: OptimizableResult[] = [];
|
||||||
|
const perType: Array<{ type: string; count: number; total: number }> = [];
|
||||||
|
|
||||||
|
for (const [ct, groupItems] of Object.entries(grouped)) {
|
||||||
|
// Sort by score descending (higher score = more relevant)
|
||||||
|
groupItems.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
|
|
||||||
|
const top = groupItems.slice(0, maxPerType);
|
||||||
|
kept.push(...top);
|
||||||
|
|
||||||
|
perType.push({
|
||||||
|
type: ct,
|
||||||
|
count: top.length,
|
||||||
|
total: typeTotals[ct],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Sort kept results by cloud priority, then by score within same priority
|
||||||
|
kept.sort((a, b) => {
|
||||||
|
const pa = getPriority(getCloudType(a));
|
||||||
|
const pb = getPriority(getCloudType(b));
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
return (b.score || 0) - (a.score || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const keptCount = kept.length;
|
||||||
|
const filteredCount = items.length - keptCount;
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: kept,
|
||||||
|
perType,
|
||||||
|
keptCount,
|
||||||
|
filteredCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
348
packages/backend/src/search/search.service.ts
Executable file
348
packages/backend/src/search/search.service.ts
Executable file
@@ -0,0 +1,348 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import config from '../config';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { localTimestamp } from '../utils/time';
|
||||||
|
import { proxiedFetch } from '../utils/proxy-agent';
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
content: string;
|
||||||
|
score?: number;
|
||||||
|
source?: string;
|
||||||
|
password?: string;
|
||||||
|
datetime?: string;
|
||||||
|
responseTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
results: SearchResult[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSearchSource {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
method?: string; // GET | POST (default POST)
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string; // JSON body template, supports {keyword} {page}
|
||||||
|
resultPath: string; // dot-notation path to results array (e.g. "data.list")
|
||||||
|
fieldMap: { // maps SearchResult fields to JSON response fields
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
password?: string;
|
||||||
|
datetime?: string;
|
||||||
|
};
|
||||||
|
timeout?: number; // per-source timeout (ms), default 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple dot/bracket notation JSON path accessor. */
|
||||||
|
function jsonPathGet(obj: any, path: string): any {
|
||||||
|
if (!obj || !path) return undefined;
|
||||||
|
const parts = path
|
||||||
|
.replace(/\[(\d+)\]/g, '.$1') // items[0] → items.0
|
||||||
|
.split('.')
|
||||||
|
.filter(Boolean);
|
||||||
|
let current = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null) return undefined;
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse configured API search sources from system config. */
|
||||||
|
function getApiSearchSources(): ApiSearchSource[] {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const raw = (db.prepare("SELECT value FROM system_configs WHERE key = 'api_search_sources'").get() as any)?.value;
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((s: any) => s.url && s.resultPath);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query a single API search source and return results with timing. */
|
||||||
|
async function queryApiSource(
|
||||||
|
source: ApiSearchSource,
|
||||||
|
keyword: string,
|
||||||
|
page: number,
|
||||||
|
proxyUrl?: string,
|
||||||
|
): Promise<{ source: string; results: SearchResult[]; responseTimeMs: number; error?: string }> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const timeout = source.timeout || 10000;
|
||||||
|
const method = (source.method || 'POST').toUpperCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = source.url;
|
||||||
|
const headers: Record<string, string> = { ...source.headers };
|
||||||
|
|
||||||
|
let body: string | undefined;
|
||||||
|
if (source.body) {
|
||||||
|
body = source.body
|
||||||
|
.replace(/\{keyword\}/g, encodeURIComponent(keyword))
|
||||||
|
.replace(/\{page\}/g, String(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For GET requests, append query params; for POST, use body
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
signal: AbortSignal.timeout(timeout),
|
||||||
|
};
|
||||||
|
|
||||||
|
// For GET requests, append query params; for POST, use body
|
||||||
|
if (method === 'GET') {
|
||||||
|
// Parse body as JSON and convert to query string
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
const params = JSON.parse(body);
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
url += (url.includes('?') ? '&' : '?') + qs;
|
||||||
|
} catch {
|
||||||
|
// If body is not JSON, append as raw query
|
||||||
|
url += (url.includes('?') ? '&' : '?') + body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(fetchOptions as any).body = body || JSON.stringify({ keyword, page });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await proxiedFetch(url, fetchOptions, proxyUrl);
|
||||||
|
const responseTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { source: source.name, results: [], responseTimeMs, error: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const resultTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Extract results array using JSONPath
|
||||||
|
const items = jsonPathGet(data, source.resultPath);
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
return { source: source.name, results: [], responseTimeMs: resultTimeMs, error: 'resultPath not found or not an array' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map fields
|
||||||
|
const fm = source.fieldMap || {};
|
||||||
|
const results: SearchResult[] = items.map((item: any) => ({
|
||||||
|
title: (fm.title ? item[fm.title] : item.title) || item.name || '',
|
||||||
|
url: (fm.url ? item[fm.url] : item.url) || item.link || '',
|
||||||
|
content: (fm.content ? item[fm.content] : item.content) || item.snippet || '',
|
||||||
|
password: (fm.password ? item[fm.password] : item.password) || '',
|
||||||
|
datetime: (fm.datetime ? item[fm.datetime] : item.datetime) || item.date || '',
|
||||||
|
source: source.name,
|
||||||
|
responseTimeMs: resultTimeMs,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { source: source.name, results, responseTimeMs: resultTimeMs };
|
||||||
|
} catch (err: any) {
|
||||||
|
const responseTimeMs = Date.now() - startTime;
|
||||||
|
return { source: source.name, results: [], responseTimeMs, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query all configured API search sources in parallel. */
|
||||||
|
async function searchApiSources(keyword: string, page: number, proxyUrl?: string): Promise<{
|
||||||
|
results: SearchResult[];
|
||||||
|
sourceStats: { name: string; count: number; responseTimeMs: number; error?: string }[];
|
||||||
|
}> {
|
||||||
|
const sources = getApiSearchSources();
|
||||||
|
if (sources.length === 0) return { results: [], sourceStats: [] };
|
||||||
|
|
||||||
|
const promises = sources.map(s => queryApiSource(s, keyword, page, proxyUrl));
|
||||||
|
const allResults = await Promise.all(promises);
|
||||||
|
|
||||||
|
const sourceStats = allResults.map(r => ({
|
||||||
|
name: r.source,
|
||||||
|
count: r.results.length,
|
||||||
|
responseTimeMs: r.responseTimeMs,
|
||||||
|
error: r.error,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge all results, tag with source name, sort by response time (fastest first)
|
||||||
|
const results = allResults
|
||||||
|
.flatMap(r => r.results)
|
||||||
|
.sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999));
|
||||||
|
|
||||||
|
return { results, sourceStats };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(keyword: string, page: number = 1, ip?: string): Promise<SearchResponse> {
|
||||||
|
const db = getDb();
|
||||||
|
const pansouUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('pansou_url') as any)?.value || config.pansouUrl;
|
||||||
|
const proxyEnabled = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_enabled') as any)?.value === 'true';
|
||||||
|
const proxyUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_url') as any)?.value || '';
|
||||||
|
const effectiveProxy = proxyEnabled ? proxyUrl : undefined;
|
||||||
|
|
||||||
|
// ── Run PanSou and API sources in parallel ──
|
||||||
|
const pansouPromise = (async () => {
|
||||||
|
const url = `${pansouUrl}/api/search`;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ kw: keyword, page }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
};
|
||||||
|
const pansouStart = Date.now();
|
||||||
|
const response = await proxiedFetch(url, fetchOptions, effectiveProxy);
|
||||||
|
if (!response.ok) throw new Error(`PanSou API error: ${response.status}`);
|
||||||
|
const data = await response.json() as any;
|
||||||
|
return { data, responseTimeMs: Date.now() - pansouStart };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const apiSourcesPromise = searchApiSources(keyword, page, effectiveProxy);
|
||||||
|
|
||||||
|
const [pansouResult, apiSourcesResult] = await Promise.all([pansouPromise, apiSourcesPromise]);
|
||||||
|
|
||||||
|
const { data, responseTimeMs: pansouTime } = pansouResult;
|
||||||
|
|
||||||
|
// ── Parse PanSou results ──
|
||||||
|
let items: any[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (data.data?.merged_by_type) {
|
||||||
|
for (const [cloudType, cloudItems] of Object.entries(data.data.merged_by_type)) {
|
||||||
|
if (Array.isArray(cloudItems)) {
|
||||||
|
items.push(...cloudItems.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
_cloud_type: cloudType,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total = data.data.total || items.length;
|
||||||
|
} else if (Array.isArray(data.data)) {
|
||||||
|
items = data.data;
|
||||||
|
total = data.total || items.length;
|
||||||
|
} else if (Array.isArray(data.results)) {
|
||||||
|
items = data.results;
|
||||||
|
total = data.total || items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pansouResults: SearchResult[] = items.map((item: any) => ({
|
||||||
|
title: item.note || item.title || '',
|
||||||
|
url: item.url || item.link || '',
|
||||||
|
content: item.content || item.snippet || item.note || '',
|
||||||
|
score: item.score || 0,
|
||||||
|
source: item.source || item._cloud_type || 'pansou',
|
||||||
|
password: item.password || '',
|
||||||
|
datetime: item.datetime || '',
|
||||||
|
responseTimeMs: pansouTime,
|
||||||
|
images: item.images || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Merge PanSou + API sources, sort by response time (fastest first) ──
|
||||||
|
const allResults = [...apiSourcesResult.results, ...pansouResults]
|
||||||
|
.sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999));
|
||||||
|
|
||||||
|
// Deduplicate by URL within merged results
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
for (const r of allResults) {
|
||||||
|
if (r.url && !seenUrls.has(r.url)) {
|
||||||
|
seenUrls.add(r.url);
|
||||||
|
results.push(r);
|
||||||
|
} else if (!r.url) {
|
||||||
|
results.push(r); // keep results without URLs (unlikely but safe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total = results.length;
|
||||||
|
|
||||||
|
// Sort by datetime descending as secondary sort (preserve response-time groups)
|
||||||
|
results.sort((a: any, b: any) => {
|
||||||
|
const ta = a.datetime || '';
|
||||||
|
const tb = b.datetime || '';
|
||||||
|
if (!ta && !tb) return 0;
|
||||||
|
if (!ta) return 1;
|
||||||
|
if (!tb) return -1;
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record search statistics
|
||||||
|
recordSearchStats(keyword, results.length, ip);
|
||||||
|
|
||||||
|
// Update hot keywords
|
||||||
|
updateHotKeywords(keyword);
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize: data.pageSize || 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply title filter rules to clean up search result titles.
|
||||||
|
* Rules format (one per line):
|
||||||
|
* # comment lines are ignored (hash must be followed by space)
|
||||||
|
* /pattern/flags → regex: matched content is deleted from title
|
||||||
|
* plain text → literal text: exact text is deleted from title wherever it appears
|
||||||
|
*/
|
||||||
|
export function applyTitleFilter(title: string, rules: string): string {
|
||||||
|
if (!title || !rules) return title;
|
||||||
|
const lines = rules.split('\n');
|
||||||
|
let result = title;
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('# ')) continue;
|
||||||
|
try {
|
||||||
|
if (line.startsWith('/') && line.lastIndexOf('/') > 0) {
|
||||||
|
const lastSlashIdx = line.lastIndexOf('/');
|
||||||
|
const pattern = line.substring(1, lastSlashIdx);
|
||||||
|
const flags = line.substring(lastSlashIdx + 1);
|
||||||
|
const anchored = pattern.startsWith('^') ? pattern : '^' + pattern;
|
||||||
|
const re = new RegExp(anchored, flags);
|
||||||
|
const match = re.exec(result);
|
||||||
|
if (match && match.index === 0) {
|
||||||
|
result = result.slice(match[0].length);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (result.startsWith(line)) {
|
||||||
|
result = result.slice(line.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSearchStats(keyword: string, resultCount: number, ip?: string): void {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO search_stats (keyword, intent, result_count, ip_address, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(keyword, 'SEARCH', resultCount, ip || '', localTimestamp());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Search] Failed to record stats:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHotKeywords(keyword: string): void {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.prepare('SELECT id FROM hot_keywords WHERE keyword = ?').get(keyword) as any;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE hot_keywords SET search_count = search_count + 1, updated_at = ? WHERE keyword = ?"
|
||||||
|
).run(localTimestamp(), keyword);
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO hot_keywords (keyword, search_count, updated_at) VALUES (?, 1, ?)"
|
||||||
|
).run(keyword, localTimestamp());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Search] Failed to update hot keywords:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
packages/backend/src/utils/crypto.ts
Normal file
111
packages/backend/src/utils/crypto.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* AES-256-GCM 加解密工具
|
||||||
|
*
|
||||||
|
* 用于保护数据库中存储的网盘 Cookie。
|
||||||
|
* 加密密钥从环境变量 COOKIE_ENCRYPTION_KEY 读取,
|
||||||
|
* 未设置时自动生成随机密钥(仅当前进程有效,重启后旧数据不可解密)。
|
||||||
|
*
|
||||||
|
* 生产环境必须设置 COOKIE_ENCRYPTION_KEY!
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 12; // 96-bit nonce for GCM
|
||||||
|
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||||
|
const KEY_LENGTH = 32; // 256-bit key
|
||||||
|
|
||||||
|
let ENCRYPTION_KEY: Buffer | null = null;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
if (ENCRYPTION_KEY) return ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
const envKey = process.env.COOKIE_ENCRYPTION_KEY;
|
||||||
|
if (envKey && envKey.length >= 32) {
|
||||||
|
// Use SHA-256 to derive a consistent 32-byte key from any length input
|
||||||
|
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||||
|
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
|
||||||
|
} else if (envKey) {
|
||||||
|
// Short key: still use SHA-256
|
||||||
|
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||||
|
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
|
||||||
|
} else {
|
||||||
|
// Default stable key (not ephemeral) — data survives container restart
|
||||||
|
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
|
||||||
|
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
|
||||||
|
}
|
||||||
|
return ENCRYPTION_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag).
|
||||||
|
*/
|
||||||
|
export function encrypt(plaintext: string): string {
|
||||||
|
if (!plaintext) return '';
|
||||||
|
const key = getKey();
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Format: iv (12) + tag (16) + ciphertext
|
||||||
|
const combined = Buffer.concat([iv, tag, encrypted]);
|
||||||
|
return combined.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt base64-encoded ciphertext. Returns original plaintext.
|
||||||
|
* Returns empty string if decryption fails (corrupted data or wrong key).
|
||||||
|
*/
|
||||||
|
export function decrypt(encoded: string): string {
|
||||||
|
if (!encoded) return '';
|
||||||
|
try {
|
||||||
|
const key = getKey();
|
||||||
|
const combined = Buffer.from(encoded, 'base64');
|
||||||
|
|
||||||
|
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
|
||||||
|
console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)');
|
||||||
|
// Legacy data: stored as plaintext before encryption was added
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = combined.subarray(0, IV_LENGTH);
|
||||||
|
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||||
|
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (err: any) {
|
||||||
|
// If it looks like base64 but decryption fails, it might be legacy plaintext
|
||||||
|
// stored before encryption was enabled. Try returning as-is.
|
||||||
|
if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) {
|
||||||
|
console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is');
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
console.error('[Crypto] Decryption error:', err.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string appears to be encrypted (base64 with IV+tag prefix).
|
||||||
|
* Used for migration: re-encrypt legacy plaintext cookies.
|
||||||
|
*/
|
||||||
|
export function isEncrypted(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const combined = Buffer.from(value, 'base64');
|
||||||
|
return combined.length > IV_LENGTH + TAG_LENGTH;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/backend/src/utils/logger.ts
Normal file
73
packages/backend/src/utils/logger.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 结构化日志工具
|
||||||
|
*
|
||||||
|
* 统一日志格式,支持请求追踪。
|
||||||
|
*/
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
module?: string;
|
||||||
|
duration?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||||
|
debug: 0,
|
||||||
|
info: 1,
|
||||||
|
warn: 2,
|
||||||
|
error: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLevel: LogLevel =
|
||||||
|
(process.env.LOG_LEVEL as LogLevel) ||
|
||||||
|
(process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||||
|
|
||||||
|
function shouldLog(level: LogLevel): boolean {
|
||||||
|
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLog(entry: LogEntry): string {
|
||||||
|
const parts = [
|
||||||
|
`[${entry.timestamp}]`,
|
||||||
|
`[${entry.level.toUpperCase()}]`,
|
||||||
|
];
|
||||||
|
if (entry.module) parts.push(`[${entry.module}]`);
|
||||||
|
parts.push(entry.message);
|
||||||
|
if (entry.duration !== undefined) parts.push(`(${entry.duration}ms)`);
|
||||||
|
if (entry.error) parts.push(`\n ${entry.error}`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(level: LogLevel, message: string, module?: string, extra?: Record<string, any>): void {
|
||||||
|
if (!shouldLog(level)) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
module,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatted = formatLog(entry);
|
||||||
|
switch (level) {
|
||||||
|
case 'error': console.error(formatted); break;
|
||||||
|
case 'warn': console.warn(formatted); break;
|
||||||
|
default: console.log(formatted); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug: (msg: string, module?: string) => log('debug', msg, module),
|
||||||
|
info: (msg: string, module?: string) => log('info', msg, module),
|
||||||
|
warn: (msg: string, module?: string) => log('warn', msg, module),
|
||||||
|
error: (msg: string, module?: string, err?: Error) =>
|
||||||
|
log('error', msg, module, err ? { error: err.stack || err.message } : undefined),
|
||||||
|
|
||||||
|
/** Log with duration (for performance tracking) */
|
||||||
|
perf: (msg: string, durationMs: number, module?: string) =>
|
||||||
|
log('info', msg, module, { duration: durationMs }),
|
||||||
|
};
|
||||||
143
packages/backend/src/utils/proxy-agent.ts
Normal file
143
packages/backend/src/utils/proxy-agent.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 统一代理工具 — 支持 HTTP/HTTPS/SOCKS5/SOCKS5h 协议
|
||||||
|
*
|
||||||
|
* Node 20+ 原生 fetch() 使用 undici Dispatcher,但 socks-proxy-agent 不实现此接口。
|
||||||
|
* 解决方案:使用 http.Agent 接口 + http/https.request()。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let HttpsProxyAgent: any;
|
||||||
|
let SocksProxyAgent: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
|
||||||
|
} catch {
|
||||||
|
try { HttpsProxyAgent = require('https-proxy-agent'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent;
|
||||||
|
} catch {
|
||||||
|
try { SocksProxyAgent = require('socks-proxy-agent'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an http.Agent for the given proxy URL (works with https.request) */
|
||||||
|
function createProxyAgent(proxyUrl: string): any | null {
|
||||||
|
if (!proxyUrl || typeof proxyUrl !== 'string') return null;
|
||||||
|
const trimmed = proxyUrl.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lower.startsWith('socks5://') || lower.startsWith('socks5h://')) {
|
||||||
|
if (!SocksProxyAgent) {
|
||||||
|
console.warn('[Proxy] socks-proxy-agent not installed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new SocksProxyAgent(trimmed);
|
||||||
|
}
|
||||||
|
if (lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||||
|
if (!HttpsProxyAgent) {
|
||||||
|
console.warn('[Proxy] No HTTP proxy agent available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new HttpsProxyAgent(trimmed);
|
||||||
|
}
|
||||||
|
// Unknown scheme — try as HTTP proxy
|
||||||
|
if (HttpsProxyAgent) return new HttpsProxyAgent(trimmed);
|
||||||
|
return null;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Proxy] Failed to create proxy agent: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch with proxy support.
|
||||||
|
* Uses native fetch() when no proxy, or http/https.request() with agent when proxy is set.
|
||||||
|
*/
|
||||||
|
export async function proxiedFetch(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
proxyUrl?: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!proxyUrl) return fetch(url, init);
|
||||||
|
|
||||||
|
const agent = createProxyAgent(proxyUrl);
|
||||||
|
if (!agent) return fetch(url, init);
|
||||||
|
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const mod = parsedUrl.protocol === 'https:' ? require('https') : require('http');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (init?.headers) {
|
||||||
|
const h = init.headers as any;
|
||||||
|
if (h instanceof Headers) {
|
||||||
|
h.forEach((v, k) => { headers[k] = v; });
|
||||||
|
} else if (typeof h === 'object') {
|
||||||
|
Object.assign(headers, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: any = {
|
||||||
|
hostname: parsedUrl.hostname,
|
||||||
|
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsedUrl.pathname + parsedUrl.search,
|
||||||
|
method: init?.method || 'GET',
|
||||||
|
headers,
|
||||||
|
agent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = mod.request(options, (res: any) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks);
|
||||||
|
resolve(new Response(body, {
|
||||||
|
status: res.statusCode || 502,
|
||||||
|
statusText: res.statusMessage || '',
|
||||||
|
headers: new Headers(res.headers || {}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
if (init?.signal) {
|
||||||
|
init.signal.addEventListener('abort', () => req.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (init?.body) {
|
||||||
|
req.write(
|
||||||
|
typeof init.body === 'string' ? init.body :
|
||||||
|
init.body instanceof Buffer ? init.body :
|
||||||
|
init.body instanceof ArrayBuffer ? Buffer.from(init.body) :
|
||||||
|
Buffer.from(String(init.body))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testProxyConnection(
|
||||||
|
proxyUrl: string,
|
||||||
|
testUrl?: string,
|
||||||
|
): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||||
|
const target = testUrl || 'https://www.baidu.com';
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await proxiedFetch(target, {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
}, proxyUrl);
|
||||||
|
const latency = Date.now() - start;
|
||||||
|
return { ok: true, latency, info: `连接成功 (${res.status})` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { ok: false, latency: Date.now() - start, info: `代理连接失败: ${err.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy compat — no longer returns dispatcher, kept for type compatibility
|
||||||
|
export function createProxyDispatcher(proxyUrl: string): { agent?: any } | null {
|
||||||
|
const agent = createProxyAgent(proxyUrl);
|
||||||
|
return agent ? { agent } : null;
|
||||||
|
}
|
||||||
407
packages/backend/src/utils/qr-login.service.ts
Executable file
407
packages/backend/src/utils/qr-login.service.ts
Executable file
@@ -0,0 +1,407 @@
|
|||||||
|
import { chromium, BrowserContext, Page } from 'playwright';
|
||||||
|
import jsQR from 'jsqr';
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
import { escapeLike } from '../utils/time';
|
||||||
|
|
||||||
|
interface QrSession {
|
||||||
|
id: string;
|
||||||
|
browserContext: BrowserContext;
|
||||||
|
page: Page;
|
||||||
|
createdAt: number;
|
||||||
|
cookieSnapshot: string;
|
||||||
|
lastPollAt: number;
|
||||||
|
qrUrl: string;
|
||||||
|
status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSIONS = new Map<string, QrSession>();
|
||||||
|
const SESSION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks
|
||||||
|
|
||||||
|
const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser';
|
||||||
|
|
||||||
|
// Clean up old sessions periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, session] of SESSIONS.entries()) {
|
||||||
|
if (now - session.createdAt > SESSION_TTL) {
|
||||||
|
cleanupSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
function cleanupSession(id: string) {
|
||||||
|
const session = SESSIONS.get(id);
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
session.browserContext.close().catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
SESSIONS.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract QR code URL from the login page canvas using jsQR.
|
||||||
|
* The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo.
|
||||||
|
*/
|
||||||
|
async function extractQrUrl(page: Page): Promise<string> {
|
||||||
|
// Run inside Playwright's browser context (as a string to avoid Node TS type errors)
|
||||||
|
const raw = await page.evaluate(`(() => {
|
||||||
|
const canvases = document.querySelectorAll('canvas');
|
||||||
|
var results = [];
|
||||||
|
for (var i = 0; i < canvases.length; i++) {
|
||||||
|
try {
|
||||||
|
var c = canvases[i];
|
||||||
|
var ctx = c.getContext('2d');
|
||||||
|
if (!ctx) continue;
|
||||||
|
var imageData = ctx.getImageData(0, 0, c.width, c.height);
|
||||||
|
results.push({
|
||||||
|
index: i,
|
||||||
|
w: c.width,
|
||||||
|
h: c.height,
|
||||||
|
data: Array.from(imageData.data)
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
})()`) as unknown as { index: number; w: number; h: number; data: number[] }[];
|
||||||
|
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
throw new Error('页面没有可用的 canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decode each canvas, preferring the one with su.quark.cn URL
|
||||||
|
let bestUrl = '';
|
||||||
|
let bestResult: { index: number; w: number; h: number; data: number[] } | null = null;
|
||||||
|
|
||||||
|
for (const canvas of raw) {
|
||||||
|
const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h);
|
||||||
|
if (code && code.data) {
|
||||||
|
// If this is the login QR code (has su.quark.cn), use it immediately
|
||||||
|
if (code.data.includes('su.quark.cn')) {
|
||||||
|
return code.data;
|
||||||
|
}
|
||||||
|
// Otherwise keep it as fallback
|
||||||
|
if (!bestUrl) {
|
||||||
|
bestUrl = code.data;
|
||||||
|
bestResult = canvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestUrl) {
|
||||||
|
return bestUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('无法解析二维码内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a QR code login session.
|
||||||
|
* Launches headless Chromium, navigates to Quark login page, extracts QR code URL.
|
||||||
|
*/
|
||||||
|
export async function startQrLogin(): Promise<{
|
||||||
|
sessionId: string;
|
||||||
|
qrUrl: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}> {
|
||||||
|
// Clean up any existing expired sessions
|
||||||
|
for (const [id, session] of SESSIONS.entries()) {
|
||||||
|
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||||
|
cleanupSession(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: CHROMIUM_PATH,
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-zygote',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const browserContext = await browser.newContext({
|
||||||
|
userAgent:
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
locale: 'zh-CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to Quark login page (now the homepage itself has QR login)
|
||||||
|
await page.goto('https://pan.quark.cn/', {
|
||||||
|
waitUntil: 'commit',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the QR code canvas to appear
|
||||||
|
await page.waitForSelector('canvas', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Extra wait for the QR code to fully render
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Extract the QR code URL from the canvas
|
||||||
|
const qrUrl = await extractQrUrl(page);
|
||||||
|
|
||||||
|
// Take initial cookie snapshot
|
||||||
|
const cookies = await browserContext.cookies();
|
||||||
|
const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
const session: QrSession = {
|
||||||
|
id: sessionId,
|
||||||
|
browserContext,
|
||||||
|
page,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cookieSnapshot,
|
||||||
|
lastPollAt: Date.now(),
|
||||||
|
qrUrl,
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
|
||||||
|
SESSIONS.set(sessionId, session);
|
||||||
|
|
||||||
|
// Start background polling for login detection
|
||||||
|
pollLoginStatus(session);
|
||||||
|
|
||||||
|
// Handle page navigation (like redirect after login)
|
||||||
|
page.on('framenavigated', async (frame) => {
|
||||||
|
if (frame === page.mainFrame()) {
|
||||||
|
const url = frame.url();
|
||||||
|
if (url === 'about:blank') {
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle popups/dialogs
|
||||||
|
page.on('popup', async (popup) => {
|
||||||
|
try {
|
||||||
|
await popup.waitForLoadState('networkidle', { timeout: 10000 });
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
qrUrl,
|
||||||
|
expiresIn: SESSION_TTL / 1000,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
// Clean up on failure
|
||||||
|
try { await browserContext.close(); } catch {}
|
||||||
|
try { browser.close().catch(() => {}); } catch {}
|
||||||
|
SESSIONS.delete(sessionId);
|
||||||
|
throw new Error(`启动扫码登录失败: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll login status in background.
|
||||||
|
* Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens.
|
||||||
|
*/
|
||||||
|
async function pollLoginStatus(session: QrSession) {
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (now - session.createdAt > SESSION_TTL) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
session.status = 'expired';
|
||||||
|
cleanupSession(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastPollAt = now;
|
||||||
|
|
||||||
|
// Check cookies
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
// Check for session cookies indicating login
|
||||||
|
const hasSessionCookie = cookies.some(
|
||||||
|
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSessionCookie) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
session.status = 'logged_in';
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check URL change as alternative indicator
|
||||||
|
const url = session.page.url();
|
||||||
|
if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) {
|
||||||
|
await checkAndCaptureCookies(session);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Page might have been closed
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, COOKIE_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check cookies after navigation/redirect and capture them if login succeeded.
|
||||||
|
*/
|
||||||
|
async function checkAndCaptureCookies(session: QrSession) {
|
||||||
|
try {
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
const hasSessionCookie = cookies.some(
|
||||||
|
c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSessionCookie) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
session.status = 'logged_in';
|
||||||
|
} else if (cookies.length > 3) {
|
||||||
|
const newCookies = cookies.filter(
|
||||||
|
c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name)
|
||||||
|
);
|
||||||
|
if (newCookies.length > 0) {
|
||||||
|
session.cookieSnapshot = cookieStr;
|
||||||
|
try {
|
||||||
|
const resp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const data = JSON.parse(resp);
|
||||||
|
if (data?.data?.nickname) {
|
||||||
|
session.status = 'logged_in';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the login status for a session.
|
||||||
|
*/
|
||||||
|
export async function getQrLoginStatus(sessionId: string): Promise<{
|
||||||
|
status: string;
|
||||||
|
cookie?: string;
|
||||||
|
nickname?: string;
|
||||||
|
storage_used?: string;
|
||||||
|
storage_total?: string;
|
||||||
|
autoUpdated?: boolean;
|
||||||
|
updatedConfigId?: number;
|
||||||
|
}> {
|
||||||
|
const session = SESSIONS.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return { status: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (Date.now() - session.createdAt > SESSION_TTL) {
|
||||||
|
session.status = 'expired';
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
return { status: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === 'logged_in') {
|
||||||
|
// Try to get nickname too
|
||||||
|
let nickname = '';
|
||||||
|
try {
|
||||||
|
const resp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch('https://pan.quark.cn/account/info', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const data = JSON.parse(resp);
|
||||||
|
nickname = data?.data?.nickname || '';
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Fetch capacity info from within the browser context (has full JS signing)
|
||||||
|
let storageTotal = '';
|
||||||
|
let storageUsed = '';
|
||||||
|
try {
|
||||||
|
const capResp = await session.page.evaluate(async () => {
|
||||||
|
const r = await fetch(
|
||||||
|
'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc',
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
return await r.text();
|
||||||
|
});
|
||||||
|
const capData = JSON.parse(capResp);
|
||||||
|
if (capData.status === 200 && capData.data?.capacity_summary) {
|
||||||
|
const summary = capData.data.capacity_summary;
|
||||||
|
const total = summary.sum_capacity || 0;
|
||||||
|
storageTotal = formatBytes(total);
|
||||||
|
storageUsed = '0 B'; // capacity/detail doesn't return used_size
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Build full cookie string including httpOnly cookies
|
||||||
|
const cookies = await session.browserContext.cookies();
|
||||||
|
const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||||
|
|
||||||
|
// Extract __uid from cookie for duplicate detection
|
||||||
|
const uidMatch = cookieStr.match(/(?<!\\w)__uid=([a-f0-9-]+)/);
|
||||||
|
let autoUpdated = false;
|
||||||
|
let updatedConfigId: number | undefined;
|
||||||
|
|
||||||
|
if (uidMatch) {
|
||||||
|
const uid = uidMatch[1];
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.prepare(
|
||||||
|
`SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?`
|
||||||
|
).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Same account → auto-update cookie with capacity info too
|
||||||
|
const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?`
|
||||||
|
).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id);
|
||||||
|
autoUpdated = true;
|
||||||
|
updatedConfigId = existing.id;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up session after successful login
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'logged_in',
|
||||||
|
cookie: cookieStr,
|
||||||
|
nickname,
|
||||||
|
storage_used: storageUsed,
|
||||||
|
storage_total: storageTotal,
|
||||||
|
autoUpdated,
|
||||||
|
updatedConfigId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: session.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a QR login session.
|
||||||
|
*/
|
||||||
|
export async function cancelQrLogin(sessionId: string): Promise<void> {
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
}
|
||||||
27
packages/backend/src/utils/response.ts
Executable file
27
packages/backend/src/utils/response.ts
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a successful JSON response.
|
||||||
|
* Uses the standard format: { error: null, data }
|
||||||
|
*/
|
||||||
|
export function sendSuccess<T>(res: Response, data: T, status: number = 200) {
|
||||||
|
res.status(status).json({ error: null, ...(data as any) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an error JSON response.
|
||||||
|
* Uses the standard format: { error: string }
|
||||||
|
* All routes should use this for consistent frontend error handling.
|
||||||
|
*/
|
||||||
|
export function sendError(res: Response, status: number, message: string) {
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a 500 error response from a caught exception.
|
||||||
|
* Prevents leaking stack traces in production.
|
||||||
|
*/
|
||||||
|
export function sendServerError(res: Response, err: unknown, fallbackMessage: string = 'Internal server error') {
|
||||||
|
const message = err instanceof Error ? err.message : fallbackMessage;
|
||||||
|
res.status(500).json({ error: message || fallbackMessage });
|
||||||
|
}
|
||||||
116
packages/backend/src/utils/time.ts
Executable file
116
packages/backend/src/utils/time.ts
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
import { getDb } from '../database/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current timezone from DB config, with fallback.
|
||||||
|
*/
|
||||||
|
export function getTimezone(): string {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get('timezone') as any;
|
||||||
|
return row?.value || 'Asia/Shanghai';
|
||||||
|
} catch {
|
||||||
|
return 'Asia/Shanghai';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current local time as an ISO 8601 string with timezone offset.
|
||||||
|
* Example: "2026-05-02T14:32:23+08:00"
|
||||||
|
* This format is reliably parsed by JavaScript Date() in all browsers.
|
||||||
|
*/
|
||||||
|
export function localTimestamp(): string {
|
||||||
|
const tz = getTimezone();
|
||||||
|
const now = new Date();
|
||||||
|
// Format as local time string with timezone offset
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(now);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||||
|
const dateStr = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`;
|
||||||
|
// Calculate timezone offset for the configured timezone
|
||||||
|
// Use getTimezoneOffset difference between UTC and the target timezone
|
||||||
|
const utcMs = now.getTime();
|
||||||
|
const localStr = dateStr.replace('T', ' ');
|
||||||
|
// Get offset in minutes for the configured timezone
|
||||||
|
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: tz,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
});
|
||||||
|
const tzName = formatter.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || '';
|
||||||
|
// tzName is like "GMT+8" or "GMT-05:00"
|
||||||
|
let offset = '+00:00';
|
||||||
|
if (tzName) {
|
||||||
|
const match = tzName.match(/GMT([+-])(\d+)(?::(\d+))?/);
|
||||||
|
if (match) {
|
||||||
|
const sign = match[1];
|
||||||
|
const hours = match[2].padStart(2, '0');
|
||||||
|
const mins = (match[3] || '00').padStart(2, '0');
|
||||||
|
offset = `${sign}${hours}:${mins}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dateStr + offset;
|
||||||
|
} catch {
|
||||||
|
// Fallback: use UTC
|
||||||
|
return now.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns today's (or given date's) date string in the configured timezone.
|
||||||
|
* Example: "2026-05-04"
|
||||||
|
*/
|
||||||
|
export function formatLocalDate(date?: Date): string {
|
||||||
|
const tz = getTimezone();
|
||||||
|
const d = date || new Date();
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}).formatToParts(d);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
|
} catch {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape SQL LIKE wildcards (% and _) in user input to prevent unintended pattern matching.
|
||||||
|
*/
|
||||||
|
export function escapeLike(str: string): string {
|
||||||
|
return str.replace(/[%_\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a local datetime string in the configured timezone.
|
||||||
|
* Example: "2026-05-04 14:32:23" — intentionally space-separated (no T) for DB compatibility.
|
||||||
|
*/
|
||||||
|
export function formatLocalDateTime(date?: Date): string {
|
||||||
|
const tz = getTimezone();
|
||||||
|
const d = date || new Date();
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||||
|
} catch {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const da = String(d.getDate()).padStart(2, '0');
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${da} ${h}:${mi}:${s}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/backend/src/validation/bounded-pool.ts
Executable file
49
packages/backend/src/validation/bounded-pool.ts
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
export class BoundedPool {
|
||||||
|
private concurrency: number;
|
||||||
|
private running: number;
|
||||||
|
private queue: Array<() => Promise<void>>;
|
||||||
|
|
||||||
|
constructor(concurrency: number = 10) {
|
||||||
|
this.concurrency = concurrency;
|
||||||
|
this.running = 0;
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const task = async () => {
|
||||||
|
this.running++;
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
resolve(result);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
} finally {
|
||||||
|
this.running--;
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.running < this.concurrency) {
|
||||||
|
task();
|
||||||
|
} else {
|
||||||
|
this.queue.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private processQueue(): void {
|
||||||
|
while (this.running < this.concurrency && this.queue.length > 0) {
|
||||||
|
const task = this.queue.shift();
|
||||||
|
if (task) task();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get pending(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): number {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
}
|
||||||
375
packages/backend/src/validation/link-validator.service.ts
Executable file
375
packages/backend/src/validation/link-validator.service.ts
Executable file
@@ -0,0 +1,375 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import config from '../config';
|
||||||
|
import { RedisClient } from '../middleware/cache';
|
||||||
|
import { BoundedPool } from './bounded-pool';
|
||||||
|
import { BaiduDriver } from '../cloud/drivers/baidu.driver';
|
||||||
|
import { AliyunDriver } from '../cloud/drivers/aliyun.driver';
|
||||||
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
|
||||||
|
export type LinkStatus = 'valid' | 'invalid' | 'unknown';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
url: string;
|
||||||
|
status: LinkStatus;
|
||||||
|
cloudType: string;
|
||||||
|
checkedAt: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从系统配置加载自定义关键词列表(一行一条)
|
||||||
|
*/
|
||||||
|
function loadCustomKeywords(configKey: string): string[] {
|
||||||
|
try {
|
||||||
|
const rules = getSystemConfig(configKey);
|
||||||
|
if (rules) {
|
||||||
|
return rules.split('\n').map(k => k.trim()).filter(k => k.length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LinkValidator {
|
||||||
|
private cache: RedisClient;
|
||||||
|
private pool: BoundedPool;
|
||||||
|
|
||||||
|
constructor(concurrency?: number) {
|
||||||
|
this.cache = new RedisClient();
|
||||||
|
this.pool = new BoundedPool(concurrency || config.validation.concurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a single share link — PanSou only, no local fallback.
|
||||||
|
*/
|
||||||
|
async validate(url: string, cloudType: string): Promise<ValidationResult> {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
return parsed as ValidationResult;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PanSou's /api/check/links
|
||||||
|
const pansouResult = await this.validateViaPansou(url, cloudType);
|
||||||
|
if (pansouResult) {
|
||||||
|
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
|
||||||
|
// Cache definitive result
|
||||||
|
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||||
|
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
|
||||||
|
return pansouResult;
|
||||||
|
}
|
||||||
|
// PanSou returned locked/unsupported/uncertain → return unknown, no local fallback
|
||||||
|
return pansouResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanSou unreachable → return unknown
|
||||||
|
return { url, status: 'unknown' as LinkStatus, cloudType, checkedAt: new Date().toISOString(), message: '盘搜不可达' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full validation with local fallback when PanSou can't determine.
|
||||||
|
*/
|
||||||
|
async validateWithLocalFallback(url: string, cloudType: string): Promise<ValidationResult> {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
return parsed as ValidationResult;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore cache errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PanSou
|
||||||
|
const pansouResult = await this.validateViaPansou(url, cloudType);
|
||||||
|
if (pansouResult) {
|
||||||
|
if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') {
|
||||||
|
const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||||
|
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {}
|
||||||
|
return pansouResult;
|
||||||
|
}
|
||||||
|
// PanSou uncertain → fall through to local validation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to own validation
|
||||||
|
let result: ValidationResult;
|
||||||
|
|
||||||
|
switch (cloudType) {
|
||||||
|
case 'quark':
|
||||||
|
result = await this.validateQuark(url);
|
||||||
|
break;
|
||||||
|
case 'baidu':
|
||||||
|
result = await this.validateBaidu(url);
|
||||||
|
break;
|
||||||
|
case 'aliyun':
|
||||||
|
result = await this.validateAliyun(url);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result = await this.validateByHtml(url, cloudType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = result.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid;
|
||||||
|
try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(result)); } catch {}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try PanSou's /api/check/links for validation.
|
||||||
|
* Returns null if PanSou is unreachable.
|
||||||
|
*
|
||||||
|
* Judgment order:
|
||||||
|
* 1. summary "链接有效" → valid (PanSou's own OK signal)
|
||||||
|
* 2. summary 含自定义确认关键词 → valid (from DB link_valid_keywords)
|
||||||
|
* 3. summary 含自定义失效关键词 → invalid (from DB link_invalid_keywords)
|
||||||
|
* 4. 其他 → unknown
|
||||||
|
*/
|
||||||
|
private async validateViaPansou(url: string, cloudType: string): Promise<ValidationResult | null> {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const pansouApiUrl = `${config.pansouUrl}/api/check/links`;
|
||||||
|
const response = await fetch(pansouApiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: [{ disk_type: cloudType, url }],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
const pansouResult = data.results?.[0];
|
||||||
|
if (!pansouResult) return null;
|
||||||
|
|
||||||
|
const summary = pansouResult.summary || '';
|
||||||
|
|
||||||
|
// 1. PanSou 明确返回"链接有效"
|
||||||
|
if (summary.includes('链接有效')) {
|
||||||
|
return { url, status: 'valid', cloudType, checkedAt, message: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 自定义确认关键词(用户配置的"有效"信号)
|
||||||
|
const validKeywords = loadCustomKeywords('link_valid_keywords');
|
||||||
|
if (validKeywords.some(kw => summary.includes(kw))) {
|
||||||
|
return { url, status: 'valid', cloudType, checkedAt, message: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 自定义失效关键词(用户配置的"失效"信号)
|
||||||
|
const invalidKeywords = loadCustomKeywords('link_invalid_keywords');
|
||||||
|
if (invalidKeywords.some(kw => summary.includes(kw))) {
|
||||||
|
return { url, status: 'invalid', cloudType, checkedAt, message: summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 其余全部返回 unknown
|
||||||
|
return { url, status: 'unknown', cloudType, checkedAt, message: summary || '盘搜无法确认' };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Quark share link using the public share token API.
|
||||||
|
*/
|
||||||
|
private async validateQuark(url: string): Promise<ValidationResult> {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleanUrl = url.split('#')[0];
|
||||||
|
const urlObj = new URL(cleanUrl);
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
const shareToken = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
|
||||||
|
|
||||||
|
if (!shareToken) {
|
||||||
|
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: '无法解析分享链接 token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc';
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Origin': 'https://pan.quark.cn',
|
||||||
|
'Referer': 'https://pan.quark.cn/',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ pwd_id: shareToken, passcode: '' }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = response.status === 403 ? '分享已过期或需要密码' : `HTTP ${response.status}`;
|
||||||
|
return { url, status: 'invalid', cloudType: 'quark', checkedAt, message: msg };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.status === 200 && data.data?.stoken) {
|
||||||
|
const title = data.data?.title || '';
|
||||||
|
const author = data.data?.author?.nick_name || '';
|
||||||
|
const expiredAt = data.data?.expired_at || 0;
|
||||||
|
const expireDate = expiredAt > 0 ? new Date(expiredAt).toISOString().slice(0, 10) : '';
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: 'valid',
|
||||||
|
cloudType: 'quark',
|
||||||
|
checkedAt,
|
||||||
|
message: expireDate ? `有效链接,过期时间: ${expireDate}` : '有效链接',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 返回了 200 但无 stoken — 可能是临时异常,保守判 unknown
|
||||||
|
return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: 'API 返回异常(无 stoken),不做失效判定' };
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: 'unknown',
|
||||||
|
cloudType: 'quark',
|
||||||
|
checkedAt,
|
||||||
|
message: `校验异常: ${err.message?.slice(0, 50) || '未知错误'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateBaidu(url: string): Promise<ValidationResult> {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const driver = new BaiduDriver();
|
||||||
|
const result = await driver.validateShareLink(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: result.valid ? 'valid' : 'invalid',
|
||||||
|
cloudType: 'baidu',
|
||||||
|
checkedAt,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: 'unknown',
|
||||||
|
cloudType: 'baidu',
|
||||||
|
checkedAt,
|
||||||
|
message: `校验失败: ${err.message || err}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateAliyun(url: string): Promise<ValidationResult> {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const driver = new AliyunDriver();
|
||||||
|
const result = await driver.validateShareLink(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: result.valid ? 'valid' : 'invalid',
|
||||||
|
cloudType: 'aliyun',
|
||||||
|
checkedAt,
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
status: 'unknown',
|
||||||
|
cloudType: 'aliyun',
|
||||||
|
checkedAt,
|
||||||
|
message: `校验失败: ${err.message || err}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: validate by fetching the share page as HTML and checking for
|
||||||
|
* custom failure keywords from DB config. Used for providers without a
|
||||||
|
* dedicated API (115, tianyi, 123pan, etc.).
|
||||||
|
*/
|
||||||
|
private async validateByHtml(url: string, cloudType: string): Promise<ValidationResult> {
|
||||||
|
let status: LinkStatus = 'valid';
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), config.validation.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal as any,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const keywords = loadCustomKeywords('link_invalid_keywords');
|
||||||
|
|
||||||
|
const isHttpError = response.status >= 400;
|
||||||
|
if (isHttpError) {
|
||||||
|
status = 'invalid';
|
||||||
|
message = `HTTP ${response.status} ${response.statusText}`;
|
||||||
|
} else {
|
||||||
|
const matched = keywords.find(kw => text.includes(kw));
|
||||||
|
if (matched) {
|
||||||
|
status = 'invalid';
|
||||||
|
message = `页面包含自定义失效关键词: "${matched}"`;
|
||||||
|
} else {
|
||||||
|
message = 'HTML 页面可访问,未检测到失效关键词';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// On timeout or network error, conservatively mark as valid
|
||||||
|
status = 'valid';
|
||||||
|
message = `网络校验超时,保守标记为有效`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, status, cloudType, checkedAt, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch validate multiple links with bounded concurrency.
|
||||||
|
*/
|
||||||
|
async validateBatch(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
|
||||||
|
const tasks = urls.map(item => () => this.validate(item.url, item.cloudType));
|
||||||
|
const results: ValidationResult[] = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
try {
|
||||||
|
const result = await this.pool.run(task);
|
||||||
|
results.push(result);
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
url: '',
|
||||||
|
status: 'unknown',
|
||||||
|
cloudType: '',
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
message: '校验执行异常',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateBatchWithPool(urls: Array<{ url: string; cloudType: string }>): Promise<ValidationResult[]> {
|
||||||
|
return this.validateBatch(urls);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/backend/src/version.ts
Normal file
12
packages/backend/src/version.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* CloudSearch 应用版本号
|
||||||
|
*
|
||||||
|
* 版本管理规则:
|
||||||
|
* - 每次小优化/修复:patch +1 (0.0.1 → 0.0.2)
|
||||||
|
* - 20 次 patch 后:minor +1, patch 归零 (0.1.0)
|
||||||
|
* - 10 次 minor 后:major +1, minor 归零 (1.0.0)
|
||||||
|
*
|
||||||
|
* 修改此文件的同时请同步更新后端 package.json 中的 version 字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const APP_VERSION = "0.0.2";
|
||||||
37
packages/backend/src/video/video.service.ts
Executable file
37
packages/backend/src/video/video.service.ts
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
// Native fetch available in Node 20+
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export interface VideoInfo {
|
||||||
|
title: string;
|
||||||
|
coverUrl: string;
|
||||||
|
videoUrl: string;
|
||||||
|
author: string;
|
||||||
|
platform: string;
|
||||||
|
duration?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseVideo(url: string): Promise<VideoInfo> {
|
||||||
|
const apiUrl = `${config.videoParserUrl}/parse`;
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Video parser API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
coverUrl: data.coverUrl || data.cover || '',
|
||||||
|
videoUrl: data.videoUrl || data.url || data.video || '',
|
||||||
|
author: data.author || data.nickname || '',
|
||||||
|
platform: data.platform || '',
|
||||||
|
duration: data.duration || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
19
packages/backend/tsconfig.json
Executable file
19
packages/backend/tsconfig.json
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
351
packages/frontend/HomePage.vue
Executable file
351
packages/frontend/HomePage.vue
Executable file
@@ -0,0 +1,351 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<div class="hero-section">
|
||||||
|
<template v-if="configLoaded">
|
||||||
|
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
|
||||||
|
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
|
||||||
|
</template>
|
||||||
|
<div class="search-box">
|
||||||
|
<el-input
|
||||||
|
v-model="query"
|
||||||
|
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
|
||||||
|
size="large"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
|
||||||
|
搜 索
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="quote-section" v-if="currentQuote">
|
||||||
|
<span class="quote-text">「 {{ currentQuote }} 」</span>
|
||||||
|
<span class="quote-author">---{{ quoteAuthor }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-section">
|
||||||
|
<PromotionBanner v-if="homeBanners.length > 0" :promotions="homeBanners" />
|
||||||
|
|
||||||
|
<div v-if="categories.length > 0" class="rankings-grid">
|
||||||
|
<div
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.category"
|
||||||
|
class="rank-panel"
|
||||||
|
>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
|
||||||
|
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in visibleItems(cat)"
|
||||||
|
:key="cat.category + '-' + idx"
|
||||||
|
class="rank-item"
|
||||||
|
@click="searchTag(item.keyword)"
|
||||||
|
>
|
||||||
|
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
|
||||||
|
<span class="rank-name">{{ item.keyword }}</span>
|
||||||
|
<span class="rank-cnt" v-if="item.rating">⭐{{ item.rating }}</span>
|
||||||
|
<span class="rank-cnt" v-else>{{ item.searchCount }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 展开按钮 -->
|
||||||
|
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
|
||||||
|
展开全部 ▼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-footer">
|
||||||
|
<span v-if="cat.category !== 'hotsite'">数据来源:TMDB</span>
|
||||||
|
<span v-else>本站搜索数据</span>
|
||||||
|
<span class="footer-time">{{ fetchedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="siteDisclaimer" class="site-footer">
|
||||||
|
<div class="footer-inner">{{ siteDisclaimer }}</div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import PromotionBanner from '../components/PromotionBanner.vue'
|
||||||
|
import { getCategorizedRankings, getPromotions, getSiteConfig } from '../api'
|
||||||
|
import type { Promotion } from '../types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const categories = ref<any[]>([])
|
||||||
|
const expanded = reactive<Record<string, boolean>>({})
|
||||||
|
const activeTab = reactive<Record<string, string>>({})
|
||||||
|
const homeBanners = ref<Promotion[]>([])
|
||||||
|
const siteLogo = ref('')
|
||||||
|
const siteName = ref('')
|
||||||
|
const siteDisclaimer = ref('')
|
||||||
|
const configLoaded = ref(false)
|
||||||
|
const currentQuote = ref('')
|
||||||
|
const quoteAuthor = ref('')
|
||||||
|
const fetchedAt = ref('')
|
||||||
|
|
||||||
|
const INITIAL_SHOW = 8
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
|
movie: '🎬', tv: '📺', western_movie: '🎥', western: '🌍',
|
||||||
|
donghua: '🐉', global_anime: '🌐',
|
||||||
|
domestic_variety: '🎤', global_variety: '🎭',
|
||||||
|
niche: '💎', hotsite: '🏆',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryIcon(cat: string): string {
|
||||||
|
return CATEGORY_ICONS[cat] || '📋'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItems(cat: any): any[] {
|
||||||
|
const tab = activeTab[cat.category] || 'hot'
|
||||||
|
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems(cat: any): any[] {
|
||||||
|
const items = getItems(cat)
|
||||||
|
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMoreItems(cat: any): boolean {
|
||||||
|
const items = getItems(cat)
|
||||||
|
return items.length > INITIAL_SHOW && !expanded[cat.category]
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandCategory(category: string) {
|
||||||
|
expanded[category] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(category: string, tab: string) {
|
||||||
|
activeTab[category] = tab
|
||||||
|
// 切换标签时收起展开
|
||||||
|
expanded[category] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisclaimer() {
|
||||||
|
window.open('/disclaimer/', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 一言 API
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://v1.hitokoto.cn/')
|
||||||
|
const data = await res.json()
|
||||||
|
currentQuote.value = data.hitokoto || ''
|
||||||
|
quoteAuthor.value = data.from_who || data.from || ''
|
||||||
|
} catch {
|
||||||
|
currentQuote.value = '学而时习之,不亦说乎。'
|
||||||
|
quoteAuthor.value = '孔子'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [catsData, promos, siteCfg] = await Promise.all([
|
||||||
|
getCategorizedRankings(),
|
||||||
|
getPromotions(),
|
||||||
|
getSiteConfig(),
|
||||||
|
])
|
||||||
|
// 新格式: { fetchedAt, categories }
|
||||||
|
if (catsData.fetchedAt) {
|
||||||
|
fetchedAt.value = catsData.fetchedAt
|
||||||
|
categories.value = catsData.categories || []
|
||||||
|
} else {
|
||||||
|
// 兼容旧格式
|
||||||
|
categories.value = Array.isArray(catsData) ? catsData : []
|
||||||
|
}
|
||||||
|
for (const cat of categories.value) {
|
||||||
|
activeTab[cat.category] = 'hot'
|
||||||
|
expanded[cat.category] = false
|
||||||
|
}
|
||||||
|
homeBanners.value = promos.filter((p) => p.position === 'home_banner' && p.active)
|
||||||
|
if (siteCfg.site_logo) {
|
||||||
|
siteLogo.value = siteCfg.site_logo
|
||||||
|
}
|
||||||
|
if (siteCfg.site_name) {
|
||||||
|
siteName.value = siteCfg.site_name
|
||||||
|
}
|
||||||
|
if (siteCfg.site_disclaimer) {
|
||||||
|
siteDisclaimer.value = siteCfg.site_disclaimer
|
||||||
|
}
|
||||||
|
configLoaded.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载首页数据失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (q) router.push('/search?q=' + encodeURIComponent(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTag(tag: string) {
|
||||||
|
router.push('/search?q=' + encodeURIComponent(tag))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
padding: 60px 24px 40px;
|
||||||
|
}
|
||||||
|
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
|
||||||
|
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%; max-width: 640px;
|
||||||
|
border: 1px solid #dfe1e5;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: box-shadow .2s, border-color .2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.search-box:focus-within {
|
||||||
|
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||||
|
border-color: rgba(223,225,229,0);
|
||||||
|
}
|
||||||
|
.search-box :deep(.el-input__wrapper) {
|
||||||
|
border: none; box-shadow: none; background: transparent;
|
||||||
|
padding: 4px 20px; border-radius: 0;
|
||||||
|
}
|
||||||
|
.search-box :deep(.el-input__inner) {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.search-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px;
|
||||||
|
margin: 4px;
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
background: var(--primary-color); color: #fff;
|
||||||
|
cursor: pointer; transition: all .2s;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.search-btn:hover {
|
||||||
|
background: #3a7be0;
|
||||||
|
}
|
||||||
|
.search-btn:active {
|
||||||
|
background: #2d6ccf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
|
||||||
|
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
|
||||||
|
|
||||||
|
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
|
||||||
|
|
||||||
|
.rankings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel {
|
||||||
|
background: var(--bg-white,#fff);
|
||||||
|
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
|
||||||
|
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
|
||||||
|
.panel-tab {
|
||||||
|
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
|
||||||
|
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
|
||||||
|
}
|
||||||
|
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
|
||||||
|
.rank-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
|
||||||
|
border-radius: 6px; cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.rank-item:hover { background: #f0f5ff; }
|
||||||
|
.rank-item:active { background: #e6f0ff; }
|
||||||
|
|
||||||
|
.rank-idx {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 12px; font-weight: 700;
|
||||||
|
color: #909399; background: #f0f0f0; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
|
||||||
|
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.rank-expand {
|
||||||
|
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
|
||||||
|
color: var(--primary-color); cursor: pointer; border-radius: 6px;
|
||||||
|
transition: background .15s; user-select: none;
|
||||||
|
}
|
||||||
|
.rank-expand:hover { background: #ecf5ff; }
|
||||||
|
|
||||||
|
.panel-footer {
|
||||||
|
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-size: 11px; color: #c0c4cc;
|
||||||
|
}
|
||||||
|
.footer-time { font-family: monospace; font-size: 10px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero-section { padding: 36px 16px 24px; }
|
||||||
|
.logo-text { font-size: 36px; margin-bottom: 20px; }
|
||||||
|
.logo-img { max-width: 360px; max-height: 80px; margin-bottom: 20px; }
|
||||||
|
.rankings-scroll { gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 20px 16px 32px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.footer-inner {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.footer-disclaimer-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #909399 !important;
|
||||||
|
}
|
||||||
|
.footer-disclaimer-btn:hover {
|
||||||
|
color: #409eff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2209
packages/frontend/SearchResult.vue
Executable file
2209
packages/frontend/SearchResult.vue
Executable file
File diff suppressed because it is too large
Load Diff
601
packages/frontend/h5/app.js
Normal file
601
packages/frontend/h5/app.js
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
// ===== Anime keywords for categorization =====
|
||||||
|
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
|
||||||
|
|
||||||
|
// ===== Quotes =====
|
||||||
|
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
|
||||||
|
|
||||||
|
// ===== Home Page =====
|
||||||
|
function homeSearch(){
|
||||||
|
const q=document.getElementById('homeSearchInput').value.trim()
|
||||||
|
if(q)doSearchFromHome(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearchFromHome(q){
|
||||||
|
document.getElementById('homePage').style.display='none'
|
||||||
|
document.getElementById('searchView').style.display='block'
|
||||||
|
document.getElementById('searchInput').value=q
|
||||||
|
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
function renderHomePage(data){
|
||||||
|
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
|
||||||
|
// 显示 Logo(优先图片,其次文字)
|
||||||
|
var logoEl=document.getElementById("homeLogo");
|
||||||
|
var headerEl=document.getElementById("headerTitle");
|
||||||
|
if(cfg.site_logo){
|
||||||
|
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
|
||||||
|
headerEl.style.display="";
|
||||||
|
}else if(cfg.site_name){
|
||||||
|
logoEl.textContent=cfg.site_name;
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.textContent=cfg.site_name;
|
||||||
|
headerEl.style.display="";
|
||||||
|
}else{
|
||||||
|
logoEl.textContent="CloudSearch";
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.textContent="CloudSearch";
|
||||||
|
headerEl.style.display="";
|
||||||
|
}
|
||||||
|
if(cfg.site_disclaimer){
|
||||||
|
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
|
||||||
|
document.getElementById("siteFooter").style.display="block";
|
||||||
|
}
|
||||||
|
}).catch(()=>{})
|
||||||
|
const categories=data.categories||[]
|
||||||
|
const fetchedAt=data.fetchedAt||''
|
||||||
|
// Quote
|
||||||
|
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
|
||||||
|
}).catch(()=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store expanded state per category
|
||||||
|
window.__expanded=window.__expanded||{}
|
||||||
|
window.__activeTab=window.__activeTab||{}
|
||||||
|
|
||||||
|
const el=document.getElementById('homeRankings')
|
||||||
|
let html=''
|
||||||
|
for(const cat of categories){
|
||||||
|
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
|
||||||
|
const icon=icons[cat.category]||'📋'
|
||||||
|
const key=cat.category
|
||||||
|
if(!window.__activeTab[key])window.__activeTab[key]='hot'
|
||||||
|
|
||||||
|
html+='<div class="rank-block">'
|
||||||
|
html+='<div class="rank-block-hdr">'+
|
||||||
|
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
|
||||||
|
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
|
||||||
|
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
|
||||||
|
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'
|
||||||
|
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||||
|
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||||
|
html+=renderRankItems(items,key,false)
|
||||||
|
html+='</div>'
|
||||||
|
// 数据来源
|
||||||
|
html+='<div class="rank-block-ftr">'+
|
||||||
|
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||||
|
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||||
|
'</div></div>'
|
||||||
|
}
|
||||||
|
el.innerHTML=html
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRankItems(items,key,expanded){
|
||||||
|
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||||
|
const limit=3
|
||||||
|
const show=expanded?items.length:Math.min(limit,items.length)
|
||||||
|
let html=items.slice(0,show).map((item,i)=>{
|
||||||
|
const c=i<3?' rank-idx top3':' rank-idx'
|
||||||
|
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||||
|
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||||
|
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||||
|
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||||
|
'</div>'
|
||||||
|
}).join('')
|
||||||
|
if(items.length>limit&&!expanded){
|
||||||
|
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandRank(key){
|
||||||
|
const container=document.getElementById('ritems-'+key)
|
||||||
|
if(!container)return
|
||||||
|
const tab=window.__activeTab[key]||'hot'
|
||||||
|
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||||
|
container.innerHTML=renderRankItems(data.items,key,true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchRankTab(category,tab){
|
||||||
|
window.__activeTab[category]=tab
|
||||||
|
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||||
|
if(tabsContainer){
|
||||||
|
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||||
|
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||||
|
}
|
||||||
|
const container=document.getElementById('ritems-'+category)
|
||||||
|
if(container){
|
||||||
|
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||||
|
container.innerHTML=renderRankItems(data.items,category,false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let userInfo = null
|
||||||
|
let allResults = []
|
||||||
|
let allChannels = []
|
||||||
|
let activeTab = ''
|
||||||
|
let currentSaveItem = null
|
||||||
|
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||||
|
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||||
|
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||||
|
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||||
|
|
||||||
|
// ===== Fetch helpers =====
|
||||||
|
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||||
|
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||||
|
|
||||||
|
// ===== Toast =====
|
||||||
|
let toastTimer
|
||||||
|
function showToast(msg,isError){
|
||||||
|
const el=document.getElementById('toast')
|
||||||
|
el.textContent=msg
|
||||||
|
el.className='toast show'+(isError?' error':'')
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== User =====
|
||||||
|
async function checkLogin(){
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||||
|
if(res.ok){
|
||||||
|
const data=await res.json()
|
||||||
|
if(data.loggedIn){
|
||||||
|
userInfo=data
|
||||||
|
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(){
|
||||||
|
localStorage.removeItem('h5_admin_token')
|
||||||
|
userInfo=null
|
||||||
|
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||||
|
showToast('已退出')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin(){
|
||||||
|
document.getElementById('loginErr').textContent=''
|
||||||
|
document.getElementById('loginUser').value=''
|
||||||
|
document.getElementById('loginPass').value=''
|
||||||
|
document.getElementById('loginModal').style.display='block'
|
||||||
|
document.getElementById('overlay').style.display='block'
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogin(){
|
||||||
|
document.getElementById('loginModal').style.display='none'
|
||||||
|
document.getElementById('overlay').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(){
|
||||||
|
const user=document.getElementById('loginUser').value.trim()
|
||||||
|
const pass=document.getElementById('loginPass').value
|
||||||
|
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||||
|
const btn=document.getElementById('loginBtn')
|
||||||
|
btn.disabled=true;btn.textContent='登录中...'
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||||
|
if(res.ok){
|
||||||
|
const data=await res.json()
|
||||||
|
localStorage.setItem('h5_admin_token',data.token)
|
||||||
|
userInfo={username:user}
|
||||||
|
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||||
|
closeLogin()
|
||||||
|
showToast('登录成功')
|
||||||
|
}else{
|
||||||
|
const err=await res.json().catch(()=>({}))
|
||||||
|
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
document.getElementById('loginErr').textContent='网络错误'
|
||||||
|
}finally{
|
||||||
|
btn.disabled=false;btn.textContent='登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Search =====
|
||||||
|
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||||
|
|
||||||
|
let searchTimer
|
||||||
|
function doSearch(){
|
||||||
|
const q=document.getElementById('searchInput').value.trim()
|
||||||
|
if(!q)return
|
||||||
|
// Update URL
|
||||||
|
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||||
|
// Show loading
|
||||||
|
document.getElementById('results').innerHTML=''
|
||||||
|
document.getElementById('tabs').style.display='none'
|
||||||
|
document.getElementById('infoBar').style.display='none'
|
||||||
|
document.getElementById('loading').style.display='block'
|
||||||
|
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||||
|
document.getElementById('searchBtn').disabled=true
|
||||||
|
|
||||||
|
let progress=0
|
||||||
|
const bar=document.getElementById('loadingBar')
|
||||||
|
const progressTimer=setInterval(()=>{
|
||||||
|
if(progress<60)progress+=1+Math.random()*3
|
||||||
|
else if(progress<85)progress+=0.5+Math.random()
|
||||||
|
bar.style.width=progress+'%'
|
||||||
|
},200)
|
||||||
|
|
||||||
|
// Use streaming search for live updates
|
||||||
|
streamSearch(q,progressTimer,bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamSearch(q,progressTimer,bar){
|
||||||
|
const startTime=Date.now()
|
||||||
|
try{
|
||||||
|
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||||
|
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||||
|
|
||||||
|
const reader=response.body.getReader()
|
||||||
|
const decoder=new TextDecoder()
|
||||||
|
let buffer=''
|
||||||
|
let allItems=[]
|
||||||
|
let channels=[]
|
||||||
|
let totalCount=0
|
||||||
|
let filteredCount=0
|
||||||
|
|
||||||
|
while(true){
|
||||||
|
const {done,value}=await reader.read()
|
||||||
|
if(done)break
|
||||||
|
|
||||||
|
buffer+=decoder.decode(value,{stream:true})
|
||||||
|
const lines=buffer.split('\n')
|
||||||
|
buffer=lines.pop()||''
|
||||||
|
|
||||||
|
for(const line of lines){
|
||||||
|
if(!line.trim())continue
|
||||||
|
try{
|
||||||
|
const msg=JSON.parse(line)
|
||||||
|
if(msg.type==='stats'){
|
||||||
|
totalCount=msg.total||0
|
||||||
|
filteredCount=msg.filtered||0
|
||||||
|
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||||
|
}else if(msg.type==='result'){
|
||||||
|
if(msg.valid&&msg.id){
|
||||||
|
allItems.push(msg.id)
|
||||||
|
}
|
||||||
|
}else if(msg.type==='complete'){
|
||||||
|
const results=msg.results||[]
|
||||||
|
channels=msg.channels||[]
|
||||||
|
clearInterval(progressTimer)
|
||||||
|
bar.style.width='100%'
|
||||||
|
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
clearInterval(progressTimer)
|
||||||
|
document.getElementById('loading').style.display='none'
|
||||||
|
document.getElementById('searchBtn').disabled=false
|
||||||
|
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render =====
|
||||||
|
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||||
|
document.getElementById('loading').style.display='none'
|
||||||
|
document.getElementById('searchBtn').disabled=false
|
||||||
|
allResults=results
|
||||||
|
allChannels=channels||[]
|
||||||
|
|
||||||
|
// Info bar
|
||||||
|
if(totalCount>0){
|
||||||
|
document.getElementById('infoBar').style.display='flex'
|
||||||
|
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||||
|
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||||
|
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||||
|
else document.getElementById('infoFiltered').textContent=''
|
||||||
|
}else{
|
||||||
|
document.getElementById('infoBar').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tabs
|
||||||
|
const tabsEl=document.getElementById('tabs')
|
||||||
|
tabsEl.innerHTML=''
|
||||||
|
const typeCounts={}
|
||||||
|
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||||
|
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||||
|
// "全部" tab
|
||||||
|
const allTab=document.createElement('div')
|
||||||
|
allTab.className='tab active'
|
||||||
|
allTab.textContent='📋 全部 ('+results.length+')'
|
||||||
|
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||||
|
tabsEl.appendChild(allTab)
|
||||||
|
for(const ct of sorted){
|
||||||
|
const tab=document.createElement('div')
|
||||||
|
tab.className='tab'
|
||||||
|
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||||
|
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||||
|
tabsEl.appendChild(tab)
|
||||||
|
}
|
||||||
|
tabsEl.style.display=results.length>0?'flex':'none'
|
||||||
|
activeTab=''
|
||||||
|
|
||||||
|
// Render cards
|
||||||
|
renderCardList(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTab(ct){
|
||||||
|
activeTab=ct
|
||||||
|
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||||
|
const isAll=i===0&&!ct
|
||||||
|
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||||
|
t.className='tab'+(active||isAll?' active':'')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardList(items){
|
||||||
|
const el=document.getElementById('results')
|
||||||
|
if(items.length===0){
|
||||||
|
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.innerHTML=items.map((item,idx)=>{
|
||||||
|
const coverHtml=item.cover
|
||||||
|
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||||
|
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||||
|
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||||
|
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||||
|
const tags=extractTags(item.title||'')
|
||||||
|
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||||
|
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||||
|
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||||
|
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||||
|
'<div class="card-body">'+
|
||||||
|
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||||
|
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||||
|
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||||
|
'<div class="card-actions">'+
|
||||||
|
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||||
|
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
// Store items for save reference
|
||||||
|
window.__h5Results=items
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
|
||||||
|
function extractTags(title){
|
||||||
|
const tags=[]
|
||||||
|
// Quality tags
|
||||||
|
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||||
|
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||||
|
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||||
|
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||||
|
return tags.slice(0,6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||||
|
|
||||||
|
function formatTime(s){
|
||||||
|
if(!s)return ''
|
||||||
|
const d=new Date(s)
|
||||||
|
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||||
|
const diff=Date.now()-d.getTime()
|
||||||
|
if(diff<0)return s.slice(0,10)
|
||||||
|
const mins=Math.floor(diff/60000)
|
||||||
|
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||||
|
const hours=Math.floor(mins/60)
|
||||||
|
if(hours<24)return hours+' 小时前'
|
||||||
|
const days=Math.floor(hours/24)
|
||||||
|
if(days<30)return days+' 天前'
|
||||||
|
return Math.floor(days/30)+' 个月前'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Save / Share =====
|
||||||
|
function saveItem(idx){
|
||||||
|
const items=window.__h5Results||[]
|
||||||
|
currentSaveItem=items[idx]
|
||||||
|
if(!currentSaveItem)return
|
||||||
|
|
||||||
|
document.getElementById('progressSteps').style.display='block'
|
||||||
|
document.getElementById('shareContent').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='none'
|
||||||
|
document.getElementById('copyBtn2').style.display='none'
|
||||||
|
|
||||||
|
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||||
|
document.getElementById('shareTitle').textContent=title
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
document.getElementById('overlay').style.display='block'
|
||||||
|
document.getElementById('shareModal').style.display='block'
|
||||||
|
|
||||||
|
// Reset steps
|
||||||
|
resetSteps()
|
||||||
|
advanceStep(1)
|
||||||
|
|
||||||
|
// Call save API
|
||||||
|
doSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave(){
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||||
|
const data=await res.json()
|
||||||
|
|
||||||
|
if(!data.success){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
advanceStep(2)
|
||||||
|
await sleep(500)
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
advanceStep(3)
|
||||||
|
await sleep(300)
|
||||||
|
|
||||||
|
if(data.share_url){
|
||||||
|
advanceStep(4)
|
||||||
|
await sleep(200)
|
||||||
|
showShareResult(data)
|
||||||
|
}else{
|
||||||
|
advanceStep(4)
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showShareResult(data){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('shareContent').style.display='block'
|
||||||
|
|
||||||
|
const link=data.share_url
|
||||||
|
document.getElementById('shareLinkInput').value=link
|
||||||
|
|
||||||
|
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||||
|
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||||
|
|
||||||
|
// Generate QR
|
||||||
|
const qrContainer=document.getElementById('qrContainer')
|
||||||
|
qrContainer.innerHTML=''
|
||||||
|
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||||
|
|
||||||
|
// Password
|
||||||
|
const pwd=data.share_pwd||data.sharePwd||''
|
||||||
|
if(pwd){
|
||||||
|
document.getElementById('sharePwdRow').style.display='flex'
|
||||||
|
document.getElementById('sharePwdTag').textContent=pwd
|
||||||
|
}else{
|
||||||
|
document.getElementById('sharePwdRow').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('copyBtn2').style.display='inline-block'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSteps(){
|
||||||
|
for(let i=1;i<=3;i++){
|
||||||
|
const el=document.getElementById('step'+i)
|
||||||
|
el.className='step'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||||
|
el.querySelector('.step-status').textContent='等待中'
|
||||||
|
el.querySelector('.step-status').className='step-status wait'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceStep(n){
|
||||||
|
for(let i=1;i<=3;i++){
|
||||||
|
const el=document.getElementById('step'+i)
|
||||||
|
if(i<n){
|
||||||
|
el.className='step done'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||||
|
el.querySelector('.step-status').textContent='已完成'
|
||||||
|
el.querySelector('.step-status').className='step-status done'
|
||||||
|
}else if(i===n){
|
||||||
|
el.className='step active'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||||
|
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||||
|
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||||
|
el.querySelector('.step-status').textContent='进行中'
|
||||||
|
el.querySelector('.step-status').className='step-status doing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||||
|
|
||||||
|
function copyShareLink(){
|
||||||
|
const input=document.getElementById('shareLinkInput')
|
||||||
|
if(!input.value)return
|
||||||
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||||
|
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||||
|
}else{
|
||||||
|
fallbackCopy(input.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text){
|
||||||
|
const ta=document.createElement('textarea')
|
||||||
|
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisclaimer(){
|
||||||
|
window.open('/disclaimer/','_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(){
|
||||||
|
document.getElementById('overlay').style.display='none'
|
||||||
|
document.getElementById('shareModal').style.display='none'
|
||||||
|
document.getElementById('loginModal').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
checkLogin()
|
||||||
|
|
||||||
|
// Add Enter key handler for home search
|
||||||
|
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||||
|
// Also add for search view input
|
||||||
|
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||||
|
|
||||||
|
// Fetch home page data
|
||||||
|
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||||
|
renderHomePage(data)
|
||||||
|
}).catch(()=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check URL for query
|
||||||
|
const params=new URLSearchParams(window.location.search)
|
||||||
|
const q=params.get('q')
|
||||||
|
if(q){
|
||||||
|
document.getElementById('homePage').style.display='none'
|
||||||
|
document.getElementById('searchView').style.display='block'
|
||||||
|
document.getElementById('searchInput').value=q
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Dark Mode Toggle =====
|
||||||
|
(function() {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'theme-btn';
|
||||||
|
btn.title = '切换暗色模式';
|
||||||
|
var isDark = localStorage.getItem('h5_theme') === 'dark';
|
||||||
|
if (!isDark && window.matchMedia('(prefers-color-scheme: dark)').matches) isDark = true;
|
||||||
|
btn.textContent = isDark ? '☀️' : '🌙';
|
||||||
|
if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
btn.onclick = function() {
|
||||||
|
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : '');
|
||||||
|
localStorage.setItem('h5_theme', dark ? 'dark' : 'light');
|
||||||
|
btn.textContent = dark ? '☀️' : '🌙';
|
||||||
|
};
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
})();
|
||||||
160
packages/frontend/h5/index.html
Executable file
160
packages/frontend/h5/index.html
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>CloudSearch - 搜索</title>
|
||||||
|
<script>
|
||||||
|
// 替换标题为网站名称
|
||||||
|
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
|
||||||
|
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
|
||||||
|
}).catch(function(){});
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/h5/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<!-- ===== Home Page (shown when no search) ===== -->
|
||||||
|
<div id="homePage" class="home-page">
|
||||||
|
<div class="home-hero">
|
||||||
|
<div class="home-logo" id="homeLogo" style="display:none"></div>
|
||||||
|
<div class="home-search-box">
|
||||||
|
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
|
||||||
|
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-quote" id="homeQuote"></div>
|
||||||
|
<div class="home-quote-author" id="homeQuoteAuthor"></div>
|
||||||
|
</div>
|
||||||
|
<div class="home-rankings" id="homeRankings"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Search Results View ===== -->
|
||||||
|
<div id="searchView" style="display:none">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-row">
|
||||||
|
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
|
||||||
|
<button id="searchBtn" onclick="doSearch()">搜 索</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions" id="userArea">
|
||||||
|
<template id="userLoggedIn">
|
||||||
|
<span class="user-badge" id="usernameDisplay"></span>
|
||||||
|
<button class="logout-btn-small" onclick="logout()">退出</button>
|
||||||
|
</template>
|
||||||
|
<template id="userLoggedOut">
|
||||||
|
<button class="login-btn-small" onclick="showLogin()">登录</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Bar -->
|
||||||
|
<div id="infoBar" class="info-bar" style="display:none">
|
||||||
|
<span id="infoCount" class="count"></span>
|
||||||
|
<span id="infoTime" class="time"></span>
|
||||||
|
<span id="infoFiltered" class="badge-err"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="loading" class="loading" style="display:none">
|
||||||
|
<div id="loadingText">🔍 正在搜索中...</div>
|
||||||
|
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div id="tabs" class="tabs" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="results" class="results"></div>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
|
||||||
|
|
||||||
|
<!-- Share Modal -->
|
||||||
|
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||||
|
<div class="modal-hdr" id="shareTitle">分享链接</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="progressSteps" class="steps" style="display:none">
|
||||||
|
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
|
||||||
|
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
|
||||||
|
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div id="saveError" class="error-alert" style="display:none"></div>
|
||||||
|
<div id="shareContent" style="display:none">
|
||||||
|
<div class="share-qr">
|
||||||
|
<div id="qrContainer"></div>
|
||||||
|
<div class="qr-label" id="qrLabel"></div>
|
||||||
|
<div class="qr-sub">保存到你自己的网盘</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-section">
|
||||||
|
<div class="share-row">
|
||||||
|
<input id="shareLinkInput" type="text" readonly />
|
||||||
|
</div>
|
||||||
|
<div id="sharePwdRow" class="share-pwd" style="display:none">
|
||||||
|
<span>🔑 提取密码:</span>
|
||||||
|
<span class="pwd-tag" id="sharePwdTag"></span>
|
||||||
|
<span class="pwd-hint">打开链接后需输入密码</span>
|
||||||
|
</div>
|
||||||
|
<div class="share-tip">
|
||||||
|
<span class="warn-icon">⚠️</span>
|
||||||
|
<div class="tip-text">
|
||||||
|
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
|
||||||
|
<strong>转存至您的网盘,以免资源被官方和谐</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="warning-box">
|
||||||
|
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
|
||||||
|
</div>
|
||||||
|
<div class="share-disclaimer">
|
||||||
|
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-ftr">
|
||||||
|
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
|
||||||
|
<button class="btn-close" onclick="closeModal()">关闭</button>
|
||||||
|
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||||
|
<div class="modal-hdr">登录</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="login-form">
|
||||||
|
<input id="loginUser" type="text" placeholder="用户名" />
|
||||||
|
<input id="loginPass" type="password" placeholder="密码" />
|
||||||
|
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
|
||||||
|
<div class="login-err" id="loginErr"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-ftr">
|
||||||
|
<button class="btn-close" onclick="closeLogin()">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||||
|
<script src="/h5/app.js"></script>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div id="siteFooter" class="site-footer" style="display:none">
|
||||||
|
<div id="footerContent" class="footer-inner"></div>
|
||||||
|
<div class="footer-actions" id="footerActions">
|
||||||
|
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
179
packages/frontend/h5/style.css
Normal file
179
packages/frontend/h5/style.css
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/* ===== Dark Mode ===== */[data-theme="dark"] body { background:#1a1a1a;color:#e5e5e5 }[data-theme="dark"] .rank-block,[data-theme="dark"] .card,[data-theme="dark"] .modal,[data-theme="dark"] .header { background:#1f1f1f;border-color:#333 }[data-theme="dark"] input,[data-theme="dark"] .home-search-box,[data-theme="dark"] .search-wrap { background:#2a2a2a;color:#e5e5e5;border-color:#333 }[data-theme="dark"] .rank-block-title,[data-theme="dark"] .card-title,[data-theme="dark"] .rank-name,[data-theme="dark"] .modal-hdr { color:#e5e5e5 }[data-theme="dark"] .card-meta,[data-theme="dark"] .rank-cnt,[data-theme="dark"] .info-bar { color:#999 }[data-theme="dark"] .rank-tab,[data-theme="dark"] .tab { background:#333;color:#999 }[data-theme="dark"] .rank-tab.active,[data-theme="dark"] .tab.active { background:#409eff;color:#fff }[data-theme="dark"] .site-footer { background:#1a1a1a;border-color:#333;color:#999 }[data-theme="dark"] .rank-block-ftr { border-color:#333;color:#666 }[data-theme="dark"] .rank-idx { background:#333;color:#999 }[data-theme="dark"] .theme-btn { position:fixed;bottom:20px;right:20px;z-index:99;width:40px;height:40px;border-radius:50%;border:1px solid #555;background:#1f1f1f;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.3) }
|
||||||
|
/* ===== Reset & Base ===== */
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
|
||||||
|
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
|
||||||
|
a{color:var(--primary);text-decoration:none}
|
||||||
|
img{display:block;max-width:100%}
|
||||||
|
|
||||||
|
/* ===== Home Page ===== */
|
||||||
|
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
|
||||||
|
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
|
||||||
|
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
|
||||||
|
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
|
||||||
|
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||||
|
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
|
||||||
|
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||||
|
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
.home-search-box button:active{background:var(--primary-dark)}
|
||||||
|
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
|
||||||
|
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
|
||||||
|
|
||||||
|
/* ===== Home Rankings ===== */
|
||||||
|
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
|
||||||
|
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
|
||||||
|
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
|
||||||
|
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
|
||||||
|
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
|
||||||
|
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
|
||||||
|
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||||
|
.rank-item:active{background:#f0f5ff}
|
||||||
|
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
|
||||||
|
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
|
||||||
|
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
|
||||||
|
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
|
||||||
|
.rank-expand:active{background:#ecf5ff}
|
||||||
|
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
|
||||||
|
.ftr-time{font-family:monospace;font-size:9px}
|
||||||
|
|
||||||
|
/* ===== Layout ===== */
|
||||||
|
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
|
||||||
|
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
|
||||||
|
.header-row{display:flex;align-items:center;gap:10px}
|
||||||
|
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
|
||||||
|
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
|
||||||
|
.header-logo-img{max-width:120px;max-height:28px;width:auto;height:auto;object-fit:contain;display:block}
|
||||||
|
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||||
|
|
||||||
|
/* ===== Search Bar ===== */
|
||||||
|
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||||
|
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
|
||||||
|
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||||
|
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||||
|
.search-wrap button:active{background:var(--primary-dark)}
|
||||||
|
.search-wrap button:disabled{opacity:.5}
|
||||||
|
|
||||||
|
/* ===== Footer ===== */
|
||||||
|
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
|
||||||
|
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
|
||||||
|
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
|
||||||
|
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
|
||||||
|
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
|
||||||
|
|
||||||
|
/* ===== Info Bar ===== */
|
||||||
|
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
|
||||||
|
.info-bar .count{font-weight:600;color:var(--text)}
|
||||||
|
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
|
||||||
|
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
|
||||||
|
|
||||||
|
/* ===== Loading ===== */
|
||||||
|
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
|
||||||
|
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
|
||||||
|
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
|
||||||
|
|
||||||
|
/* ===== Tabs ===== */
|
||||||
|
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||||||
|
.tabs::-webkit-scrollbar{display:none}
|
||||||
|
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
|
||||||
|
.tab:active{transform:scale(.95)}
|
||||||
|
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
|
||||||
|
|
||||||
|
/* ===== Results ===== */
|
||||||
|
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
|
||||||
|
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
|
||||||
|
|
||||||
|
/* ===== Card ===== */
|
||||||
|
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
|
||||||
|
.card:active{border-color:#c0c4cc}
|
||||||
|
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
|
||||||
|
.card-cover img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
|
||||||
|
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
|
||||||
|
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
|
||||||
|
.card-meta .size{color:#67c23a}
|
||||||
|
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
|
||||||
|
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
|
||||||
|
.card-tags .quality{background:#fef0f0;color:#e74c3c}
|
||||||
|
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
||||||
|
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
|
||||||
|
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
|
||||||
|
.card-btn:active{background:var(--primary-dark)}
|
||||||
|
.card-btn:disabled{opacity:.5}
|
||||||
|
|
||||||
|
/* ===== Toast ===== */
|
||||||
|
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
|
||||||
|
.toast.show{opacity:1}
|
||||||
|
.toast.error{background:rgba(245,108,108,.9)}
|
||||||
|
|
||||||
|
/* ===== Overlay / Modal ===== */
|
||||||
|
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
|
||||||
|
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
|
||||||
|
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
|
||||||
|
.modal-body{padding:14px 16px}
|
||||||
|
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
|
||||||
|
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
|
||||||
|
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
|
||||||
|
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
|
||||||
|
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
|
||||||
|
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||||
|
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||||
|
|
||||||
|
/* ===== Share Modal ===== */
|
||||||
|
.share-section{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.share-row{display:flex;gap:8px}
|
||||||
|
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
|
||||||
|
.share-row input:focus{border-color:var(--primary)}
|
||||||
|
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
|
||||||
|
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
|
||||||
|
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
|
||||||
|
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
|
||||||
|
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
|
||||||
|
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
|
||||||
|
.share-tip .tip-text{flex:1;min-width:0}
|
||||||
|
.share-tip strong{font-weight:700}
|
||||||
|
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||||
|
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
|
||||||
|
.warning-item:nth-child(odd){color:#cf1322}
|
||||||
|
.warning-item:nth-child(even){color:#d46b08}
|
||||||
|
.warning-item:last-child{color:#b71c1c;font-size:13px}
|
||||||
|
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
|
||||||
|
.share-qr canvas{border-radius:8px}
|
||||||
|
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
|
||||||
|
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
|
||||||
|
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* ===== Login Modal ===== */
|
||||||
|
.login-form{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
|
||||||
|
.login-form input:focus{border-color:var(--primary)}
|
||||||
|
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||||
|
.login-form .login-btn:active{background:var(--primary-dark)}
|
||||||
|
.login-form .login-btn:disabled{opacity:.5}
|
||||||
|
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
|
||||||
|
|
||||||
|
/* ===== Progress Steps ===== */
|
||||||
|
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
|
||||||
|
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
|
||||||
|
.step.active{opacity:1}
|
||||||
|
.step.done{opacity:.7}
|
||||||
|
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
|
||||||
|
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
|
||||||
|
.step.done .step-dot{background:#67c23a;color:#fff}
|
||||||
|
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
|
||||||
|
.step-title{font-size:13px;color:var(--text);font-weight:500}
|
||||||
|
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
|
||||||
|
.step-status.doing{background:#ecf5ff;color:var(--primary)}
|
||||||
|
.step-status.done{background:#f0f9eb;color:#67c23a}
|
||||||
|
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
|
||||||
|
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
|
||||||
|
|
||||||
|
/* ===== User Badge ===== */
|
||||||
|
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
|
||||||
|
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
|
||||||
|
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
|
||||||
29
packages/frontend/index.html
Executable file
29
packages/frontend/index.html
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CloudSearch - 网盘资源搜索</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// 替换标题为网站名称
|
||||||
|
fetch('/api/site-config').then(r=>r.json()).then(cfg=>{
|
||||||
|
if(cfg.site_name) document.title = cfg.site_name + ' - 网盘资源搜索';
|
||||||
|
}).catch(function(){});
|
||||||
|
// 跳过:参数 ?desktop=1 强制使用桌面版
|
||||||
|
if (window.location.search.includes('desktop=1')) return;
|
||||||
|
var ua = navigator.userAgent;
|
||||||
|
var isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
||||||
|
var isTablet = /iPad|Android(?!.*Mobile)/i.test(ua);
|
||||||
|
if (isMobile || isTablet) {
|
||||||
|
window.location.replace(window.location.origin + '/h5');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
packages/frontend/nginx.conf
Executable file
36
packages/frontend/nginx.conf
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 前端路由(SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# H5 手机版
|
||||||
|
location /h5 {
|
||||||
|
alias /usr/share/nginx/html/h5;
|
||||||
|
try_files $uri $uri/ /h5/index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理到后端
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location /assets {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
2123
packages/frontend/package-lock.json
generated
Executable file
2123
packages/frontend/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
29
packages/frontend/package.json
Executable file
29
packages/frontend/package.json
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "cloudsearch-frontend",
|
||||||
|
"version": "1.1.8",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"element-plus": "^2.8.0",
|
||||||
|
"pinia": "^2.2.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue-router": "^4.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vue-tsc": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
packages/frontend/public/disclaimer/index.html
Executable file
72
packages/frontend/public/disclaimer/index.html
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>免责声明 - 资源分享</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
border-bottom: 2px solid #e8e8e8;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
h2 { font-size: 16px; margin: 24px 0 10px; color: #1a1a2e; }
|
||||||
|
h3 { font-size: 15px; margin: 18px 0 8px; color: #303133; }
|
||||||
|
p { margin: 8px 0; text-indent: 2em; font-size: 14px; }
|
||||||
|
.highlight { background: #fff3cd; padding: 1px 4px; border-radius: 3px; }
|
||||||
|
.footer { margin-top: 30px; padding-top: 16px; border-top: 1px solid #e8e8e8; text-align: center; font-size: 12px; color: #999; }
|
||||||
|
.back-link { display: inline-block; margin-top: 20px; color: #409eff; text-decoration: none; font-size: 14px; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📜 网站免责声明</h1>
|
||||||
|
|
||||||
|
<h2>一、版权与资源声明</h2>
|
||||||
|
<p>本网站(<a href="https://hk-zy.timaa.cn" target="_blank">hk-zy.timaa.cn</a>)是一个基于开源项目搭建的<strong>非盈利性个人站点</strong>,旨在分享与交流技术资源。本网站所有资源均收集整理自互联网,其版权、著作权均归原作者或发行公司所有。本网站不对资源的版权归属进行实质审查,对于任何由资源本身引发的版权争议概不负责。</p>
|
||||||
|
|
||||||
|
<h2>二、使用限制与法律责任</h2>
|
||||||
|
<p>用户在本网站下载的所有软件、资料等资源,仅供<strong>个人学习、研究、技术交流</strong>,严禁用于任何商业或非法用途。用户必须在下载后的<strong class="highlight">24小时内</strong>,从个人电脑及存储设备中彻底删除相关内容。如用户喜欢该程序或内容,请支持正版,到官方网站购买注册。</p>
|
||||||
|
<p>因用户不当使用(包括但不限于商业使用、非法传播、破解侵权)而引发的一切法律纠纷及后果,由用户<strong>自行承担</strong>,本网站及网站管理者不承担任何连带责任。</p>
|
||||||
|
|
||||||
|
<h2>三、"避风港原则"与侵权处理</h2>
|
||||||
|
<p>依据《信息网络传播权保护条例》,本网站仅提供信息存储空间服务或资源索引服务。若用户上传或分享的内容侵犯了您的合法权益,请您立即通过以下联系方式与我们交涉。</p>
|
||||||
|
<p><strong>联系方式:</strong> 3337598077@qq.com</p>
|
||||||
|
<p><strong>处理措施:</strong> 我们在收到权利人发出的合格通知(包括权属证明和侵权链接)后,将在合理期限内对涉嫌侵权内容进行核实、断开链接或直接删除。</p>
|
||||||
|
<p><strong>唯一目的:</strong> 本网站为纯公益、非盈利性分享,绝无意侵害任何第三方权益。若内容涉及侵权,实属无意,请版权方及时通知以便我们处理。</p>
|
||||||
|
|
||||||
|
<h2>四、用户行为与网站免责</h2>
|
||||||
|
<p>访问者在本网站进行下载、浏览时,须自行承担风险。本网站不保证资源完全无毒、无缺陷或绝对安全,对于因使用本站资源而造成的硬件损坏、数据丢失等损失,本网站不负任何责任。</p>
|
||||||
|
<p>本站内容仅代表资源提供者的个人观点,不代表本站立场。对于任何站点外部链接的真实性、合法性,本站不承担担保责任。</p>
|
||||||
|
<p>凡以任何方式登陆本网站或直接、间接使用本网站资源者,视为自愿接受本网站免责声明的约束。</p>
|
||||||
|
|
||||||
|
<h2>五、法律适用</h2>
|
||||||
|
<p>本声明未涉及的问题参见国家有关法律法规。当本声明与国家法律法规冲突时,以国家法律法规为准。本网站保留对本声明的最终解释权。</p>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p style="text-indent:0">更新日期:2026年5月 · 版本 V1.0</p>
|
||||||
|
<a class="back-link" href="/" onclick="history.length > 1 ? history.back() : (location.href='/'); return false;">← 返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
923
packages/frontend/public/h5/index.html
Executable file
923
packages/frontend/public/h5/index.html
Executable file
@@ -0,0 +1,923 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<title>CloudSearch - 搜索</title>
|
||||||
|
<script>
|
||||||
|
// 替换标题为网站名称
|
||||||
|
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){
|
||||||
|
if(cfg.site_name) document.title = cfg.site_name + ' - 搜索';
|
||||||
|
}).catch(function(){});
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ===== Reset & Base ===== */
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{font-size:16px;-webkit-text-size-adjust:100%}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden}
|
||||||
|
:root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)}
|
||||||
|
a{color:var(--primary);text-decoration:none}
|
||||||
|
img{display:block;max-width:100%}
|
||||||
|
|
||||||
|
/* ===== Home Page ===== */
|
||||||
|
.home-page{padding-bottom:calc(30px + var(--safe-bottom))}
|
||||||
|
.home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px}
|
||||||
|
.home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center}
|
||||||
|
.home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain}
|
||||||
|
.home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||||
|
.home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)}
|
||||||
|
.home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||||
|
.home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
.home-search-box button:active{background:var(--primary-dark)}
|
||||||
|
.home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5}
|
||||||
|
.home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px}
|
||||||
|
|
||||||
|
/* ===== Home Rankings ===== */
|
||||||
|
.home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)}
|
||||||
|
.rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px}
|
||||||
|
.rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap}
|
||||||
|
.rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px}
|
||||||
|
.rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none}
|
||||||
|
.rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
|
||||||
|
.rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s}
|
||||||
|
.rank-item:active{background:#f0f5ff}
|
||||||
|
.rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0}
|
||||||
|
.rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px}
|
||||||
|
.rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0}
|
||||||
|
.rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none}
|
||||||
|
.rank-expand:active{background:#ecf5ff}
|
||||||
|
.rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc}
|
||||||
|
.ftr-time{font-family:monospace;font-size:9px}
|
||||||
|
|
||||||
|
/* ===== Layout ===== */
|
||||||
|
.app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))}
|
||||||
|
.header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px}
|
||||||
|
.header-row{display:flex;align-items:center;gap:10px}
|
||||||
|
.header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0}
|
||||||
|
.header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center}
|
||||||
|
.header-logo-img{max-width:160px;max-height:36px;width:auto;height:auto;object-fit:contain;display:block}
|
||||||
|
.header-actions{margin-left:auto;display:flex;gap:6px;align-items:center}
|
||||||
|
|
||||||
|
/* ===== Search Bar ===== */
|
||||||
|
.search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s}
|
||||||
|
.search-wrap:focus-within{border-color:var(--primary);background:var(--white)}
|
||||||
|
.search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent}
|
||||||
|
.search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||||
|
.search-wrap button:active{background:var(--primary-dark)}
|
||||||
|
.search-wrap button:disabled{opacity:.5}
|
||||||
|
|
||||||
|
/* ===== Footer ===== */
|
||||||
|
.site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)}
|
||||||
|
.footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line}
|
||||||
|
.footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap}
|
||||||
|
.footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s}
|
||||||
|
.footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)}
|
||||||
|
|
||||||
|
/* ===== Info Bar ===== */
|
||||||
|
.info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap}
|
||||||
|
.info-bar .count{font-weight:600;color:var(--text)}
|
||||||
|
.info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px}
|
||||||
|
.info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px}
|
||||||
|
|
||||||
|
/* ===== Loading ===== */
|
||||||
|
.loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)}
|
||||||
|
.loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px}
|
||||||
|
.loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%}
|
||||||
|
|
||||||
|
/* ===== Tabs ===== */
|
||||||
|
.tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||||||
|
.tabs::-webkit-scrollbar{display:none}
|
||||||
|
.tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none}
|
||||||
|
.tab:active{transform:scale(.95)}
|
||||||
|
.tab.active{background:var(--primary-light);color:var(--primary);font-weight:600}
|
||||||
|
|
||||||
|
/* ===== Results ===== */
|
||||||
|
.results{display:flex;flex-direction:column;gap:10px;padding:8px 12px}
|
||||||
|
.empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px}
|
||||||
|
|
||||||
|
/* ===== Card ===== */
|
||||||
|
.card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s}
|
||||||
|
.card:active{border-color:#c0c4cc}
|
||||||
|
.card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative}
|
||||||
|
.card-cover img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)}
|
||||||
|
.card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)}
|
||||||
|
.card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}
|
||||||
|
.card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px}
|
||||||
|
.card-meta .size{color:#67c23a}
|
||||||
|
.card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
|
||||||
|
.card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap}
|
||||||
|
.card-tags .quality{background:#fef0f0;color:#e74c3c}
|
||||||
|
.card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
||||||
|
.card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
|
||||||
|
.card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s}
|
||||||
|
.card-btn:active{background:var(--primary-dark)}
|
||||||
|
.card-btn:disabled{opacity:.5}
|
||||||
|
|
||||||
|
/* ===== Toast ===== */
|
||||||
|
.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s}
|
||||||
|
.toast.show{opacity:1}
|
||||||
|
.toast.error{background:rgba(245,108,108,.9)}
|
||||||
|
|
||||||
|
/* ===== Overlay / Modal ===== */
|
||||||
|
.overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s}
|
||||||
|
.modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s}
|
||||||
|
.modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)}
|
||||||
|
.modal-body{padding:14px 16px}
|
||||||
|
.modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
|
||||||
|
.modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
.modal-ftr .btn-close{background:#f4f4f5;color:#606266}
|
||||||
|
.modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto}
|
||||||
|
.modal-ftr .btn-primary{background:var(--primary);color:var(--white)}
|
||||||
|
.modal-ftr .btn-primary:active{background:var(--primary-dark)}
|
||||||
|
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||||
|
@keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
|
||||||
|
|
||||||
|
/* ===== Share Modal ===== */
|
||||||
|
.share-section{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.share-row{display:flex;gap:8px}
|
||||||
|
.share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)}
|
||||||
|
.share-row input:focus{border-color:var(--primary)}
|
||||||
|
.share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
|
||||||
|
.share-pwd{display:flex;align-items:center;gap:6px;font-size:13px}
|
||||||
|
.share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700}
|
||||||
|
.share-pwd .pwd-hint{font-size:11px;color:var(--text2)}
|
||||||
|
.share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start}
|
||||||
|
.share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0}
|
||||||
|
.share-tip .tip-text{flex:1;min-width:0}
|
||||||
|
.share-tip strong{font-weight:700}
|
||||||
|
.warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||||
|
.warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap}
|
||||||
|
.warning-item:nth-child(odd){color:#cf1322}
|
||||||
|
.warning-item:nth-child(even){color:#d46b08}
|
||||||
|
.warning-item:last-child{color:#b71c1c;font-size:13px}
|
||||||
|
.share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px}
|
||||||
|
.share-qr canvas{border-radius:8px}
|
||||||
|
.share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)}
|
||||||
|
.share-qr .qr-sub{font-size:11px;color:var(--text2)}
|
||||||
|
.share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* ===== Login Modal ===== */
|
||||||
|
.login-form{display:flex;flex-direction:column;gap:12px}
|
||||||
|
.login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none}
|
||||||
|
.login-form input:focus{border-color:var(--primary)}
|
||||||
|
.login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||||
|
.login-form .login-btn:active{background:var(--primary-dark)}
|
||||||
|
.login-form .login-btn:disabled{opacity:.5}
|
||||||
|
.login-form .login-err{font-size:12px;color:#f56c6c;text-align:center}
|
||||||
|
|
||||||
|
/* ===== Progress Steps ===== */
|
||||||
|
.steps{display:flex;flex-direction:column;gap:10px;padding:8px 0}
|
||||||
|
.step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s}
|
||||||
|
.step.active{opacity:1}
|
||||||
|
.step.done{opacity:.7}
|
||||||
|
.step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399}
|
||||||
|
.step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)}
|
||||||
|
.step.done .step-dot{background:#67c23a;color:#fff}
|
||||||
|
.step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px}
|
||||||
|
.step-title{font-size:13px;color:var(--text);font-weight:500}
|
||||||
|
.step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap}
|
||||||
|
.step-status.doing{background:#ecf5ff;color:var(--primary)}
|
||||||
|
.step-status.done{background:#f0f9eb;color:#67c23a}
|
||||||
|
.step-status.wait{background:#f4f4f5;color:#c0c4cc}
|
||||||
|
.error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c}
|
||||||
|
|
||||||
|
/* ===== User Badge ===== */
|
||||||
|
.user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap}
|
||||||
|
.login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer}
|
||||||
|
.logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<!-- ===== Home Page (shown when no search) ===== -->
|
||||||
|
<div id="homePage" class="home-page">
|
||||||
|
<div class="home-hero">
|
||||||
|
<div class="home-logo" id="homeLogo" style="display:none"></div>
|
||||||
|
<div class="home-search-box">
|
||||||
|
<input id="homeSearchInput" type="text" placeholder="搜索网盘资源..." />
|
||||||
|
<button id="homeSearchBtn" onclick="homeSearch()">搜 索</button>
|
||||||
|
</div>
|
||||||
|
<div class="home-quote" id="homeQuote"></div>
|
||||||
|
<div class="home-quote-author" id="homeQuoteAuthor"></div>
|
||||||
|
</div>
|
||||||
|
<div class="home-rankings" id="homeRankings"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Search Results View ===== -->
|
||||||
|
<div id="searchView" style="display:none">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-row">
|
||||||
|
<a href="/h5" class="header-title-link"><div class="header-title" id="headerTitle" style="display:none">CloudSearch</div></a>
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input id="searchInput" type="text" placeholder="搜索网盘资源..." @keydown="handleKeydown" />
|
||||||
|
<button id="searchBtn" onclick="doSearch()">搜 索</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions" id="userArea">
|
||||||
|
<template id="userLoggedIn">
|
||||||
|
<span class="user-badge" id="usernameDisplay"></span>
|
||||||
|
<button class="logout-btn-small" onclick="logout()">退出</button>
|
||||||
|
</template>
|
||||||
|
<template id="userLoggedOut">
|
||||||
|
<button class="login-btn-small" onclick="showLogin()">登录</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Bar -->
|
||||||
|
<div id="infoBar" class="info-bar" style="display:none">
|
||||||
|
<span id="infoCount" class="count"></span>
|
||||||
|
<span id="infoTime" class="time"></span>
|
||||||
|
<span id="infoFiltered" class="badge-err"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="loading" class="loading" style="display:none">
|
||||||
|
<div id="loadingText">🔍 正在搜索中...</div>
|
||||||
|
<div class="loading-bar"><div class="loading-bar-inner" id="loadingBar"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div id="tabs" class="tabs" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div id="results" class="results"></div>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="overlay" id="overlay" style="display:none" onclick="closeModal()"></div>
|
||||||
|
|
||||||
|
<!-- Share Modal -->
|
||||||
|
<div class="modal" id="shareModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||||
|
<div class="modal-hdr" id="shareTitle">分享链接</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="progressSteps" class="steps" style="display:none">
|
||||||
|
<div class="step" id="step1"><div class="step-dot"><span>1</span></div><div class="step-body"><span class="step-title">正在转存...</span><span class="step-status doing">进行中</span></div></div>
|
||||||
|
<div class="step" id="step2"><div class="step-dot"><span>2</span></div><div class="step-body"><span class="step-title">重命名文件(防和谐)...</span><span class="step-status wait">等待中</span></div></div>
|
||||||
|
<div class="step" id="step3"><div class="step-dot"><span>3</span></div><div class="step-body"><span class="step-title">生成分享链接...</span><span class="step-status wait">等待中</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div id="saveError" class="error-alert" style="display:none"></div>
|
||||||
|
<div id="shareContent" style="display:none">
|
||||||
|
<div class="share-qr">
|
||||||
|
<div id="qrContainer"></div>
|
||||||
|
<div class="qr-label" id="qrLabel"></div>
|
||||||
|
<div class="qr-sub">保存到你自己的网盘</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-section">
|
||||||
|
<div class="share-row">
|
||||||
|
<input id="shareLinkInput" type="text" readonly />
|
||||||
|
</div>
|
||||||
|
<div id="sharePwdRow" class="share-pwd" style="display:none">
|
||||||
|
<span>🔑 提取密码:</span>
|
||||||
|
<span class="pwd-tag" id="sharePwdTag"></span>
|
||||||
|
<span class="pwd-hint">打开链接后需输入密码</span>
|
||||||
|
</div>
|
||||||
|
<div class="share-tip">
|
||||||
|
<span class="warn-icon">⚠️</span>
|
||||||
|
<div class="tip-text">
|
||||||
|
<strong>请尽快复制链接到浏览器打开</strong> 或 <strong>用夸克APP扫码</strong><br>
|
||||||
|
<strong>转存至您的网盘,以免资源被官方和谐</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="warning-box">
|
||||||
|
<p class="warning-item">郑重警告一:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告二:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告三:网盘内除您所需资源外,不要打开任何不相关内容。</p>
|
||||||
|
<p class="warning-item">郑重警告四:以上警告说三遍,你还要明知故犯吗?</p>
|
||||||
|
</div>
|
||||||
|
<div class="share-disclaimer">
|
||||||
|
<span>⚠️ 本站资源仅供学习交流,请于24h内删除</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-ftr">
|
||||||
|
<button class="btn-disclaimer" onclick="openDisclaimer()">📜 免责声明</button>
|
||||||
|
<button class="btn-close" onclick="closeModal()">关闭</button>
|
||||||
|
<button class="btn-primary" id="copyBtn2" onclick="copyShareLink()" style="display:none">一键复制链接</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div class="modal" id="loginModal" style="display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:101">
|
||||||
|
<div class="modal-hdr">登录</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="login-form">
|
||||||
|
<input id="loginUser" type="text" placeholder="用户名" />
|
||||||
|
<input id="loginPass" type="password" placeholder="密码" />
|
||||||
|
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
|
||||||
|
<div class="login-err" id="loginErr"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-ftr">
|
||||||
|
<button class="btn-close" onclick="closeLogin()">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===== Anime keywords for categorization =====
|
||||||
|
const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家']
|
||||||
|
|
||||||
|
// ===== Quotes =====
|
||||||
|
const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
|
||||||
|
|
||||||
|
// ===== Home Page =====
|
||||||
|
function homeSearch(){
|
||||||
|
const q=document.getElementById('homeSearchInput').value.trim()
|
||||||
|
if(q)doSearchFromHome(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearchFromHome(q){
|
||||||
|
document.getElementById('homePage').style.display='none'
|
||||||
|
document.getElementById('searchView').style.display='block'
|
||||||
|
document.getElementById('searchInput').value=q
|
||||||
|
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
function renderHomePage(data){
|
||||||
|
fetch("/api/site-config").then(r=>r.json()).then(cfg=>{
|
||||||
|
// 显示 Logo(优先图片,其次文字)
|
||||||
|
var logoEl=document.getElementById("homeLogo");
|
||||||
|
var headerEl=document.getElementById("headerTitle");
|
||||||
|
if(cfg.site_logo){
|
||||||
|
logoEl.innerHTML='<img src="'+cfg.site_logo+'" class="home-logo-img" alt="logo" />';
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.innerHTML='<img src="'+cfg.site_logo+'" class="header-logo-img" alt="logo" />';
|
||||||
|
headerEl.style.display="";
|
||||||
|
}else if(cfg.site_name){
|
||||||
|
logoEl.textContent=cfg.site_name;
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.textContent=cfg.site_name;
|
||||||
|
headerEl.style.display="";
|
||||||
|
}else{
|
||||||
|
logoEl.textContent="CloudSearch";
|
||||||
|
logoEl.style.display="";
|
||||||
|
headerEl.textContent="CloudSearch";
|
||||||
|
headerEl.style.display="";
|
||||||
|
}
|
||||||
|
if(cfg.site_disclaimer){
|
||||||
|
document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'<br>');
|
||||||
|
document.getElementById("siteFooter").style.display="block";
|
||||||
|
}
|
||||||
|
}).catch(()=>{})
|
||||||
|
const categories=data.categories||[]
|
||||||
|
const fetchedAt=data.fetchedAt||''
|
||||||
|
// Quote
|
||||||
|
fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'')
|
||||||
|
}).catch(()=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store expanded state per category
|
||||||
|
window.__expanded=window.__expanded||{}
|
||||||
|
window.__activeTab=window.__activeTab||{}
|
||||||
|
|
||||||
|
const el=document.getElementById('homeRankings')
|
||||||
|
let html=''
|
||||||
|
for(const cat of categories){
|
||||||
|
const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'}
|
||||||
|
const icon=icons[cat.category]||'📋'
|
||||||
|
const key=cat.category
|
||||||
|
if(!window.__activeTab[key])window.__activeTab[key]='hot'
|
||||||
|
|
||||||
|
html+='<div class="rank-block">'
|
||||||
|
html+='<div class="rank-block-hdr">'+
|
||||||
|
'<span class="rank-block-title">'+icon+' '+cat.label+'</span>'+
|
||||||
|
'<div class="rank-block-tabs" id="rtabs-'+key+'">'+
|
||||||
|
'<span class="rank-tab'+(window.__activeTab[key]==='hot'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'hot\')">热榜</span>'+
|
||||||
|
'<span class="rank-tab'+(window.__activeTab[key]==='newest'?' active':'')+'" onclick="switchRankTab(\''+key+'\',\'newest\')">最新</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'
|
||||||
|
html+='<div class="rank-block-items" id="ritems-'+key+'" data-hot=\''+JSON.stringify({items:cat.hot||[]}).replace(/'/g,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||||
|
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||||
|
html+=renderRankItems(items,key,false)
|
||||||
|
html+='</div>'
|
||||||
|
// 数据来源
|
||||||
|
html+='<div class="rank-block-ftr">'+
|
||||||
|
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||||
|
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||||
|
'</div></div>'
|
||||||
|
}
|
||||||
|
el.innerHTML=html
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRankItems(items,key,expanded){
|
||||||
|
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||||
|
const limit=3
|
||||||
|
const show=expanded?items.length:Math.min(limit,items.length)
|
||||||
|
let html=items.slice(0,show).map((item,i)=>{
|
||||||
|
const c=i<3?' rank-idx top3':' rank-idx'
|
||||||
|
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||||
|
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||||
|
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||||
|
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||||
|
'</div>'
|
||||||
|
}).join('')
|
||||||
|
if(items.length>limit&&!expanded){
|
||||||
|
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||||
|
}
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandRank(key){
|
||||||
|
const container=document.getElementById('ritems-'+key)
|
||||||
|
if(!container)return
|
||||||
|
const tab=window.__activeTab[key]||'hot'
|
||||||
|
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||||
|
container.innerHTML=renderRankItems(data.items,key,true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchRankTab(category,tab){
|
||||||
|
window.__activeTab[category]=tab
|
||||||
|
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||||
|
if(tabsContainer){
|
||||||
|
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||||
|
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||||
|
}
|
||||||
|
const container=document.getElementById('ritems-'+category)
|
||||||
|
if(container){
|
||||||
|
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||||
|
container.innerHTML=renderRankItems(data.items,category,false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let userInfo = null
|
||||||
|
let allResults = []
|
||||||
|
let allChannels = []
|
||||||
|
let activeTab = ''
|
||||||
|
let currentSaveItem = null
|
||||||
|
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||||
|
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||||
|
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||||
|
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||||
|
|
||||||
|
// ===== Fetch helpers =====
|
||||||
|
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||||
|
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||||
|
|
||||||
|
// ===== Toast =====
|
||||||
|
let toastTimer
|
||||||
|
function showToast(msg,isError){
|
||||||
|
const el=document.getElementById('toast')
|
||||||
|
el.textContent=msg
|
||||||
|
el.className='toast show'+(isError?' error':'')
|
||||||
|
clearTimeout(toastTimer)
|
||||||
|
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== User =====
|
||||||
|
async function checkLogin(){
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||||
|
if(res.ok){
|
||||||
|
const data=await res.json()
|
||||||
|
if(data.loggedIn){
|
||||||
|
userInfo=data
|
||||||
|
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout(){
|
||||||
|
localStorage.removeItem('h5_admin_token')
|
||||||
|
userInfo=null
|
||||||
|
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||||
|
showToast('已退出')
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin(){
|
||||||
|
document.getElementById('loginErr').textContent=''
|
||||||
|
document.getElementById('loginUser').value=''
|
||||||
|
document.getElementById('loginPass').value=''
|
||||||
|
document.getElementById('loginModal').style.display='block'
|
||||||
|
document.getElementById('overlay').style.display='block'
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLogin(){
|
||||||
|
document.getElementById('loginModal').style.display='none'
|
||||||
|
document.getElementById('overlay').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(){
|
||||||
|
const user=document.getElementById('loginUser').value.trim()
|
||||||
|
const pass=document.getElementById('loginPass').value
|
||||||
|
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||||
|
const btn=document.getElementById('loginBtn')
|
||||||
|
btn.disabled=true;btn.textContent='登录中...'
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||||
|
if(res.ok){
|
||||||
|
const data=await res.json()
|
||||||
|
localStorage.setItem('h5_admin_token',data.token)
|
||||||
|
userInfo={username:user}
|
||||||
|
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||||
|
closeLogin()
|
||||||
|
showToast('登录成功')
|
||||||
|
}else{
|
||||||
|
const err=await res.json().catch(()=>({}))
|
||||||
|
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
document.getElementById('loginErr').textContent='网络错误'
|
||||||
|
}finally{
|
||||||
|
btn.disabled=false;btn.textContent='登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Search =====
|
||||||
|
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||||
|
|
||||||
|
let searchTimer
|
||||||
|
function doSearch(){
|
||||||
|
const q=document.getElementById('searchInput').value.trim()
|
||||||
|
if(!q)return
|
||||||
|
// Update URL
|
||||||
|
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||||
|
// Show loading
|
||||||
|
document.getElementById('results').innerHTML=''
|
||||||
|
document.getElementById('tabs').style.display='none'
|
||||||
|
document.getElementById('infoBar').style.display='none'
|
||||||
|
document.getElementById('loading').style.display='block'
|
||||||
|
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||||
|
document.getElementById('searchBtn').disabled=true
|
||||||
|
|
||||||
|
let progress=0
|
||||||
|
const bar=document.getElementById('loadingBar')
|
||||||
|
const progressTimer=setInterval(()=>{
|
||||||
|
if(progress<60)progress+=1+Math.random()*3
|
||||||
|
else if(progress<85)progress+=0.5+Math.random()
|
||||||
|
bar.style.width=progress+'%'
|
||||||
|
},200)
|
||||||
|
|
||||||
|
// Use streaming search for live updates
|
||||||
|
streamSearch(q,progressTimer,bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamSearch(q,progressTimer,bar){
|
||||||
|
const startTime=Date.now()
|
||||||
|
try{
|
||||||
|
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||||
|
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||||
|
|
||||||
|
const reader=response.body.getReader()
|
||||||
|
const decoder=new TextDecoder()
|
||||||
|
let buffer=''
|
||||||
|
let allItems=[]
|
||||||
|
let channels=[]
|
||||||
|
let totalCount=0
|
||||||
|
let filteredCount=0
|
||||||
|
|
||||||
|
while(true){
|
||||||
|
const {done,value}=await reader.read()
|
||||||
|
if(done)break
|
||||||
|
|
||||||
|
buffer+=decoder.decode(value,{stream:true})
|
||||||
|
const lines=buffer.split('\n')
|
||||||
|
buffer=lines.pop()||''
|
||||||
|
|
||||||
|
for(const line of lines){
|
||||||
|
if(!line.trim())continue
|
||||||
|
try{
|
||||||
|
const msg=JSON.parse(line)
|
||||||
|
if(msg.type==='stats'){
|
||||||
|
totalCount=msg.total||0
|
||||||
|
filteredCount=msg.filtered||0
|
||||||
|
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||||
|
}else if(msg.type==='result'){
|
||||||
|
if(msg.valid&&msg.id){
|
||||||
|
allItems.push(msg.id)
|
||||||
|
}
|
||||||
|
}else if(msg.type==='complete'){
|
||||||
|
const results=msg.results||[]
|
||||||
|
channels=msg.channels||[]
|
||||||
|
clearInterval(progressTimer)
|
||||||
|
bar.style.width='100%'
|
||||||
|
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
clearInterval(progressTimer)
|
||||||
|
document.getElementById('loading').style.display='none'
|
||||||
|
document.getElementById('searchBtn').disabled=false
|
||||||
|
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Render =====
|
||||||
|
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||||
|
document.getElementById('loading').style.display='none'
|
||||||
|
document.getElementById('searchBtn').disabled=false
|
||||||
|
allResults=results
|
||||||
|
allChannels=channels||[]
|
||||||
|
|
||||||
|
// Info bar
|
||||||
|
if(totalCount>0){
|
||||||
|
document.getElementById('infoBar').style.display='flex'
|
||||||
|
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||||
|
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||||
|
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||||
|
else document.getElementById('infoFiltered').textContent=''
|
||||||
|
}else{
|
||||||
|
document.getElementById('infoBar').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build tabs
|
||||||
|
const tabsEl=document.getElementById('tabs')
|
||||||
|
tabsEl.innerHTML=''
|
||||||
|
const typeCounts={}
|
||||||
|
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||||
|
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||||
|
// "全部" tab
|
||||||
|
const allTab=document.createElement('div')
|
||||||
|
allTab.className='tab active'
|
||||||
|
allTab.textContent='📋 全部 ('+results.length+')'
|
||||||
|
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||||
|
tabsEl.appendChild(allTab)
|
||||||
|
for(const ct of sorted){
|
||||||
|
const tab=document.createElement('div')
|
||||||
|
tab.className='tab'
|
||||||
|
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||||
|
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||||
|
tabsEl.appendChild(tab)
|
||||||
|
}
|
||||||
|
tabsEl.style.display=results.length>0?'flex':'none'
|
||||||
|
activeTab=''
|
||||||
|
|
||||||
|
// Render cards
|
||||||
|
renderCardList(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTab(ct){
|
||||||
|
activeTab=ct
|
||||||
|
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||||
|
const isAll=i===0&&!ct
|
||||||
|
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||||
|
t.className='tab'+(active||isAll?' active':'')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardList(items){
|
||||||
|
const el=document.getElementById('results')
|
||||||
|
if(items.length===0){
|
||||||
|
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.innerHTML=items.map((item,idx)=>{
|
||||||
|
const coverHtml=item.cover
|
||||||
|
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||||
|
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||||
|
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||||
|
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||||
|
const tags=extractTags(item.title||'')
|
||||||
|
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||||
|
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||||
|
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||||
|
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||||
|
'<div class="card-body">'+
|
||||||
|
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||||
|
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||||
|
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||||
|
'<div class="card-actions">'+
|
||||||
|
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||||
|
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
// Store items for save reference
|
||||||
|
window.__h5Results=items
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
|
||||||
|
function extractTags(title){
|
||||||
|
const tags=[]
|
||||||
|
// Quality tags
|
||||||
|
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||||
|
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||||
|
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||||
|
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||||
|
return tags.slice(0,6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||||
|
|
||||||
|
function formatTime(s){
|
||||||
|
if(!s)return ''
|
||||||
|
const d=new Date(s)
|
||||||
|
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||||
|
const diff=Date.now()-d.getTime()
|
||||||
|
if(diff<0)return s.slice(0,10)
|
||||||
|
const mins=Math.floor(diff/60000)
|
||||||
|
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||||
|
const hours=Math.floor(mins/60)
|
||||||
|
if(hours<24)return hours+' 小时前'
|
||||||
|
const days=Math.floor(hours/24)
|
||||||
|
if(days<30)return days+' 天前'
|
||||||
|
return Math.floor(days/30)+' 个月前'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Save / Share =====
|
||||||
|
function saveItem(idx){
|
||||||
|
const items=window.__h5Results||[]
|
||||||
|
currentSaveItem=items[idx]
|
||||||
|
if(!currentSaveItem)return
|
||||||
|
|
||||||
|
document.getElementById('progressSteps').style.display='block'
|
||||||
|
document.getElementById('shareContent').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='none'
|
||||||
|
document.getElementById('copyBtn2').style.display='none'
|
||||||
|
|
||||||
|
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||||
|
document.getElementById('shareTitle').textContent=title
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
document.getElementById('overlay').style.display='block'
|
||||||
|
document.getElementById('shareModal').style.display='block'
|
||||||
|
|
||||||
|
// Reset steps
|
||||||
|
resetSteps()
|
||||||
|
advanceStep(1)
|
||||||
|
|
||||||
|
// Call save API
|
||||||
|
doSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave(){
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||||
|
const data=await res.json()
|
||||||
|
|
||||||
|
if(!data.success){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
advanceStep(2)
|
||||||
|
await sleep(500)
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
advanceStep(3)
|
||||||
|
await sleep(300)
|
||||||
|
|
||||||
|
if(data.share_url){
|
||||||
|
advanceStep(4)
|
||||||
|
await sleep(200)
|
||||||
|
showShareResult(data)
|
||||||
|
}else{
|
||||||
|
advanceStep(4)
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('saveError').style.display='flex'
|
||||||
|
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showShareResult(data){
|
||||||
|
document.getElementById('progressSteps').style.display='none'
|
||||||
|
document.getElementById('shareContent').style.display='block'
|
||||||
|
|
||||||
|
const link=data.share_url
|
||||||
|
document.getElementById('shareLinkInput').value=link
|
||||||
|
|
||||||
|
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||||
|
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||||
|
|
||||||
|
// Generate QR
|
||||||
|
const qrContainer=document.getElementById('qrContainer')
|
||||||
|
qrContainer.innerHTML=''
|
||||||
|
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||||
|
|
||||||
|
// Password
|
||||||
|
const pwd=data.share_pwd||data.sharePwd||''
|
||||||
|
if(pwd){
|
||||||
|
document.getElementById('sharePwdRow').style.display='flex'
|
||||||
|
document.getElementById('sharePwdTag').textContent=pwd
|
||||||
|
}else{
|
||||||
|
document.getElementById('sharePwdRow').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('copyBtn2').style.display='inline-block'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSteps(){
|
||||||
|
for(let i=1;i<=3;i++){
|
||||||
|
const el=document.getElementById('step'+i)
|
||||||
|
el.className='step'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||||
|
el.querySelector('.step-status').textContent='等待中'
|
||||||
|
el.querySelector('.step-status').className='step-status wait'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceStep(n){
|
||||||
|
for(let i=1;i<=3;i++){
|
||||||
|
const el=document.getElementById('step'+i)
|
||||||
|
if(i<n){
|
||||||
|
el.className='step done'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||||
|
el.querySelector('.step-status').textContent='已完成'
|
||||||
|
el.querySelector('.step-status').className='step-status done'
|
||||||
|
}else if(i===n){
|
||||||
|
el.className='step active'
|
||||||
|
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||||
|
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||||
|
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||||
|
el.querySelector('.step-status').textContent='进行中'
|
||||||
|
el.querySelector('.step-status').className='step-status doing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||||
|
|
||||||
|
function copyShareLink(){
|
||||||
|
const input=document.getElementById('shareLinkInput')
|
||||||
|
if(!input.value)return
|
||||||
|
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||||
|
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||||
|
}else{
|
||||||
|
fallbackCopy(input.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text){
|
||||||
|
const ta=document.createElement('textarea')
|
||||||
|
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisclaimer(){
|
||||||
|
window.open('/disclaimer/','_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(){
|
||||||
|
document.getElementById('overlay').style.display='none'
|
||||||
|
document.getElementById('shareModal').style.display='none'
|
||||||
|
document.getElementById('loginModal').style.display='none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
checkLogin()
|
||||||
|
|
||||||
|
// Add Enter key handler for home search
|
||||||
|
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||||
|
// Also add for search view input
|
||||||
|
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||||
|
|
||||||
|
// Fetch home page data
|
||||||
|
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||||
|
renderHomePage(data)
|
||||||
|
}).catch(()=>{
|
||||||
|
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||||
|
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check URL for query
|
||||||
|
const params=new URLSearchParams(window.location.search)
|
||||||
|
const q=params.get('q')
|
||||||
|
if(q){
|
||||||
|
document.getElementById('homePage').style.display='none'
|
||||||
|
document.getElementById('searchView').style.display='block'
|
||||||
|
document.getElementById('searchInput').value=q
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div id="siteFooter" class="site-footer" style="display:none">
|
||||||
|
<div id="footerContent" class="footer-inner"></div>
|
||||||
|
<div class="footer-actions" id="footerActions">
|
||||||
|
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
88
packages/frontend/src/App.vue
Executable file
88
packages/frontend/src/App.vue
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
<button class="theme-toggle" @click="toggleTheme" :title="isDark ? '切换亮色' : '切换暗色'">
|
||||||
|
{{ isDark ? '☀️' : '🌙' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const isDark = ref(false)
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : '')
|
||||||
|
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('theme')
|
||||||
|
if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
isDark.value = true
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f7fa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-input: #f5f7fa;
|
||||||
|
--text: #303133;
|
||||||
|
--text2: #909399;
|
||||||
|
--text3: #c0c4cc;
|
||||||
|
--border: #e4e7ed;
|
||||||
|
--primary: #409eff;
|
||||||
|
--primary-light: rgba(64, 158, 255, 0.08);
|
||||||
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--hover: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #141414;
|
||||||
|
--bg-card: #1f1f1f;
|
||||||
|
--bg-input: #2a2a2a;
|
||||||
|
--text: #e5e5e5;
|
||||||
|
--text2: #999999;
|
||||||
|
--text3: #666666;
|
||||||
|
--border: #333333;
|
||||||
|
--primary: #409eff;
|
||||||
|
--primary-light: rgba(64, 158, 255, 0.15);
|
||||||
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
--hover: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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); }
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 99;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
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); }
|
||||||
|
</style>
|
||||||
433
packages/frontend/src/api/index.ts
Executable file
433
packages/frontend/src/api/index.ts
Executable file
@@ -0,0 +1,433 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import type {
|
||||||
|
SearchResponse,
|
||||||
|
VideoParseResult,
|
||||||
|
SaveResult,
|
||||||
|
QueryResponse,
|
||||||
|
RankingItem,
|
||||||
|
Promotion,
|
||||||
|
CloudConfig,
|
||||||
|
StatsData,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器 — 添加管理员 Token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// 响应拦截器 — 统一错误处理
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
// Don't redirect if already on the login page or if this was a login attempt itself
|
||||||
|
if (!window.location.pathname.startsWith('/admin/login') && !err.config?.url?.includes('/admin/login')) {
|
||||||
|
window.location.href = '/admin/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 搜索与解析 =====
|
||||||
|
export async function query(q: string, page = 1): Promise<QueryResponse> {
|
||||||
|
const { data } = await api.post<QueryResponse>('/query', { q, page })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式搜索 — 使用 NDJSON stream,逐条返回验证结果
|
||||||
|
* callback 接收五种事件:
|
||||||
|
* onSearching() - 搜索开始(立即返回)
|
||||||
|
* onSaved({results, total}) - 本地已保存资源(DB缓存,即时返回)
|
||||||
|
* onStats({total, channels, content_info, content_tags}) - 统计信息
|
||||||
|
* onResult(id, valid, message) - 单条链接验证结果
|
||||||
|
* onComplete({results, channels, total, filtered}) - 全部完成
|
||||||
|
*/
|
||||||
|
export async function streamSearch(
|
||||||
|
q: string,
|
||||||
|
callbacks: {
|
||||||
|
onSearching?: () => void
|
||||||
|
onSaved?: (data: { results: any[]; total: number }) => void
|
||||||
|
onStats: (stats: any) => void
|
||||||
|
onResult: (id: string, valid: boolean, message?: string) => void
|
||||||
|
onComplete: (data: any) => void
|
||||||
|
onError?: (err: any) => void
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/query', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ q }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body!.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line)
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'searching':
|
||||||
|
callbacks.onSearching?.()
|
||||||
|
break
|
||||||
|
case 'saved':
|
||||||
|
callbacks.onSaved?.(msg)
|
||||||
|
break
|
||||||
|
case 'stats':
|
||||||
|
callbacks.onStats(msg)
|
||||||
|
break
|
||||||
|
case 'result':
|
||||||
|
callbacks.onResult(msg.id, msg.valid, msg.message)
|
||||||
|
break
|
||||||
|
case 'complete':
|
||||||
|
callbacks.onComplete(msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
callbacks.onError?.(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPanSou(
|
||||||
|
kw: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20
|
||||||
|
): Promise<SearchResponse> {
|
||||||
|
const { data } = await api.get<SearchResponse>('/search', {
|
||||||
|
params: { kw, page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseVideo(url: string): Promise<VideoParseResult> {
|
||||||
|
const { data } = await api.post<VideoParseResult>('/video/parse', { url })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 保存与分享 =====
|
||||||
|
export async function saveToCloud(params: {
|
||||||
|
type: 'search' | 'video'
|
||||||
|
source: any
|
||||||
|
target_cloud: string
|
||||||
|
}): Promise<SaveResult> {
|
||||||
|
const { data } = await api.post<SaveResult>('/save', params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVideoToCloud(params: {
|
||||||
|
video_url: string
|
||||||
|
title: string
|
||||||
|
target_cloud: string
|
||||||
|
}): Promise<SaveResult> {
|
||||||
|
const { data } = await api.post<SaveResult>('/video/save-to-cloud', params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 排行榜 =====
|
||||||
|
export async function getRankings(): Promise<RankingItem[]> {
|
||||||
|
const { data } = await api.get<RankingItem[]>('/rankings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHotKeywords(): Promise<string[]> {
|
||||||
|
const { data } = await api.get<string[]>('/rankings/hot')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategorizedRankings(): Promise<any> {
|
||||||
|
const { data } = await api.get<any>('/rankings/categorized')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 管理员 =====
|
||||||
|
export async function adminLogin(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ token: string }> {
|
||||||
|
const { data } = await api.post('/admin/login', { username, password })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<{ loggedIn: boolean; id?: number; username?: string }> {
|
||||||
|
const { data } = await api.get('/me')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCloudConfigs(): Promise<CloudConfig[]> {
|
||||||
|
const { data } = await api.get('/admin/cloud-configs')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCloudConfig(
|
||||||
|
config: CloudConfig & { cookie?: string }
|
||||||
|
): Promise<CloudConfig> {
|
||||||
|
const { data } = await api.post('/admin/cloud-configs', config)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCloudConfig(
|
||||||
|
config: CloudConfig & { cookie?: string }
|
||||||
|
): Promise<CloudConfig> {
|
||||||
|
const { data } = await api.put(`/admin/cloud-configs/${config.id}`, config)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testCloudConnection(
|
||||||
|
cloudType: string,
|
||||||
|
cookie?: string,
|
||||||
|
id?: number
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
nickname?: string
|
||||||
|
storage_used?: string
|
||||||
|
storage_total?: string
|
||||||
|
}> {
|
||||||
|
const { data } = await api.post(`/admin/cloud-configs/${cloudType}/test`, { cookie, id })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dailyCheckIn(
|
||||||
|
id: number
|
||||||
|
): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
signedDays?: number
|
||||||
|
}> {
|
||||||
|
const { data } = await api.post(`/admin/cloud-configs/${id}/checkin`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function skipCheckin(id: number): Promise<boolean> {
|
||||||
|
const { data } = await api.post(`/admin/cloud-configs/${id}/skip-checkin`)
|
||||||
|
return data.success
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkinAll(): Promise<{
|
||||||
|
total: number
|
||||||
|
results: { id: number; nickname: string; success: boolean; message: string }[]
|
||||||
|
}> {
|
||||||
|
const { data } = await api.post('/admin/cloud-configs/checkin-all')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkinSummary(): Promise<{
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
pending: number
|
||||||
|
skipped: number
|
||||||
|
}> {
|
||||||
|
const { data } = await api.get('/admin/cloud-configs/checkin-summary')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCloudConfig(
|
||||||
|
id: number
|
||||||
|
): Promise<void> {
|
||||||
|
await api.delete(`/admin/cloud-configs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(days?: number): Promise<StatsData> {
|
||||||
|
const params: Record<string, number> = {}
|
||||||
|
if (days) params.days = days
|
||||||
|
const { data } = await api.get('/admin/stats', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 转存日志 =====
|
||||||
|
export async function getSaveRecords(page = 1, pageSize = 20, startDate?: string, endDate?: string, status?: string, cloud?: string, keyword?: string): Promise<{
|
||||||
|
total: number
|
||||||
|
records: SaveRecord[]
|
||||||
|
summary?: { total: number; success: number; failed: number; reused: number }
|
||||||
|
}> {
|
||||||
|
const params: Record<string, number | string> = { page, pageSize }
|
||||||
|
if (startDate) params.startDate = startDate
|
||||||
|
if (endDate) params.endDate = endDate
|
||||||
|
if (status) params.status = status
|
||||||
|
if (cloud) params.sourceType = cloud
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
const { data } = await api.get('/admin/save-records', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveRecord {
|
||||||
|
id: number
|
||||||
|
source_type: string
|
||||||
|
source_title: string | null
|
||||||
|
source_url: string
|
||||||
|
target_cloud: string
|
||||||
|
share_url: string | null
|
||||||
|
share_pwd: string | null
|
||||||
|
file_size: string | null
|
||||||
|
file_count: number
|
||||||
|
duration_ms: number
|
||||||
|
status: string
|
||||||
|
error_message: string | null
|
||||||
|
folder_name: string | null
|
||||||
|
folder_count: number
|
||||||
|
original_folder_name: string | null
|
||||||
|
ip_address: string | null
|
||||||
|
ip_location: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 系统配置 =====
|
||||||
|
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
|
||||||
|
const { data } = await api.get('/admin/system-configs')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystemConfigs(
|
||||||
|
entries: { key: string; value: string }[]
|
||||||
|
): Promise<void> {
|
||||||
|
await api.put('/admin/system-configs', { entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 网盘类型开关 =====
|
||||||
|
export async function getCloudTypes(): Promise<{ types: { type: string; label: string; icon: string; enabled: boolean }[] }> {
|
||||||
|
const { data } = await api.get('/admin/cloud-types')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleCloudType(type: string, enabled: boolean): Promise<void> {
|
||||||
|
await api.put('/admin/cloud-types', { type, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 修改密码 =====
|
||||||
|
export async function changePassword(
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { data } = await api.post('/admin/change-password', { oldPassword, newPassword })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
|
export { query as searchQuery }
|
||||||
|
|
||||||
|
// ===== 系统设置(SettingsManage.vue) =====
|
||||||
|
export async function getSettings(): Promise<any[]> {
|
||||||
|
const { data } = await api.get('/admin/system-configs')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
export async function updateSetting(key: string, value: string): Promise<void> {
|
||||||
|
await api.put('/admin/system-configs', { entries: [{ key, value }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFallbackImage(file: File): Promise<{ success: boolean; url: string; message: string }> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
const { data } = await api.post('/admin/upload-fallback-image', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadLogo(file: File): Promise<{ success: boolean; url: string; message: string }> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
const { data } = await api.post('/admin/upload-logo', form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSiteConfig(): Promise<{ site_logo: string; site_name: string; search_fallback_image: string; site_disclaimer: string }> {
|
||||||
|
const { data } = await api.get('/site-config')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Redis 连接测试 =====
|
||||||
|
export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||||
|
const { data } = await api.post('/admin/test-redis', { url })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 外部服务连接测试 =====
|
||||||
|
export async function testExternalService(params: {
|
||||||
|
type: 'pansou' | 'video_parser' | 'tmdb' | 'proxy' | 'ip_geo'
|
||||||
|
url?: string
|
||||||
|
token?: string
|
||||||
|
}): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||||
|
const { data } = await api.post('/admin/test-external-service', params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 数据库状态 =====
|
||||||
|
export async function getDbStatus(): Promise<{
|
||||||
|
db_size: string
|
||||||
|
db_path: string
|
||||||
|
save_records: number
|
||||||
|
search_stats: number
|
||||||
|
system_configs: number
|
||||||
|
cloud_configs: number
|
||||||
|
content_cache: number
|
||||||
|
redis_status: string
|
||||||
|
redis_url: string
|
||||||
|
}> {
|
||||||
|
const { data } = await api.get('/admin/db-status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 存储清理 =====
|
||||||
|
export async function runCleanup(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
files_trashed: number
|
||||||
|
logs_deleted: number
|
||||||
|
trash_emptied: boolean
|
||||||
|
errors: string[]
|
||||||
|
message: string
|
||||||
|
}> {
|
||||||
|
const { data } = await api.post('/admin/cleanup/run')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emptyAllTrash(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
emptied: boolean
|
||||||
|
errors: string[]
|
||||||
|
message: string
|
||||||
|
}> {
|
||||||
|
const { data } = await api.post('/admin/cleanup/empty-trash')
|
||||||
|
return data
|
||||||
|
}
|
||||||
1
packages/frontend/src/api/index.ts.new
Normal file
1
packages/frontend/src/api/index.ts.new
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// This will be done in chunks via multiple commands to avoid escaping issues
|
||||||
35
packages/frontend/src/components/CloudBadge.vue
Executable file
35
packages/frontend/src/components/CloudBadge.vue
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<span class="cloud-badge" :style="{ background: CLOUD_COLORS[cloud_type] }">
|
||||||
|
<img v-if="showIcon && CLOUD_ICONS[cloud_type]" :src="CLOUD_ICONS[cloud_type]" class="badge-icon" />
|
||||||
|
{{ CLOUD_LABELS[cloud_type] }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CLOUD_LABELS, CLOUD_COLORS, CLOUD_ICONS } from '../types'
|
||||||
|
import type { CloudType } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
cloud_type: CloudType
|
||||||
|
showIcon?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
packages/frontend/src/components/CloudSelect.vue
Executable file
50
packages/frontend/src/components/CloudSelect.vue
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cloud-select">
|
||||||
|
<el-select v-model="selectedCloud" placeholder="选择目标网盘" @change="handleSelect">
|
||||||
|
<el-option
|
||||||
|
v-for="item in cloudList"
|
||||||
|
:key="item.cloud_type"
|
||||||
|
:label="item.nickname || CLOUD_LABELS[item.cloud_type]"
|
||||||
|
:value="item.cloud_type"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { CLOUD_LABELS } from '../types'
|
||||||
|
import type { CloudType, CloudConfig } from '../types'
|
||||||
|
import { getCloudConfigs } from '../api'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: CloudType
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [cloudType: CloudType]
|
||||||
|
'update:modelValue': [cloudType: CloudType]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedCloud = ref<CloudType | undefined>(props.modelValue)
|
||||||
|
const cloudList = ref<CloudConfig[]>([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
cloudList.value = await getCloudConfigs()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取网盘配置失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelect(value: CloudType) {
|
||||||
|
emit('select', value)
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
packages/frontend/src/components/RankingCard.vue
Executable file
87
packages/frontend/src/components/RankingCard.vue
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ranking-card" @click="handleClick">
|
||||||
|
<span class="rank-num" :class="{ 'top-three': index < 3 }">{{ index + 1 }}</span>
|
||||||
|
<img v-if="data.cover" :src="data.cover" class="rank-cover" />
|
||||||
|
<div class="rank-info">
|
||||||
|
<span class="rank-title">{{ data.title }}</span>
|
||||||
|
<span class="rank-count">🔍 {{ data.search_count }} 次搜索</span>
|
||||||
|
</div>
|
||||||
|
<CloudBadge :cloud_type="data.cloud_type" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CloudBadge from './CloudBadge.vue'
|
||||||
|
import type { RankingItem } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: RankingItem
|
||||||
|
index: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
router.push('/search?q=' + encodeURIComponent(props.data.title))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ranking-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ranking-card:hover {
|
||||||
|
background: #f0f5ff;
|
||||||
|
}
|
||||||
|
.rank-num {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: #f0f0f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rank-num.top-three {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.rank-cover {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rank-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.rank-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rank-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
530
packages/frontend/src/components/ResultCard.vue
Executable file
530
packages/frontend/src/components/ResultCard.vue
Executable file
@@ -0,0 +1,530 @@
|
|||||||
|
<template>
|
||||||
|
<div class="result-card" :class="{ 'clickable': loggedIn }" @click="loggedIn && openLink()">
|
||||||
|
<!-- 封面图区域(左侧) -->
|
||||||
|
<div class="card-cover">
|
||||||
|
<!-- 后台静默加载资源图,成功后切换 -->
|
||||||
|
<img v-if="showCover" :src="data.cover" :alt="data.title" @error="onCoverLoadError" loading="lazy" fetchpriority="low" />
|
||||||
|
<!-- 默认先显示兜底图(自家服务器,秒加载) -->
|
||||||
|
<img v-else-if="fallbackImage && !fallbackImgError" :src="fallbackImage" alt="cover" class="fallback-img" @error="onFallbackImgError" />
|
||||||
|
<!-- 兜底图也没有就用渐变色占位 -->
|
||||||
|
<div v-else class="cover-placeholder" :style="{ background: coverGradient }">
|
||||||
|
<span class="placeholder-icon">
|
||||||
|
<img v-if="cloudIcon.startsWith('data:') || cloudIcon.startsWith('http') || cloudIcon.startsWith('/')" :src="cloudIcon" style="width:36px;height:36px" />
|
||||||
|
<span v-else>{{ cloudIcon }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="cover-tag" :style="{ background: CLOUD_COLORS[data.cloud_type] }">
|
||||||
|
{{ CLOUD_LABELS[data.cloud_type] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- 资源名称(已清洗前缀和【】)2行显示 -->
|
||||||
|
<div class="card-title" :title="data.title">{{ cleanTitle }}</div>
|
||||||
|
|
||||||
|
<!-- 更新时间 + 大小 -->
|
||||||
|
<div class="card-time">
|
||||||
|
<span>🕐 {{ relativeTime }}</span>
|
||||||
|
<span v-if="data.file_size" class="meta-size">📦 {{ data.file_size }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签行(仅质量/格式/字幕类标签) -->
|
||||||
|
<div v-if="displayTags.length > 0" class="card-tags">
|
||||||
|
<span v-for="(tag, ti) in displayTags" :key="ti" class="tag" :class="'tag-' + tagClass(tag)">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部行:来源 + 操作按钮 -->
|
||||||
|
<div class="card-bottom-row">
|
||||||
|
<div class="bottom-left">
|
||||||
|
<span v-if="sourceName" class="meta-source" :title="data.source">
|
||||||
|
{{ sourceIcon }} {{ sourceName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-right">
|
||||||
|
<button v-if="data.share_url && !loggedIn" class="action-btn get-link-btn" @click.stop="handleSave">
|
||||||
|
🔗 获取分享链接
|
||||||
|
</button>
|
||||||
|
<button v-if="data.share_url && loggedIn" class="action-btn open-link-btn" @click.stop="openLink">
|
||||||
|
🔗 打开链接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { CLOUD_LABELS, CLOUD_ICONS, CLOUD_COLORS } from '../types'
|
||||||
|
import type { SearchResult } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: SearchResult
|
||||||
|
fallbackTags?: string[]
|
||||||
|
fallbackImage?: string
|
||||||
|
loggedIn?: boolean
|
||||||
|
cloudTypeMap?: Record<string, { label: string; icon: string }>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [data: SearchResult]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const PRELOAD_TIMEOUT_MS = 10000 // 资源图静默加载超时(10秒后放弃)
|
||||||
|
|
||||||
|
const showCover = ref(false) // 是否切换显示资源图(默认先显示兜底)
|
||||||
|
const fallbackImgError = ref(false) // 兜底图加载失败
|
||||||
|
const coverLoading = ref(false) // 资源图是否正在后台尝试
|
||||||
|
let preloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 后台静默预加载资源图
|
||||||
|
if (props.data.cover && !showCover.value) {
|
||||||
|
coverLoading.value = true
|
||||||
|
const img = new Image()
|
||||||
|
let resolved = false
|
||||||
|
|
||||||
|
preloadTimer = setTimeout(() => {
|
||||||
|
// 超时未完成 -> 放弃,继续显示兜底图
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
coverLoading.value = false
|
||||||
|
}
|
||||||
|
}, PRELOAD_TIMEOUT_MS)
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
showCover.value = true
|
||||||
|
coverLoading.value = false
|
||||||
|
if (preloadTimer) clearTimeout(preloadTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true
|
||||||
|
coverLoading.value = false
|
||||||
|
if (preloadTimer) clearTimeout(preloadTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = props.data.cover
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (preloadTimer) clearTimeout(preloadTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 极意外情况:showCover 后图片加载失败 -> 回退到兜底图
|
||||||
|
function onCoverLoadError() {
|
||||||
|
showCover.value = false
|
||||||
|
// 注意:此时 fallbackImage 可能已经加载过,浏览器有缓存会直接显示
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFallbackImgError() { fallbackImgError.value = true }
|
||||||
|
|
||||||
|
// 网盘图标 — 优先使用 prop 中的 API 数据,fallback emoji
|
||||||
|
const cloudIcon = computed(() => {
|
||||||
|
const icon = props.cloudTypeMap?.[props.data.cloud_type]?.icon
|
||||||
|
return icon || CLOUD_ICONS[props.data.cloud_type] || '📁'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 封面渐变色(无图时)
|
||||||
|
const coverGradient = computed(() => {
|
||||||
|
const gradients: Record<string, string> = {
|
||||||
|
quark: 'linear-gradient(135deg, #e8f5e9, #c8e6c9)',
|
||||||
|
baidu: 'linear-gradient(135deg, #e3f2fd, #bbdefb)',
|
||||||
|
aliyun: 'linear-gradient(135deg, #fff3e0, #ffe0b2)',
|
||||||
|
'115': 'linear-gradient(135deg, #f3e5f5, #e1bee7)',
|
||||||
|
xunlei: 'linear-gradient(135deg, #e8f5e9, #a5d6a7)',
|
||||||
|
magnet: 'linear-gradient(135deg, #e8eaf6, #c5cae9)',
|
||||||
|
}
|
||||||
|
return gradients[props.data.cloud_type] || 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 时间格式化 =====
|
||||||
|
function formatRelativeTime(dateStr?: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const now = Date.now()
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date.getTime())) return dateStr.slice(0, 10)
|
||||||
|
const diffMs = now - date.getTime()
|
||||||
|
if (diffMs < 0) return dateStr.slice(0, 10)
|
||||||
|
const secs = Math.floor(diffMs / 1000)
|
||||||
|
if (secs < 60) return '刚刚'
|
||||||
|
const mins = Math.floor(secs / 60)
|
||||||
|
if (mins < 60) return `${mins} 分钟前`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 30) return `${days} 天前`
|
||||||
|
if (days < 365) return `${Math.floor(days / 30)} 个月前`
|
||||||
|
return `${Math.floor(days / 365)} 年前`
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeTime = computed(() => {
|
||||||
|
return formatRelativeTime(props.data.update_time || props.data.datetime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 来源解析 =====
|
||||||
|
const sourceName = computed(() => {
|
||||||
|
const src = props.data.source || ''
|
||||||
|
if (!src) return ''
|
||||||
|
if (src.startsWith('tg:')) return '@' + src.slice(3)
|
||||||
|
if (src.startsWith('plugin:')) return src.slice(7)
|
||||||
|
return src
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceIcon = computed(() => {
|
||||||
|
const src = props.data.source || ''
|
||||||
|
if (src.startsWith('tg:')) return '📢'
|
||||||
|
if (src.startsWith('plugin:')) return '🔌'
|
||||||
|
return '📎'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 标题清洗 =====
|
||||||
|
// 移除各类前缀: [夸克网盘]、【#电影名称:】等
|
||||||
|
const CLEAN_PREFIXES = [
|
||||||
|
/^\[夸克网盘\][::]?\s*/,
|
||||||
|
/^【#电影名称:】\s*/,
|
||||||
|
/^【#电影名称[::]】\s*/,
|
||||||
|
/^【[^】]*[网盘|分享|电影|下载|资源]】[::]?\s*/,
|
||||||
|
/^\[[^\]]*[网盘|分享|电影|下载|资源]\]\s*/,
|
||||||
|
/^[##]电影名称[::]?\s*/,
|
||||||
|
/^[##]资源名称[::]?\s*/,
|
||||||
|
/^[##]标题[::]?\s*/,
|
||||||
|
/^【[^】]*资源名称[^】]*】\s*/,
|
||||||
|
/^【影片名称】\s*/,
|
||||||
|
/^【资源名称】\s*/,
|
||||||
|
/^【标题】\s*/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const cleanTitle = computed(() => {
|
||||||
|
let title = props.data.title || ''
|
||||||
|
// 1. 去除【】内的通用前缀类标识
|
||||||
|
for (const pat of CLEAN_PREFIXES) {
|
||||||
|
title = title.replace(pat, '')
|
||||||
|
}
|
||||||
|
// 2. 去除剩余的【xxx】内容(保留内容作为标签,标题只留干净部分)
|
||||||
|
title = title.replace(/【[^】]+】/g, '').trim()
|
||||||
|
return title || props.data.title
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 标签 =====
|
||||||
|
const QUALITY_TAG_SET = new Set([
|
||||||
|
'4K', '1080P', '2160P', '720P', '480P',
|
||||||
|
'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', '杜比全景声',
|
||||||
|
'高码率', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', 'AVC',
|
||||||
|
'内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕',
|
||||||
|
'字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕',
|
||||||
|
'臻彩', '高清', 'WEB-DL', 'WEBRip', '蓝光',
|
||||||
|
])
|
||||||
|
|
||||||
|
const QUALITY_PATTERNS = [
|
||||||
|
/\b(4K)\b/, /\b(1080[Pp])\b/, /\b(2160[Pp])\b/, /\b(720[Pp])\b/,
|
||||||
|
/\b(HDR10?\+?)\b/i, /\b(DV)\b/i,
|
||||||
|
/\b(BluRay|蓝光)\b/i, /\b(REMUX)\b/i, /\b(HEVC)\b/i,
|
||||||
|
/\b(x264)\b/i, /\b(x265)\b/i, /\b(WEB-DL)\b/i, /\b(WEBRip)\b/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
const displayTags = computed(() => {
|
||||||
|
const title = props.data.title || ''
|
||||||
|
const tags: string[] = []
|
||||||
|
|
||||||
|
// 1. 从【xxx】提取内容,只保留质量/格式/字幕类标签
|
||||||
|
const bracketMatches = title.matchAll(/【([^】]+)】/g)
|
||||||
|
for (const m of bracketMatches) {
|
||||||
|
const inner = m[1]
|
||||||
|
const parts = inner.split(/[.·、,,\/\\|]/)
|
||||||
|
for (const p of parts) {
|
||||||
|
const trimmed = p.trim()
|
||||||
|
if (trimmed && QUALITY_TAG_SET.has(trimmed) && !tags.includes(trimmed)) {
|
||||||
|
tags.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 额外从标题提取分辨率/编码标签
|
||||||
|
for (const pat of QUALITY_PATTERNS) {
|
||||||
|
const m = title.match(pat)
|
||||||
|
if (m) {
|
||||||
|
const found = m[1]
|
||||||
|
if (!tags.includes(found)) tags.push(found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 从标题全文找包含的关键词
|
||||||
|
const fullTextKeywords = ['杜比视界', '杜比全景声', '高码率', '内封简繁英字幕', '内嵌中英字幕', '内封简繁', '内嵌字幕', '中文字幕', '简繁字幕', '中英字幕', '内封字幕', '臻彩']
|
||||||
|
for (const kw of fullTextKeywords) {
|
||||||
|
if (title.includes(kw) && !tags.includes(kw)) {
|
||||||
|
tags.push(kw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length === 0 && props.fallbackTags && props.fallbackTags.length > 0) {
|
||||||
|
return props.fallbackTags.slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags.slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
function tagClass(tag: string): string {
|
||||||
|
const quality = ['4K', '1080P', '2160P', '720P', '480P', 'HDR', 'HDR10', 'HDR10+', 'DV', '杜比视界', 'BluRay', 'REMUX', 'HEVC', 'x264', 'x265', '臻彩', '高清', 'WEB-DL', 'WEBRip']
|
||||||
|
if (quality.includes(tag)) return 'quality'
|
||||||
|
if (tag.includes('字幕') || tag === '杜比全景声' || tag === '高码率') return 'subtitle'
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 交互行为 =====
|
||||||
|
function handleSave() {
|
||||||
|
emit('save', props.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
if (props.data.share_url) {
|
||||||
|
window.open(props.data.share_url, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-card {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 130px 120px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.result-card:hover {
|
||||||
|
border-color: #c0c4cc;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.result-card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.result-card.clickable:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 封面(左侧,与右侧等高) ---- */
|
||||||
|
.card-cover {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 100px;
|
||||||
|
max-width: 130px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f2f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.card-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.card-cover img.fallback-img {
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f0f2f5;
|
||||||
|
loading: eager;
|
||||||
|
}
|
||||||
|
.result-card:hover .card-cover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(0.2);
|
||||||
|
}
|
||||||
|
.cover-tag {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5px;
|
||||||
|
left: 5px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 右侧内容 ---- */
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资源名称 */
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更新时间 */
|
||||||
|
.card-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.meta-size {
|
||||||
|
color: #67c23a;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 标签 ---- */
|
||||||
|
.card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.tag-quality {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
.tag-subtitle {
|
||||||
|
background: #f0f9eb;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
.tag-default {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 底部行:来源 + 操作 ---- */
|
||||||
|
.card-bottom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.bottom-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.bottom-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-source {
|
||||||
|
color: #909399;
|
||||||
|
background: #f4f4f5;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.get-link-btn {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.get-link-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.open-link-btn {
|
||||||
|
background: #67c23a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.open-link-btn:hover {
|
||||||
|
background: #5daf34;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 响应式 ===== */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.result-card {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.card-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
align-self: auto;
|
||||||
|
}
|
||||||
|
.card-cover img {
|
||||||
|
position: static;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
.card-cover img.fallback-img {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.cover-placeholder {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.card-bottom-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
packages/frontend/src/components/VideoResultCard.vue
Executable file
130
packages/frontend/src/components/VideoResultCard.vue
Executable file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="video-card">
|
||||||
|
<div class="video-cover">
|
||||||
|
<img :src="data.cover" :alt="data.title" />
|
||||||
|
<div class="play-icon">▶</div>
|
||||||
|
<span class="platform-tag">{{ data.platform }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<h4>{{ data.title }}</h4>
|
||||||
|
<p v-if="data.author" class="video-author">👤 {{ data.author }}</p>
|
||||||
|
<p v-if="data.description" class="video-desc">{{ data.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="video-actions">
|
||||||
|
<button class="save-btn" @click="handleSave">📥 保存到云盘并获取下载链接</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { VideoParseResult } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: VideoParseResult
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [data: VideoParseResult]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
emit('save', props.data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-card {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.video-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.video-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 200px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.video-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.play-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.platform-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.video-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.video-info h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.video-author {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.video-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.video-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.save-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.save-btn:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
382
packages/frontend/src/composables/useTrendChart.ts
Normal file
382
packages/frontend/src/composables/useTrendChart.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* useTrendChart
|
||||||
|
*
|
||||||
|
* Composable that manages a combined bar+line ECharts trend chart.
|
||||||
|
* Features:
|
||||||
|
* - Tree-shakeable ECharts import (core + only used chart types/components)
|
||||||
|
* - Auto resize via ResizeObserver
|
||||||
|
* - DataZoom slider for 30+ day ranges
|
||||||
|
* - Day-over-day delta in tooltips
|
||||||
|
* - Average markLine
|
||||||
|
* - Period summary callback
|
||||||
|
* - Proper cleanup on unmount
|
||||||
|
*/
|
||||||
|
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { init, use, graphic } from 'echarts/core'
|
||||||
|
import { BarChart, LineChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
TooltipComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
DataZoomSliderComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||||
|
|
||||||
|
// Register only the features we actually use
|
||||||
|
use([
|
||||||
|
BarChart, LineChart,
|
||||||
|
TooltipComponent, GridComponent, LegendComponent,
|
||||||
|
DataZoomSliderComponent, MarkLineComponent,
|
||||||
|
CanvasRenderer,
|
||||||
|
])
|
||||||
|
|
||||||
|
const { LinearGradient } = graphic
|
||||||
|
|
||||||
|
export interface TrendDataPoint {
|
||||||
|
date: string
|
||||||
|
searches: number
|
||||||
|
saves: number
|
||||||
|
searchDelta: number
|
||||||
|
saveDelta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendSummary {
|
||||||
|
totalSearches: number
|
||||||
|
totalSaves: number
|
||||||
|
avgSearches: number
|
||||||
|
avgSaves: number
|
||||||
|
peakDay: string
|
||||||
|
peakSearches: number
|
||||||
|
peakSaves: number
|
||||||
|
dayCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrendChart(
|
||||||
|
trendData: Ref<TrendDataPoint[]>,
|
||||||
|
onSummary?: Ref<TrendSummary | null> | ((s: TrendSummary) => void),
|
||||||
|
) {
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chartInstance: ECharts | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
const BAR_WIDTH_PCT = '35%'
|
||||||
|
const BAR_GAP_PCT = '30%'
|
||||||
|
|
||||||
|
function computeSummary(data: TrendDataPoint[]): TrendSummary {
|
||||||
|
const totalSearches = data.reduce((s, d) => s + d.searches, 0)
|
||||||
|
const totalSaves = data.reduce((s, d) => s + d.saves, 0)
|
||||||
|
const n = data.length || 1
|
||||||
|
let peakIdx = 0
|
||||||
|
let peakVal = 0
|
||||||
|
data.forEach((d, i) => {
|
||||||
|
const v = d.searches + d.saves
|
||||||
|
if (v > peakVal) { peakVal = v; peakIdx = i }
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
totalSearches,
|
||||||
|
totalSaves,
|
||||||
|
avgSearches: Math.round(totalSearches / n),
|
||||||
|
avgSaves: Math.round(totalSaves / n),
|
||||||
|
peakDay: data[peakIdx]?.date?.slice(5) || '—',
|
||||||
|
peakSearches: data[peakIdx]?.searches || 0,
|
||||||
|
peakSaves: data[peakIdx]?.saves || 0,
|
||||||
|
dayCount: n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const el = chartRef.value
|
||||||
|
const data = trendData.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// Compute summary and emit
|
||||||
|
const summary = computeSummary(data)
|
||||||
|
if (typeof onSummary === 'function') {
|
||||||
|
onSummary(summary)
|
||||||
|
} else if (onSummary) {
|
||||||
|
onSummary.value = summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!data.length) {
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.dispose()
|
||||||
|
chartInstance = null
|
||||||
|
}
|
||||||
|
el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#909399;font-size:13px;">暂无使用数据</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle DOM element recreation (v-if remounts chart div when navigating away & back)
|
||||||
|
if (!chartInstance || chartInstance.getDom() !== el) {
|
||||||
|
if (chartInstance) chartInstance.dispose()
|
||||||
|
chartInstance = init(el)
|
||||||
|
initResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = data.map(d => d.date.slice(5)) // MM-DD
|
||||||
|
const n = data.length
|
||||||
|
const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1)
|
||||||
|
const yMax = Math.ceil(maxCount * 1.35) || 1
|
||||||
|
const avgSearches = Math.round(summary.totalSearches / n)
|
||||||
|
const showDataZoom = n >= 30
|
||||||
|
|
||||||
|
const option: EChartsCoreOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.97)',
|
||||||
|
borderColor: '#e8e8e8',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: [10, 14],
|
||||||
|
textStyle: { fontSize: 12, color: '#303133' },
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const idx = params[0]?.dataIndex ?? 0
|
||||||
|
const dateLabel = data[idx]?.date || ''
|
||||||
|
const d = data[idx]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const items = params.filter((p: any) => {
|
||||||
|
const key = p.seriesName
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
let html = `<div style="font-weight:700;font-size:13px;margin-bottom:8px;color:#1a1a2e">${dateLabel}</div>`
|
||||||
|
items.forEach((p: any) => {
|
||||||
|
const rawVal = p.value
|
||||||
|
const val = Array.isArray(rawVal) ? rawVal[1] : rawVal
|
||||||
|
const isBar = p.seriesType === 'bar'
|
||||||
|
const isSearch = p.seriesName === '搜索'
|
||||||
|
const delta = isSearch ? d?.searchDelta : d?.saveDelta
|
||||||
|
const deltaStr = delta !== undefined && delta !== 0
|
||||||
|
? `<span style="margin-left:6px;font-size:11px;color:${delta > 0 ? '#f56c6c' : '#67c23a'}">${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}</span>`
|
||||||
|
: (delta === 0 ? '<span style="margin-left:6px;font-size:11px;color:#909399">→0</span>' : '')
|
||||||
|
const icon = isBar
|
||||||
|
? `<span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color};vertical-align:middle"></span>`
|
||||||
|
: `<span style="display:inline-block;width:14px;height:2px;background:${p.color};vertical-align:middle"></span>`
|
||||||
|
html += `<div style="display:flex;align-items:center;gap:6px;margin-top:4px">${icon}<span>${p.seriesName}:<b>${val}</b> 次${deltaStr}</span></div>`
|
||||||
|
})
|
||||||
|
return html
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['搜索', '保存'],
|
||||||
|
bottom: showDataZoom ? 30 : 0,
|
||||||
|
left: 'center',
|
||||||
|
itemWidth: 14,
|
||||||
|
itemHeight: 10,
|
||||||
|
textStyle: { fontSize: 11, color: '#666' },
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: 8,
|
||||||
|
right: 12,
|
||||||
|
top: 28,
|
||||||
|
bottom: showDataZoom ? 70 : 42,
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: dates,
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#909399',
|
||||||
|
rotate: n > 15 ? 45 : 0,
|
||||||
|
},
|
||||||
|
axisLine: { lineStyle: { color: '#e8e8e8' } },
|
||||||
|
splitLine: { show: false },
|
||||||
|
axisTick: { show: false },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '次',
|
||||||
|
nameTextStyle: { fontSize: 10, color: '#909399' },
|
||||||
|
min: 0,
|
||||||
|
max: yMax,
|
||||||
|
splitNumber: 4,
|
||||||
|
axisLabel: {
|
||||||
|
fontSize: 10, color: '#909399' },
|
||||||
|
splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
// Bar — 搜索
|
||||||
|
{
|
||||||
|
name: '搜索',
|
||||||
|
type: 'bar',
|
||||||
|
data: data.map(d => d.searches),
|
||||||
|
barWidth: BAR_WIDTH_PCT,
|
||||||
|
barGap: BAR_GAP_PCT,
|
||||||
|
itemStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#6366f1' },
|
||||||
|
{ offset: 1, color: '#a5b4fc' },
|
||||||
|
]),
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#4f46e5' },
|
||||||
|
{ offset: 1, color: '#818cf8' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
},
|
||||||
|
// Bar — 保存
|
||||||
|
{
|
||||||
|
name: '保存',
|
||||||
|
type: 'bar',
|
||||||
|
data: data.map(d => d.saves),
|
||||||
|
barWidth: BAR_WIDTH_PCT,
|
||||||
|
barGap: BAR_GAP_PCT,
|
||||||
|
itemStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#10b981' },
|
||||||
|
{ offset: 1, color: '#6ee7b7' },
|
||||||
|
]),
|
||||||
|
borderRadius: [4, 4, 0, 0],
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: '#059669' },
|
||||||
|
{ offset: 1, color: '#34d399' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationDuration: 500,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
},
|
||||||
|
// Line — 搜索
|
||||||
|
{
|
||||||
|
name: '搜索',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 5,
|
||||||
|
data: data.map(d => d.searches),
|
||||||
|
lineStyle: { width: 2.5, color: '#4f46e5' },
|
||||||
|
itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
|
||||||
|
{ offset: 1, color: 'rgba(99,102,241,0.01)' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
connectNulls: true,
|
||||||
|
animationDuration: 700,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
z: 3,
|
||||||
|
markLine: avgSearches > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 },
|
||||||
|
label: {
|
||||||
|
formatter: `均 ${avgSearches}`,
|
||||||
|
position: 'insideEndTop',
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#6366f1',
|
||||||
|
},
|
||||||
|
data: [{ yAxis: avgSearches, name: '日均搜索' }],
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
// Line — 保存
|
||||||
|
{
|
||||||
|
name: '保存',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 5,
|
||||||
|
data: data.map(d => d.saves),
|
||||||
|
lineStyle: { width: 2.5, color: '#059669' },
|
||||||
|
itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 },
|
||||||
|
areaStyle: {
|
||||||
|
color: new LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(16,185,129,0.12)' },
|
||||||
|
{ offset: 1, color: 'rgba(16,185,129,0.01)' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
connectNulls: true,
|
||||||
|
animationDuration: 700,
|
||||||
|
animationEasing: 'cubicOut',
|
||||||
|
z: 3,
|
||||||
|
markLine: avgSearches > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 },
|
||||||
|
label: {
|
||||||
|
formatter: `均 ${Math.round(summary.totalSaves / n)}`,
|
||||||
|
position: 'insideEndTop',
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#10b981',
|
||||||
|
},
|
||||||
|
data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }],
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// DataZoom slider for 30+ day views
|
||||||
|
...(showDataZoom ? {
|
||||||
|
dataZoom: [{
|
||||||
|
type: 'slider',
|
||||||
|
bottom: 6,
|
||||||
|
height: 20,
|
||||||
|
start: 0,
|
||||||
|
end: 100,
|
||||||
|
borderColor: '#e8e8e8',
|
||||||
|
fillerColor: 'rgba(99,102,241,0.08)',
|
||||||
|
handleStyle: { color: '#6366f1', borderColor: '#6366f1' },
|
||||||
|
textStyle: { fontSize: 10, color: '#909399' },
|
||||||
|
}],
|
||||||
|
} : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.setOption(option, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-render when data changes
|
||||||
|
watch(
|
||||||
|
trendData,
|
||||||
|
() => {
|
||||||
|
nextTick(() => render())
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup ResizeObserver for responsive chart
|
||||||
|
function initResize() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
chartInstance?.resize()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(chartRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
chartInstance?.dispose()
|
||||||
|
chartInstance = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartRef,
|
||||||
|
render,
|
||||||
|
initResize,
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/frontend/src/env.d.ts
vendored
Executable file
7
packages/frontend/src/env.d.ts
vendored
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
13
packages/frontend/src/main.ts
Executable file
13
packages/frontend/src/main.ts
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/global.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.mount('#app')
|
||||||
363
packages/frontend/src/pages/HomePage.vue
Executable file
363
packages/frontend/src/pages/HomePage.vue
Executable file
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<div class="hero-section">
|
||||||
|
<template v-if="configLoaded">
|
||||||
|
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
|
||||||
|
<div v-else class="logo-text">{{ siteName || 'CloudSearch' }}</div>
|
||||||
|
</template>
|
||||||
|
<div class="search-box">
|
||||||
|
<el-input
|
||||||
|
v-model="query"
|
||||||
|
placeholder="搜索网盘资源,或粘贴视频/网盘链接..."
|
||||||
|
size="large"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" size="large" @click="handleSearch" class="search-btn">
|
||||||
|
搜 索
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="quote-section" v-if="currentQuote">
|
||||||
|
<span class="quote-text">「 {{ currentQuote }} 」</span>
|
||||||
|
<span class="quote-author">---{{ quoteAuthor }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-section">
|
||||||
|
<div v-if="categories.length > 0" class="rankings-grid">
|
||||||
|
<div
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.category"
|
||||||
|
class="rank-panel"
|
||||||
|
>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">{{ getCategoryIcon(cat.category) }} {{ cat.label }}</span>
|
||||||
|
<div class="panel-tabs">
|
||||||
|
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'hot' }" @click="switchTab(cat.category, 'hot')">热榜</span>
|
||||||
|
<span class="panel-tab" :class="{ active: activeTab[cat.category] === 'newest' }" @click="switchTab(cat.category, 'newest')">最新</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div
|
||||||
|
v-for="(item, idx) in visibleItems(cat)"
|
||||||
|
:key="cat.category + '-' + idx"
|
||||||
|
class="rank-item"
|
||||||
|
@click="searchTag(item.keyword)"
|
||||||
|
>
|
||||||
|
<span class="rank-idx" :class="{ 'top-three': idx < 3 }">{{ idx + 1 }}</span>
|
||||||
|
<span class="rank-name">{{ item.keyword }}</span>
|
||||||
|
<span class="rank-cnt">{{ formatCount(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 展开按钮 -->
|
||||||
|
<div v-if="hasMoreItems(cat)" class="rank-expand" @click="expandCategory(cat.category)">
|
||||||
|
展开全部 ▼
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-footer">
|
||||||
|
<span v-if="cat.category === 'hotsite'">基于本站搜索数据</span>
|
||||||
|
<span v-else-if="cat.category === 'donghua' || cat.category === 'global_anime'">数据来源:Bilibili</span>
|
||||||
|
<span v-else-if="cat.category === 'movie' || cat.category === 'tv'">数据来源:百度</span>
|
||||||
|
<span v-else>数据来源:TMDB</span>
|
||||||
|
<span class="footer-time">{{ fetchedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="siteDisclaimer" class="site-footer">
|
||||||
|
<div class="footer-inner">{{ siteDisclaimer }}</div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<el-button class="footer-disclaimer-btn" size="small" @click="openDisclaimer">📜 免责声明</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import { getCategorizedRankings, getSiteConfig } from '../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const categories = ref<any[]>([])
|
||||||
|
const expanded = reactive<Record<string, boolean>>({})
|
||||||
|
const activeTab = reactive<Record<string, string>>({})
|
||||||
|
const siteLogo = ref('')
|
||||||
|
const siteName = ref('')
|
||||||
|
const siteDisclaimer = ref('')
|
||||||
|
const configLoaded = ref(false)
|
||||||
|
const currentQuote = ref('')
|
||||||
|
const quoteAuthor = ref('')
|
||||||
|
const fetchedAt = ref('')
|
||||||
|
|
||||||
|
const INITIAL_SHOW = 8
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<string, string> = {
|
||||||
|
movie: '🎬', western_movie: '🎥', western_tv: '🌍',
|
||||||
|
donghua: '🐉', global_anime: '🌐',
|
||||||
|
tv: '📺',
|
||||||
|
niche: '💎', hotsite: '🏆',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryIcon(cat: string): string {
|
||||||
|
return CATEGORY_ICONS[cat] || '📋'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(item: any): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (item.rating) {
|
||||||
|
parts.push(`⭐${item.rating}`)
|
||||||
|
}
|
||||||
|
if (item.searchCount > 0) {
|
||||||
|
const n = item.searchCount
|
||||||
|
if (n >= 100000000) {
|
||||||
|
parts.push(`${(n / 100000000).toFixed(1)}亿`)
|
||||||
|
} else if (n >= 10000) {
|
||||||
|
parts.push(`${(n / 10000).toFixed(0)}万`)
|
||||||
|
} else {
|
||||||
|
parts.push(String(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(' ') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItems(cat: any): any[] {
|
||||||
|
const tab = activeTab[cat.category] || 'hot'
|
||||||
|
return tab === 'hot' ? (cat.hot || []) : (cat.newest || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems(cat: any): any[] {
|
||||||
|
const items = getItems(cat)
|
||||||
|
return expanded[cat.category] ? items : items.slice(0, INITIAL_SHOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMoreItems(cat: any): boolean {
|
||||||
|
const items = getItems(cat)
|
||||||
|
return items.length > INITIAL_SHOW && !expanded[cat.category]
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandCategory(category: string) {
|
||||||
|
expanded[category] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(category: string, tab: string) {
|
||||||
|
activeTab[category] = tab
|
||||||
|
// 切换标签时收起展开
|
||||||
|
expanded[category] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisclaimer() {
|
||||||
|
window.open('/disclaimer/', '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 一言 API
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://v1.hitokoto.cn/')
|
||||||
|
const data = await res.json()
|
||||||
|
currentQuote.value = data.hitokoto || ''
|
||||||
|
quoteAuthor.value = data.from_who || data.from || ''
|
||||||
|
} catch {
|
||||||
|
currentQuote.value = '学而时习之,不亦说乎。'
|
||||||
|
quoteAuthor.value = '孔子'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [catsData, siteCfg] = await Promise.all([
|
||||||
|
getCategorizedRankings(),
|
||||||
|
getSiteConfig(),
|
||||||
|
])
|
||||||
|
// 新格式: { fetchedAt, categories }
|
||||||
|
if (catsData.fetchedAt) {
|
||||||
|
fetchedAt.value = catsData.fetchedAt
|
||||||
|
categories.value = catsData.categories || []
|
||||||
|
} else {
|
||||||
|
// 兼容旧格式
|
||||||
|
categories.value = Array.isArray(catsData) ? catsData : []
|
||||||
|
}
|
||||||
|
for (const cat of categories.value) {
|
||||||
|
activeTab[cat.category] = 'hot'
|
||||||
|
expanded[cat.category] = false
|
||||||
|
}
|
||||||
|
if (siteCfg.site_logo) {
|
||||||
|
siteLogo.value = siteCfg.site_logo
|
||||||
|
}
|
||||||
|
if (siteCfg.site_name) {
|
||||||
|
siteName.value = siteCfg.site_name
|
||||||
|
}
|
||||||
|
if (siteCfg.site_disclaimer) {
|
||||||
|
siteDisclaimer.value = siteCfg.site_disclaimer
|
||||||
|
}
|
||||||
|
configLoaded.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载首页数据失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (q) router.push('/search?q=' + encodeURIComponent(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTag(tag: string) {
|
||||||
|
router.push('/search?q=' + encodeURIComponent(tag))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-page { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
padding: 60px 24px 40px;
|
||||||
|
}
|
||||||
|
.logo-text { font-size: 64px; font-weight: 700; color: var(--primary-color); margin-bottom: 32px; letter-spacing: -2px; }
|
||||||
|
.logo-img { max-width: 500px; max-height: 120px; width: auto; height: auto; object-fit: contain; margin-bottom: 32px; }
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%; max-width: 640px;
|
||||||
|
border: 1px solid #dfe1e5;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: box-shadow .2s, border-color .2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.search-box:focus-within {
|
||||||
|
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||||
|
border-color: rgba(223,225,229,0);
|
||||||
|
}
|
||||||
|
.search-box :deep(.el-input__wrapper) {
|
||||||
|
border: none; box-shadow: none; background: transparent;
|
||||||
|
padding: 4px 20px; border-radius: 0;
|
||||||
|
}
|
||||||
|
.search-box :deep(.el-input__inner) {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.search-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px;
|
||||||
|
margin: 4px;
|
||||||
|
font-size: 14px; font-weight: 600;
|
||||||
|
background: var(--primary-color); color: #fff;
|
||||||
|
cursor: pointer; transition: all .2s;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.search-btn:hover {
|
||||||
|
background: #3a7be0;
|
||||||
|
}
|
||||||
|
.search-btn:active {
|
||||||
|
background: #2d6ccf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-section { margin-top: 18px; max-width: 640px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.quote-text { font-size: 14px; color: #aab0b8; font-style: italic; letter-spacing: 0.5px; }
|
||||||
|
.quote-author { font-size: 12px; color: #c0c4cc; display: inline-block; margin-left: 4px; }
|
||||||
|
|
||||||
|
.content-section { max-width: 1500px; width: 100%; margin: 0 auto; padding: 0 16px 60px; }
|
||||||
|
|
||||||
|
.rankings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-panel {
|
||||||
|
background: var(--bg-white,#fff);
|
||||||
|
border-radius: 12px; padding: 14px; border: 1px solid #ebeef5;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04); display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding-bottom: 10px; border-bottom: 2px solid #f0f0f0; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.panel-title { font-size: 15px; font-weight: 700; color: #303133; white-space: nowrap; }
|
||||||
|
.panel-tabs { display: flex; gap: 2px; background: #f0f2f5; border-radius: 6px; padding: 2px; }
|
||||||
|
.panel-tab {
|
||||||
|
font-size: 11px; padding: 3px 10px; border-radius: 5px; cursor: pointer;
|
||||||
|
color: #909399; font-weight: 500; transition: all .2s; user-select: none;
|
||||||
|
}
|
||||||
|
.panel-tab.active { background: #fff; color: var(--primary-color); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||||
|
|
||||||
|
.panel-body { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
|
||||||
|
.rank-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 5px 6px;
|
||||||
|
border-radius: 6px; cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.rank-item:hover { background: #f0f5ff; }
|
||||||
|
.rank-item:active { background: #e6f0ff; }
|
||||||
|
|
||||||
|
.rank-idx {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center;
|
||||||
|
justify-content: center; font-size: 12px; font-weight: 700;
|
||||||
|
color: #909399; background: #f0f0f0; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rank-idx.top-three { background: var(--primary-color); color: #fff; }
|
||||||
|
.rank-name { flex: 1; min-width: 0; font-size: 13px; font-weight: 500; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rank-cnt { font-size: 11px; color: #c0c4cc; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.rank-expand {
|
||||||
|
text-align: center; padding: 6px; margin-top: 2px; font-size: 12px;
|
||||||
|
color: var(--primary-color); cursor: pointer; border-radius: 6px;
|
||||||
|
transition: background .15s; user-select: none;
|
||||||
|
}
|
||||||
|
.rank-expand:hover { background: #ecf5ff; }
|
||||||
|
|
||||||
|
.panel-footer {
|
||||||
|
margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-size: 11px; color: #c0c4cc;
|
||||||
|
}
|
||||||
|
.footer-time { font-family: monospace; font-size: 10px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero-section { padding: 36px 16px 24px; }
|
||||||
|
.logo-text { font-size: 36px; margin-bottom: 20px; }
|
||||||
|
.logo-img { max-width: 360px; max-height: 100px; margin-bottom: 20px; }
|
||||||
|
.rankings-scroll { gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 20px 16px 32px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.footer-inner {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.footer-disclaimer-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #909399 !important;
|
||||||
|
}
|
||||||
|
.footer-disclaimer-btn:hover {
|
||||||
|
color: #409eff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
350
packages/frontend/src/pages/ResultDetail.vue
Executable file
350
packages/frontend/src/pages/ResultDetail.vue
Executable file
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<div class="result-detail-page">
|
||||||
|
<div class="detail-container">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading-state">
|
||||||
|
<el-skeleton :rows="6" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 网盘资源详情 -->
|
||||||
|
<div v-else-if="result" class="detail-card">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-cover">
|
||||||
|
<img :src="result.cover" :alt="result.title" />
|
||||||
|
<CloudBadge :cloud_type="result.cloud_type" />
|
||||||
|
</div>
|
||||||
|
<div class="detail-info">
|
||||||
|
<h1>{{ result.title }}</h1>
|
||||||
|
<div class="detail-meta">
|
||||||
|
<el-tag v-if="result.file_size">📦 {{ result.file_size }}</el-tag>
|
||||||
|
<el-tag v-if="result.update_time">🕐 {{ result.update_time }}</el-tag>
|
||||||
|
<el-tag v-if="result.source">📂 {{ result.source }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<p v-if="result.description" class="detail-desc">{{ result.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<el-button type="primary" size="large" @click="showSaveDialog">
|
||||||
|
📥 保存到网盘
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频解析详情 -->
|
||||||
|
<div v-else-if="videoResult" class="detail-card">
|
||||||
|
<div class="detail-video">
|
||||||
|
<div class="video-preview">
|
||||||
|
<img :src="videoResult.cover" :alt="videoResult.title" />
|
||||||
|
<div class="play-overlay" @click="playVideo">
|
||||||
|
<div class="play-btn">▶ 播放</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<h1>{{ videoResult.title }}</h1>
|
||||||
|
<p v-if="videoResult.author" class="video-author">👤 {{ videoResult.author }}</p>
|
||||||
|
<p class="video-platform">📺 {{ videoResult.platform }}</p>
|
||||||
|
<p v-if="videoResult.description" class="detail-desc">{{ videoResult.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<el-button type="primary" size="large" @click="showSaveDialog">
|
||||||
|
📥 保存到云盘
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<div v-if="showPlayer" class="video-player-wrapper">
|
||||||
|
<video :src="videoResult.video_url" controls autoplay class="video-player"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 未找到 -->
|
||||||
|
<el-empty v-else description="未找到该资源" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 保存弹窗 -->
|
||||||
|
<el-dialog v-model="saveDialogVisible" title="保存到网盘" width="420px">
|
||||||
|
<div class="save-dialog-content">
|
||||||
|
<p class="save-file-name">📄 {{ result?.title || videoResult?.title }}</p>
|
||||||
|
<CloudSelect @select="onCloudSelected" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="saveDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="confirmSave">确认保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 保存结果弹窗 -->
|
||||||
|
<el-dialog v-model="resultDialogVisible" title="保存成功" width="420px">
|
||||||
|
<div class="result-dialog-content">
|
||||||
|
<el-alert type="success" :title="saveResult?.message || '保存成功'" show-icon :closable="false" />
|
||||||
|
<div class="share-link-box">
|
||||||
|
<p class="share-label">分享链接:</p>
|
||||||
|
<div class="share-link-row">
|
||||||
|
<el-input v-model="shareLink" readonly />
|
||||||
|
<el-button @click="copyShareLink">复制</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" @click="resultDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import CloudBadge from '../components/CloudBadge.vue'
|
||||||
|
import CloudSelect from '../components/CloudSelect.vue'
|
||||||
|
import { query, saveToCloud, saveVideoToCloud } from '../api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { SearchResult, VideoParseResult, SaveResult } from '../types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const result = ref<SearchResult | null>(null)
|
||||||
|
const videoResult = ref<VideoParseResult | null>(null)
|
||||||
|
const showPlayer = ref(false)
|
||||||
|
|
||||||
|
const saveDialogVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const selectedCloud = ref('')
|
||||||
|
const isVideo = ref(false)
|
||||||
|
|
||||||
|
const resultDialogVisible = ref(false)
|
||||||
|
const saveResult = ref<SaveResult | null>(null)
|
||||||
|
const shareLink = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id as string
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 用 id 作为搜索词重新查询具体结果
|
||||||
|
const res = await query(id)
|
||||||
|
if (res.intent === 'SEARCH' && res.results.length > 0) {
|
||||||
|
result.value = (res.results as SearchResult[])[0]
|
||||||
|
} else if (res.intent === 'VIDEO_PARSE' && res.results.length > 0) {
|
||||||
|
videoResult.value = (res.results as VideoParseResult[])[0]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取详情失败', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function showSaveDialog() {
|
||||||
|
isVideo.value = !!videoResult.value
|
||||||
|
saveDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function playVideo() {
|
||||||
|
showPlayer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloudSelected(cloudType: string) {
|
||||||
|
selectedCloud.value = cloudType
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSave() {
|
||||||
|
if (!selectedCloud.value) {
|
||||||
|
ElMessage.warning('请选择目标网盘')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
let res: SaveResult
|
||||||
|
if (isVideo.value && videoResult.value) {
|
||||||
|
res = await saveVideoToCloud({
|
||||||
|
video_url: videoResult.value.video_url,
|
||||||
|
title: videoResult.value.title,
|
||||||
|
target_cloud: selectedCloud.value,
|
||||||
|
})
|
||||||
|
} else if (result.value) {
|
||||||
|
res = await saveToCloud({
|
||||||
|
type: 'search',
|
||||||
|
source: result.value,
|
||||||
|
target_cloud: selectedCloud.value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveResult.value = res
|
||||||
|
shareLink.value = res.share_url
|
||||||
|
saveDialogVisible.value = false
|
||||||
|
resultDialogVisible.value = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存失败', e)
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyShareLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareLink.value)
|
||||||
|
ElMessage.success('链接已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
.detail-container {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.detail-card {
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.detail-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 240px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.detail-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.detail-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.detail-info h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.detail-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.detail-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.detail-actions {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.detail-video {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.video-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.video-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.play-overlay:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.play-btn {
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.video-info h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.video-author,
|
||||||
|
.video-platform {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.video-player-wrapper {
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
.video-player {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.loading-state {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
.save-dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.save-file-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.result-dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.share-link-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.share-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.share-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2199
packages/frontend/src/pages/SearchResult.vue
Executable file
2199
packages/frontend/src/pages/SearchResult.vue
Executable file
File diff suppressed because it is too large
Load Diff
1071
packages/frontend/src/pages/admin/AdminDashboard.vue
Normal file
1071
packages/frontend/src/pages/admin/AdminDashboard.vue
Normal file
File diff suppressed because it is too large
Load Diff
142
packages/frontend/src/pages/admin/AdminLayout.vue
Normal file
142
packages/frontend/src/pages/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="admin-content">
|
||||||
|
<div class="content-header">
|
||||||
|
<h2>{{ pageTitle }}</h2>
|
||||||
|
<el-button text @click="goBackHome">返回前台</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="content-body">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 { getSiteConfig } from '../../api'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const siteName = ref('')
|
||||||
|
const appVersion = ref('')
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
dashboard: '仪表盘',
|
||||||
|
'cloud-configs-toggle': '网盘设置及授权',
|
||||||
|
'cloud-configs-cleanup': '存储清理',
|
||||||
|
'sys-site': '网站设置',
|
||||||
|
'sys-services': '外部服务 & 缓存',
|
||||||
|
'sys-strategy': '性能配置',
|
||||||
|
'sys-password': '修改管理员密码',
|
||||||
|
'save-records': '转存日志',
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const name = route.name as string
|
||||||
|
if (name === 'admin-cloud-configs') return 'cloud-configs-toggle'
|
||||||
|
if (name === 'admin-cleanup') return 'cloud-configs-cleanup'
|
||||||
|
if (name === 'admin-system') {
|
||||||
|
const sec = route.query.section as string
|
||||||
|
return sec || 'sys-site'
|
||||||
|
}
|
||||||
|
if (name === 'admin-save-records') return 'save-records'
|
||||||
|
return 'dashboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
return pageTitles[activeMenu.value] || '仪表盘'
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleMenuSelect(index: string) {
|
||||||
|
if (index === 'dashboard') {
|
||||||
|
router.push('/admin/dashboard')
|
||||||
|
} else if (index === 'cloud-configs-toggle') {
|
||||||
|
router.push('/admin/cloud-configs')
|
||||||
|
} else if (index === 'cloud-configs-cleanup') {
|
||||||
|
router.push('/admin/cleanup')
|
||||||
|
} else if (index.startsWith('sys-')) {
|
||||||
|
router.push({ path: '/admin/system', query: { section: index } })
|
||||||
|
} else if (index === 'save-records') {
|
||||||
|
router.push('/admin/save-records')
|
||||||
|
} else if (index === 'logout') {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
router.push('/admin/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBackHome() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await getSiteConfig()
|
||||||
|
siteName.value = cfg.site_name || ''
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const h = await fetch('/health')
|
||||||
|
const hv = await h.json()
|
||||||
|
appVersion.value = hv.version
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
</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); }
|
||||||
|
</style>
|
||||||
100
packages/frontend/src/pages/admin/AdminLogin.vue
Executable file
100
packages/frontend/src/pages/admin/AdminLogin.vue
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1 class="login-title">{{ siteName || 'CloudSearch' }} 管理后台</h1>
|
||||||
|
<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" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { User, Lock } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getSiteConfig, adminLogin } from '../../api'
|
||||||
|
import type { ElForm } from 'element-plus'
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const siteName = ref('')
|
||||||
|
|
||||||
|
// 获取网站名称
|
||||||
|
getSiteConfig().then(cfg => {
|
||||||
|
if (cfg.site_name) siteName.value = cfg.site_name
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const res = await adminLogin(form.username, form.password)
|
||||||
|
localStorage.setItem('admin_token', res.token)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
window.location.href = '/admin'
|
||||||
|
} catch (e: any) {
|
||||||
|
errorMsg.value = e?.response?.data?.message || e?.message || '登录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.error-msg {
|
||||||
|
text-align: center;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
194
packages/frontend/src/pages/admin/Cleanup.vue
Normal file
194
packages/frontend/src/pages/admin/Cleanup.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getSystemConfigs, updateSystemConfigs, runCleanup, emptyAllTrash } from '../../api'
|
||||||
|
|
||||||
|
interface ConfigMap {
|
||||||
|
[key: string]: string | number
|
||||||
|
}
|
||||||
|
const sysConfigs = reactive<ConfigMap>({})
|
||||||
|
|
||||||
|
const cleanupRunning = ref(false)
|
||||||
|
const emptyTrashRunning = ref(false)
|
||||||
|
const cleanupSaving = ref(false)
|
||||||
|
|
||||||
|
const lastCleanupTime = computed(() => String(sysConfigs.cleanup_last_run || ''))
|
||||||
|
const lastCleanupStats = computed(() => {
|
||||||
|
const raw = String(sysConfigs.cleanup_last_stats || '')
|
||||||
|
if (!raw) return ''
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw)
|
||||||
|
const parts = []
|
||||||
|
if (s.filesTrashed > 0) parts.push(`移入回收站 ${s.filesTrashed} 个文件夹`)
|
||||||
|
if (s.logsDeleted > 0) parts.push(`删除 ${s.logsDeleted} 条日志`)
|
||||||
|
if (s.trashEmptied) parts.push(`已清空回收站`)
|
||||||
|
if (s.errors > 0) parts.push(`⚠️ ${s.errors} 个错误`)
|
||||||
|
return parts.join(' / ') || '无操作'
|
||||||
|
} catch { return '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanupEnabled = computed({
|
||||||
|
get: () => String(sysConfigs.cleanup_enabled) === 'true',
|
||||||
|
set: (val: boolean) => { sysConfigs.cleanup_enabled = val ? 'true' : 'false' },
|
||||||
|
})
|
||||||
|
const cleanupEmptyTrash = computed({
|
||||||
|
get: () => String(sysConfigs.cleanup_empty_trash) !== 'false',
|
||||||
|
set: (val: boolean) => { sysConfigs.cleanup_empty_trash = val ? 'true' : 'false' },
|
||||||
|
})
|
||||||
|
const cleanupFileRetentionDays = computed({
|
||||||
|
get: () => Number(sysConfigs.cleanup_file_retention_days ?? 7),
|
||||||
|
set: (val: number) => { sysConfigs.cleanup_file_retention_days = val },
|
||||||
|
})
|
||||||
|
const cleanupLogRetentionDays = computed({
|
||||||
|
get: () => Number(sysConfigs.cleanup_log_retention_days ?? 30),
|
||||||
|
set: (val: number) => { sysConfigs.cleanup_log_retention_days = val },
|
||||||
|
})
|
||||||
|
const cleanupSpaceThresholdEnabled = computed({
|
||||||
|
get: () => String(sysConfigs.cleanup_space_threshold_enabled) === 'true',
|
||||||
|
set: (val: boolean) => { sysConfigs.cleanup_space_threshold_enabled = val ? 'true' : 'false' },
|
||||||
|
})
|
||||||
|
const cleanupSpaceThresholdPercent = computed({
|
||||||
|
get: () => Number(sysConfigs.cleanup_space_threshold_percent ?? 90),
|
||||||
|
set: (val: number) => { sysConfigs.cleanup_space_threshold_percent = val },
|
||||||
|
})
|
||||||
|
const cleanupSpaceThresholdDeletePercent = computed({
|
||||||
|
get: () => Number(sysConfigs.cleanup_space_threshold_delete_percent ?? 10),
|
||||||
|
set: (val: number) => { sysConfigs.cleanup_space_threshold_delete_percent = val },
|
||||||
|
})
|
||||||
|
const saveReuseEnabled = computed({
|
||||||
|
get: () => String(sysConfigs.save_reuse_enabled) !== 'false',
|
||||||
|
set: (val: boolean) => { sysConfigs.save_reuse_enabled = val ? 'true' : 'false' },
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCleanupConfigs() {
|
||||||
|
try {
|
||||||
|
const raw = await getSystemConfigs()
|
||||||
|
for (const cfg of raw) {
|
||||||
|
sysConfigs[cfg.key] = cfg.value
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载清理配置失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 entries = keys.map(key => ({ key, value: String(sysConfigs[key] ?? '') }))
|
||||||
|
await updateSystemConfigs(entries)
|
||||||
|
ElMessage.success('清理配置已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||||
|
} finally {
|
||||||
|
cleanupSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRunCleanup() {
|
||||||
|
cleanupRunning.value = true
|
||||||
|
try {
|
||||||
|
const result = await runCleanup()
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(result.message)
|
||||||
|
}
|
||||||
|
await loadCleanupConfigs()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '清理失败')
|
||||||
|
} finally {
|
||||||
|
cleanupRunning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmptyTrash() {
|
||||||
|
emptyTrashRunning.value = true
|
||||||
|
try {
|
||||||
|
const result = await emptyAllTrash()
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(result.message)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(result.message)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '清空回收站失败')
|
||||||
|
} finally {
|
||||||
|
emptyTrashRunning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCleanupConfigs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cleanup-section .config-card {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.cleanup-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
701
packages/frontend/src/pages/admin/CloudConfig.vue
Executable file
701
packages/frontend/src/pages/admin/CloudConfig.vue
Executable file
@@ -0,0 +1,701 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cloud-config">
|
||||||
|
<!-- 网盘类型开关 -->
|
||||||
|
<el-card class="toggle-card" style="margin-bottom: 20px;">
|
||||||
|
<template #header><span>📂 网盘设置及授权</span></template>
|
||||||
|
<div class="cloud-toggle-grid">
|
||||||
|
<div
|
||||||
|
v-for="ct in cloudTypes"
|
||||||
|
:key="ct.type"
|
||||||
|
class="cloud-toggle-chip"
|
||||||
|
>
|
||||||
|
<img :src="ct.icon" class="cloud-icon-img" />
|
||||||
|
<span class="cloud-label">{{ ct.label }}</span>
|
||||||
|
<el-tag v-if="ct.type === 'others'" size="small" type="info">关</el-tag>
|
||||||
|
<el-switch
|
||||||
|
:model-value="ct.enabled"
|
||||||
|
size="small"
|
||||||
|
@change="(val: boolean) => handleCloudToggle(ct.type, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-tip" style="margin-top: 12px;">
|
||||||
|
关闭的网盘类型在搜索结果中不会展示。修改后立即生效,无需点击保存。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openDialog(null)">新增配置</el-button>
|
||||||
|
<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-table-column>
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.nickname" class="nickname-text">{{ row.nickname }}</span>
|
||||||
|
<el-text v-else type="info" size="small">未设置</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="promotion_account" label="推广账号" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.promotion_account" class="promotion-text">{{ row.promotion_account }}</span>
|
||||||
|
<el-text v-else type="info" size="small">-</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="cloud_type_uid" label="标识(__uid)" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.cloud_type_uid" class="uid-cell">{{ row.cloud_type_uid }}</span>
|
||||||
|
<el-text v-else type="info" size="small">-</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="验证" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row._verifying" class="verifying">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
</span>
|
||||||
|
<el-tag v-else-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
|
||||||
|
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未验证</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="空间" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="row.storage_total && row.storage_total !== '-'" class="storage-cell">
|
||||||
|
<div class="storage-bar-wrap">
|
||||||
|
<div
|
||||||
|
class="storage-bar-fill"
|
||||||
|
:style="{ width: storagePercent(row) + '%' }"
|
||||||
|
:class="storageBarClass(row)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="storage-text">
|
||||||
|
<span class="storage-used">{{ row.storage_used || '计算中...' }}</span>
|
||||||
|
<span class="storage-sep">/</span>
|
||||||
|
<span class="storage-total">{{ row.storage_total }}</span>
|
||||||
|
<span class="storage-free">(可用 {{ storageFree(row) }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-text v-else type="info" size="small">—</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<!-- 转存统计 -->
|
||||||
|
<el-table-column label="转存数" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.total_saves > 0" class="save-count">{{ row.total_saves }}次</span>
|
||||||
|
<el-text v-else type="info" size="small">-</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="转存启用" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-switch
|
||||||
|
:model-value="row.is_transfer_enabled !== 0"
|
||||||
|
size="small"
|
||||||
|
@change="(val: boolean) => handleToggleTransfer(row, val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="390" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
|
||||||
|
<el-button text type="primary" @click="verifyOne(row)">验证</el-button>
|
||||||
|
<el-popconfirm title="确定删除该配置?" @confirm="handleDelete(row)">
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑配置' : '新增配置'" width="560px">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||||
|
<el-form-item label="网盘类型" prop="cloud_type">
|
||||||
|
<el-select v-model="form.cloud_type" style="width: 100%" :disabled="!!editingId" @change="onCloudTypeChange">
|
||||||
|
<el-option
|
||||||
|
v-for="[key, label] in cloudTypeOptions"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="推广平台及账号" prop="promotion_account" style="margin-bottom: 18px;">
|
||||||
|
<el-input
|
||||||
|
v-model="form.promotion_account"
|
||||||
|
placeholder="请填写您的推广平台及账号,例:蜂小推-13288889999"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Cookie" prop="cookie">
|
||||||
|
<el-input
|
||||||
|
v-model="form.cookie"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 4 }"
|
||||||
|
:placeholder="cookiePlaceholder"
|
||||||
|
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}`">
|
||||||
|
<div class="cookie-tips-header">
|
||||||
|
<span class="cookie-tips-title">📖 {{ cloudTypeLabel }} Cookie 获取教程</span>
|
||||||
|
</div>
|
||||||
|
<ol class="cookie-tips-steps" v-html="cookieTutorialHtml"></ol>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { CLOUD_LABELS } from '../../types'
|
||||||
|
import type { CloudType, CloudConfig } from '../../types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
|
||||||
|
import CloudBadge from '../../components/CloudBadge.vue'
|
||||||
|
import type { ElForm } from 'element-plus'
|
||||||
|
|
||||||
|
interface CloudTypeInfo { type: string; label: string; icon: string; enabled: boolean }
|
||||||
|
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||||
|
const configs = ref<(CloudConfig & { _verifying?: boolean })[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const defaultForm = () => ({
|
||||||
|
cloud_type: '' as CloudType | '',
|
||||||
|
nickname: '',
|
||||||
|
promotion_account: '',
|
||||||
|
is_transfer_enabled: false,
|
||||||
|
cookie: '',
|
||||||
|
_verifying: false,
|
||||||
|
_storageUsed: '',
|
||||||
|
_storageTotal: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
cloud_type: CloudType | ''
|
||||||
|
nickname: string
|
||||||
|
promotion_account: string
|
||||||
|
is_transfer_enabled: boolean
|
||||||
|
cookie: string
|
||||||
|
_verifying: boolean
|
||||||
|
_storageUsed: string
|
||||||
|
_storageTotal: string
|
||||||
|
}>(defaultForm())
|
||||||
|
|
||||||
|
const rules = computed(() => ({
|
||||||
|
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
|
||||||
|
nickname: [{ required: false, message: '请填写昵称(区分多个同类型网盘)', trigger: 'blur' }],
|
||||||
|
promotion_account: [{ required: true, message: '请填写推广平台及账号', trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cloudTypeOptions = computed(() => {
|
||||||
|
return Object.entries(CLOUD_LABELS) as [CloudType, string][]
|
||||||
|
})
|
||||||
|
|
||||||
|
const cookiePlaceholder = computed(() => {
|
||||||
|
if (!form.cloud_type) return '请先选择网盘类型'
|
||||||
|
const t = form.cloud_type
|
||||||
|
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
|
||||||
|
return editingId.value ? '留空则保持原有' : '输入完整 Cookie'
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloudTypeLabel = computed(() => {
|
||||||
|
return CLOUD_LABELS[form.cloud_type as CloudType] || form.cloud_type || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Cookie 获取教程 HTML(根据不同网盘类型) */
|
||||||
|
const cookieTutorialHtml = computed(() => {
|
||||||
|
const t = form.cloud_type
|
||||||
|
if (!t) return ''
|
||||||
|
const tutorials: Record<string, string> = {
|
||||||
|
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||||
|
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code>)</li>
|
||||||
|
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||||
|
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
|
||||||
|
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||||
|
<div class="cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>(F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
|
||||||
|
|
||||||
|
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
|
||||||
|
<li>刷新页面,在请求列表中点击任意一个请求</li>
|
||||||
|
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
|
||||||
|
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
|
||||||
|
<li>点击「<b>自动获取</b>」按钮验证 Cookie 是否有效</li>
|
||||||
|
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
|
||||||
|
|
||||||
|
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
|
||||||
|
|
||||||
|
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
|
||||||
|
|
||||||
|
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>
|
||||||
|
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
|
||||||
|
|
||||||
|
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
|
||||||
|
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`,
|
||||||
|
}
|
||||||
|
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
|
||||||
|
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
|
||||||
|
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
|
||||||
|
<li>粘贴到上方输入框,点击「自动获取」验证</li>`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadConfigs()
|
||||||
|
await loadCloudTypes()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 每30分钟自动验证一次
|
||||||
|
let verifyTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
onMounted(() => {
|
||||||
|
verifyTimer = setInterval(() => {
|
||||||
|
autoVerifyAll()
|
||||||
|
}, 30 * 60 * 1000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (verifyTimer) clearInterval(verifyTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCloudTypes() {
|
||||||
|
try {
|
||||||
|
const result = await getCloudTypes()
|
||||||
|
cloudTypes.value = result.types
|
||||||
|
} catch (e) { console.error('加载网盘类型失败', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloudToggle(type: string, enabled: boolean) {
|
||||||
|
const ct = cloudTypes.value.find(c => c.type === type)
|
||||||
|
if (!ct) return
|
||||||
|
try {
|
||||||
|
await toggleCloudType(type, enabled)
|
||||||
|
ct.enabled = enabled
|
||||||
|
} catch (e: any) { ElMessage.error(e.message || '切换失败'); ct.enabled = !enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigs() {
|
||||||
|
try {
|
||||||
|
configs.value = await getCloudConfigs()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载网盘配置失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
|
||||||
|
const newVal = enabled ? 1 : 0
|
||||||
|
try {
|
||||||
|
await updateCloudConfig({
|
||||||
|
id: row.id!,
|
||||||
|
cloud_type: row.cloud_type,
|
||||||
|
nickname: row.nickname || '',
|
||||||
|
promotion_account: row.promotion_account || '',
|
||||||
|
is_transfer_enabled: newVal,
|
||||||
|
is_active: row.is_active !== 0,
|
||||||
|
cookie: undefined, // don't send cookie on toggle-only
|
||||||
|
})
|
||||||
|
row.is_transfer_enabled = newVal
|
||||||
|
ElMessage.success(enabled ? '转存已开启' : '转存已关闭')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoVerifyAll() {
|
||||||
|
for (const cfg of configs.value) {
|
||||||
|
if (cfg.cookie_preview || cfg.nickname) {
|
||||||
|
await verifyOne(cfg, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAll() {
|
||||||
|
for (const cfg of configs.value) {
|
||||||
|
if ((cfg.cookie_preview || cfg.nickname) && !cfg._verifying) {
|
||||||
|
await verifyOne(cfg, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success('全部验证完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOne(row: CloudConfig & { _verifying?: boolean }, silent = false) {
|
||||||
|
if (!row.cookie_preview && !row.nickname) {
|
||||||
|
if (!silent) ElMessage.warning('该配置没有 Cookie,请先编辑保存后再验证')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row._verifying = true
|
||||||
|
try {
|
||||||
|
const result = await testCloudConnection(row.cloud_type, undefined, row.id)
|
||||||
|
row.verification_status = result.success ? 'valid' : 'invalid'
|
||||||
|
if (result.success) {
|
||||||
|
if (result.nickname && !row.nickname) row.nickname = result.nickname
|
||||||
|
if (result.storage_used) row.storage_used = result.storage_used
|
||||||
|
if (result.storage_total) row.storage_total = result.storage_total
|
||||||
|
if (!silent) ElMessage.success(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||||
|
} else {
|
||||||
|
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
row.verification_status = 'invalid'
|
||||||
|
if (!silent) ElMessage.error(`${CLOUD_LABELS[row.cloud_type]}:验证失败`)
|
||||||
|
} finally {
|
||||||
|
row._verifying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
form.cloud_type = row.cloud_type
|
||||||
|
form.nickname = row.nickname || ''
|
||||||
|
form.promotion_account = row.promotion_account || ''
|
||||||
|
form.is_transfer_enabled = row.is_transfer_enabled !== 0
|
||||||
|
form.cookie = row.cookie || ''
|
||||||
|
form._verifying = false
|
||||||
|
form._storageUsed = ''
|
||||||
|
form._storageTotal = ''
|
||||||
|
} else {
|
||||||
|
editingId.value = null
|
||||||
|
form.cloud_type = '' as CloudType | ''
|
||||||
|
form.nickname = ''
|
||||||
|
form.promotion_account = ''
|
||||||
|
form.is_transfer_enabled = false
|
||||||
|
form.cookie = ''
|
||||||
|
form._verifying = false
|
||||||
|
form._storageUsed = ''
|
||||||
|
form._storageTotal = ''
|
||||||
|
}
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloudTypeChange() {
|
||||||
|
// Cookie 输入框提示会自动更新(computed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
// 1. 表单校验(含推广账号必填)
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 2. 如果有 Cookie,先验证 Cookie
|
||||||
|
if (form.cookie) {
|
||||||
|
try {
|
||||||
|
const verifyResult = await testCloudConnection(form.cloud_type as CloudType, form.cookie)
|
||||||
|
if (!verifyResult.success) {
|
||||||
|
ElMessage.error(`Cookie验证失败:${verifyResult.message}`)
|
||||||
|
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({
|
||||||
|
id: editingId.value,
|
||||||
|
cloud_type: form.cloud_type as CloudType,
|
||||||
|
nickname: form.nickname,
|
||||||
|
promotion_account: form.promotion_account,
|
||||||
|
is_transfer_enabled: form.is_transfer_enabled,
|
||||||
|
cookie: form.cookie || undefined,
|
||||||
|
is_active: true,
|
||||||
|
storage_used: form._storageUsed || undefined,
|
||||||
|
storage_total: form._storageTotal || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('配置更新成功')
|
||||||
|
} else {
|
||||||
|
const saved = await saveCloudConfig({
|
||||||
|
cloud_type: form.cloud_type as CloudType,
|
||||||
|
nickname: form.nickname,
|
||||||
|
promotion_account: form.promotion_account,
|
||||||
|
is_transfer_enabled: form.is_transfer_enabled,
|
||||||
|
cookie: form.cookie,
|
||||||
|
is_active: true,
|
||||||
|
storage_used: form._storageUsed || undefined,
|
||||||
|
storage_total: form._storageTotal || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('配置保存成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
editingId.value = null
|
||||||
|
await loadConfigs()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.error || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: CloudConfig) {
|
||||||
|
try {
|
||||||
|
await deleteCloudConfig(row.id!)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadConfigs()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析字节数 → 数值 */
|
||||||
|
function parseBytes(s: string): number {
|
||||||
|
const m = s.match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i)
|
||||||
|
if (!m) return 0
|
||||||
|
const n = parseFloat(m[1])
|
||||||
|
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024**2, GB: 1024**3, TB: 1024**4 }
|
||||||
|
return n * (units[m[2].toUpperCase()] || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePercent(row: CloudConfig): number {
|
||||||
|
if (!row.storage_total || row.storage_total === '-' || !row.storage_used) return 0
|
||||||
|
const total = parseBytes(row.storage_total)
|
||||||
|
const used = parseBytes(row.storage_used)
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.min(100, Math.round((used / total) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageBarClass(row: CloudConfig): string {
|
||||||
|
const pct = storagePercent(row)
|
||||||
|
if (pct >= 90) return 'bar-danger'
|
||||||
|
if (pct >= 70) return 'bar-warning'
|
||||||
|
return 'bar-normal'
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageFree(row: CloudConfig): string {
|
||||||
|
if (!row.storage_total || row.storage_total === '-') return '?'
|
||||||
|
if (!row.storage_used) return '计算中...'
|
||||||
|
const total = parseBytes(row.storage_total)
|
||||||
|
const used = parseBytes(row.storage_used)
|
||||||
|
if (total === 0) return '?'
|
||||||
|
const free = total - used
|
||||||
|
if (free < 1024) return '小于 1 KB'
|
||||||
|
if (free < 1024 * 1024) return (free / 1024).toFixed(1) + ' KB'
|
||||||
|
if (free < 1024 * 1024 * 1024) return (free / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
if (free < 1024 * 1024 * 1024 * 1024) return (free / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||||
|
return (free / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cloud-config {
|
||||||
|
background: var(--bg-white);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sign-summary-tag {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.nickname-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.promotion-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.uid-cell {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
/* 空间进度条 */
|
||||||
|
.storage-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.storage-bar-wrap {
|
||||||
|
height: 4px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.storage-bar-fill.bar-normal { background: #67c23a; }
|
||||||
|
.storage-bar-fill.bar-warning { background: #e6a23c; }
|
||||||
|
.storage-bar-fill.bar-danger { background: #f56c6c; }
|
||||||
|
.storage-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cookie 教程卡片 */
|
||||||
|
.cookie-tips-item :deep(.el-form-item__content) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
.cookie-tips {
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #e8f0fe;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #606266;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cookie-tips-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.cookie-tips-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.cookie-tips-steps code {
|
||||||
|
background: #ecf5ff;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
.cookie-tips-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #fff3c4;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8a6d3b;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cookie-tips-note code {
|
||||||
|
background: #f5f0e0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
774
packages/frontend/src/pages/admin/SaveRecords.vue
Executable file
774
packages/frontend/src/pages/admin/SaveRecords.vue
Executable file
@@ -0,0 +1,774 @@
|
|||||||
|
<template>
|
||||||
|
<div class="save-records">
|
||||||
|
<!-- ── Toolbar ── -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<div class="filter-group">
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 100px" @change="loadRecords(1)">
|
||||||
|
<el-option label="全部状态" value="" />
|
||||||
|
<el-option label="✓ 成功" value="success" />
|
||||||
|
<el-option label="♻️ 复用" value="reused" />
|
||||||
|
<el-option label="✗ 失败" value="failed" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select v-model="cloudFilter" placeholder="网盘" clearable style="width: 100px" @change="loadRecords(1)">
|
||||||
|
<el-option label="全部网盘" value="" />
|
||||||
|
<el-option v-for="ct in cloudTypes" :key="ct" :label="cloudLabel(ct)" :value="ct">
|
||||||
|
<span :style="{ display: 'inline-flex', alignItems: 'center', gap: '6px' }">
|
||||||
|
<img :src="cloudIcon(ct)" style="width:16px;height:16px" />
|
||||||
|
{{ cloudLabel(ct) }}
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<div class="time-btns">
|
||||||
|
<button
|
||||||
|
v-for="btn in timeButtons" :key="btn.key"
|
||||||
|
:class="['time-btn', { active: activeTimeBtn === btn.key }]"
|
||||||
|
@click="setTimeFilter(btn.key)"
|
||||||
|
>{{ btn.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 220px"
|
||||||
|
@change="onDateRangeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索资源名称…"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@clear="loadRecords(1)"
|
||||||
|
@keyup.enter="loadRecords(1)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button size="small" @click="resetFilters">重置筛选</el-button>
|
||||||
|
<span class="record-count">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 转存统计汇总 ── -->
|
||||||
|
<div v-if="summary" class="save-summary">
|
||||||
|
<span class="summary-item summary-all">📊 共 <strong>{{ summary.total }}</strong> 条</span>
|
||||||
|
<span class="summary-divider">|</span>
|
||||||
|
<span class="summary-item summary-success">✅ 成功 <strong>{{ summary.success }}</strong></span>
|
||||||
|
<span class="summary-item summary-reused">♻️ 复用 <strong>{{ summary.reused }}</strong></span>
|
||||||
|
<span class="summary-item summary-failed">❌ 失败 <strong>{{ summary.failed }}</strong></span>
|
||||||
|
<span class="summary-item summary-rate" v-if="summary.total > 0">
|
||||||
|
成功率 <strong>{{ ((summary.success + summary.reused) / summary.total * 100).toFixed(1) }}%</strong>
|
||||||
|
</span>
|
||||||
|
</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-table-column type="expand" width="36">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="expand-detail">
|
||||||
|
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数量 -->
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-cell">
|
||||||
|
<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>
|
||||||
|
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||||
|
<span class="detail-label">文件夹</span>
|
||||||
|
<span><strong>{{ row.folder_count || 0 }}</strong> 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
|
||||||
|
<span class="detail-label">文件</span>
|
||||||
|
<span><strong>{{ row.file_count || 0 }}</strong> 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-cell" v-if="row.status === 'reused'">
|
||||||
|
<span class="detail-label">复用方式</span>
|
||||||
|
<span class="reuse-msg">♻️ 直接使用已有分享链接,无需实际转存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-cell" v-if="row.share_url">
|
||||||
|
<span class="detail-label">分享链接</span>
|
||||||
|
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="detail-cell" v-if="row.share_pwd">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<!-- Row 3: IP地址 + 归属地 -->
|
||||||
|
<div class="detail-row" v-if="row.ip_address">
|
||||||
|
<div class="detail-cell">
|
||||||
|
<span class="detail-label">IP 地址</span>
|
||||||
|
<code class="detail-code">{{ row.ip_address }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="detail-cell" v-if="row.ip_location">
|
||||||
|
<span class="detail-label">归属地</span>
|
||||||
|
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 4: 错误信息(整行) -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="序号" width="68" align="center">
|
||||||
|
<template #default="{ $index }">
|
||||||
|
{{ (currentPage - 1) * pageSize + $index + 1 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="时间" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :title="row.created_at">{{ formatTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="网盘" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="cloudLabel(row.source_type)" placement="top">
|
||||||
|
<img :src="cloudIcon(row.source_type)" style="width:22px;height:22px;cursor:default" />
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="72" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="statusTip(row.status)" placement="top">
|
||||||
|
<span :class="['status-badge', statusClass(row.status)]">
|
||||||
|
{{ statusIcon(row.status) }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="资源名称" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :title="row.source_title || ''">{{ row.source_title || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="耗时" width="85" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['duration', durationClass(row.duration_ms)]">
|
||||||
|
{{ formatDuration(row.duration_ms) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="归属地" min-width="130" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.ip_location" class="loc-badge">{{ formatLocation(row.ip_location) }}</span>
|
||||||
|
<span v-else class="no-data">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="备注" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'failed' && row.error_message" class="err-msg" :title="row.error_message">
|
||||||
|
{{ truncateErr(row.error_message) }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="row.status === 'failed'" class="err-msg">失败</span>
|
||||||
|
<span v-else-if="row.status === 'reused'" class="reuse-msg">♻️ 复用已有链接</span>
|
||||||
|
<span v-else class="no-data">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="80" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-cell">
|
||||||
|
<el-tooltip content="复制分享链接" placement="top">
|
||||||
|
<el-button size="small" circle text :disabled="!row.share_url" @click="copyText(row.share_url!)">
|
||||||
|
<el-icon><Link /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="打开分享链接" placement="top">
|
||||||
|
<el-button size="small" circle text :disabled="!row.share_url" @click="windowOpen(row.share_url!)">
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Pagination ── -->
|
||||||
|
<div class="pagination-wrap" v-if="total > 0">
|
||||||
|
<div class="pagination-info">
|
||||||
|
第 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, total) }} 条,共 {{ total }} 条
|
||||||
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[15, 20, 30, 50, 100]"
|
||||||
|
layout="sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="loadRecords"
|
||||||
|
@size-change="loadRecords(1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getSaveRecords, getCloudTypes } from '../../api'
|
||||||
|
import type { SaveRecord } from '../../api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, CopyDocument, Link, TopRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const records = ref<SaveRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const cloudFilter = ref('')
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const activeTimeBtn = ref('today')
|
||||||
|
const timeStart = ref('')
|
||||||
|
const timeEnd = ref('')
|
||||||
|
const dateRange = ref<string[] | null>(null)
|
||||||
|
const cloudTypes = ref<string[]>([])
|
||||||
|
const summary = ref<{ total: number; success: number; failed: number; reused: number } | null>(null)
|
||||||
|
|
||||||
|
const timeButtons = [
|
||||||
|
{ key: 'today', label: '今日' },
|
||||||
|
{ key: 'week', label: '本周' },
|
||||||
|
{ key: 'month', label: '本月' },
|
||||||
|
{ key: 'lastMonth', label: '上月' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Cloud type helpers — loaded from backend API ──
|
||||||
|
const cloudTypeMap = ref<Record<string, { label: string; icon: string }>>({})
|
||||||
|
const FALLBACK_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><rect rx="4" width="24" height="24" fill="#909399"/><text x="12" y="16" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">☁</text></svg>'
|
||||||
|
|
||||||
|
async function loadCloudTypes() {
|
||||||
|
try {
|
||||||
|
const res = await getCloudTypes()
|
||||||
|
const map: Record<string, { label: string; icon: string }> = {}
|
||||||
|
for (const ct of res.types) {
|
||||||
|
map[ct.type] = { label: ct.label, icon: ct.icon }
|
||||||
|
}
|
||||||
|
cloudTypeMap.value = map
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cloudLabel(t: string): string { return cloudTypeMap.value[t]?.label || t }
|
||||||
|
function cloudIcon(t: string): string { return cloudTypeMap.value[t]?.icon || FALLBACK_ICON_SVG }
|
||||||
|
|
||||||
|
function extractCloudTypes(data: SaveRecord[]) {
|
||||||
|
const set = new Set<string>()
|
||||||
|
data.forEach(r => { if (r.source_type) set.add(r.source_type) })
|
||||||
|
const existing = new Set(cloudTypes.value)
|
||||||
|
set.forEach(t => { if (!existing.has(t)) cloudTypes.value.push(t) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting helpers ──
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const M = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const D = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${M}-${D}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string): string {
|
||||||
|
if (!t) return '-'
|
||||||
|
let ts = t
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)) ts = ts.replace(' ', 'T') + '+08:00'
|
||||||
|
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())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (!ms) return '-'
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationClass(ms: number): string {
|
||||||
|
if (!ms) return ''
|
||||||
|
if (ms > 30000) return 'dur-slow'
|
||||||
|
if (ms > 10000) return 'dur-warn'
|
||||||
|
return 'dur-fast'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileCountType(n: number): string {
|
||||||
|
if (n >= 50) return 'danger'
|
||||||
|
if (n >= 10) return 'warning'
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateErr(msg: string): string {
|
||||||
|
return msg.length > 50 ? msg.slice(0, 50) + '…' : msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status helpers ──
|
||||||
|
function statusTip(status: string): string {
|
||||||
|
if (status === 'success') return '转存成功'
|
||||||
|
if (status === 'reused') return '♻️ 复用已有分享链接'
|
||||||
|
return '转存失败'
|
||||||
|
}
|
||||||
|
function statusClass(status: string): string {
|
||||||
|
if (status === 'success') return 'status-ok'
|
||||||
|
if (status === 'reused') return 'status-reuse'
|
||||||
|
return 'status-fail'
|
||||||
|
}
|
||||||
|
function statusIcon(status: string): string {
|
||||||
|
if (status === 'success') return '✓'
|
||||||
|
if (status === 'reused') return '♻️'
|
||||||
|
return '✗'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 省份/城市中英文翻译(用于 api.ip.sb 返回的英语地名) */
|
||||||
|
const CN_PLACES: Record<string, string> = {
|
||||||
|
// 省份/直辖市
|
||||||
|
'Anhui':'安徽','Beijing':'北京','Chongqing':'重庆','Fujian':'福建','Gansu':'甘肃','Guangdong':'广东',
|
||||||
|
'Guangxi':'广西','Guizhou':'贵州','Hainan':'海南','Hebei':'河北','Henan':'河南','Heilongjiang':'黑龙江',
|
||||||
|
'Hubei':'湖北','Hunan':'湖南','Inner Mongolia':'内蒙古','Jiangsu':'江苏','Jiangxi':'江西','Jilin':'吉林',
|
||||||
|
'Liaoning':'辽宁','Ningxia':'宁夏','Qinghai':'青海','Shaanxi':'陕西','Shandong':'山东','Shanghai':'上海',
|
||||||
|
'Shanxi':'山西','Sichuan':'四川','Tianjin':'天津','Tibet':'西藏','Xinjiang':'新疆','Yunnan':'云南','Zhejiang':'浙江',
|
||||||
|
'Hong Kong':'香港','Macau':'澳门','Taiwan':'台湾',
|
||||||
|
// 主要城市
|
||||||
|
'Changsha':'长沙','Hefei':'合肥','Fuzhou':'福州','Lanzhou':'兰州',
|
||||||
|
'Guangzhou':'广州','Nanning':'南宁','Guiyang':'贵阳','Haikou':'海口',
|
||||||
|
'Shijiazhuang':'石家庄','Zhengzhou':'郑州','Harbin':'哈尔滨','Wuhan':'武汉',
|
||||||
|
'Nanjing':'南京','Nanchang':'南昌','Changchun':'长春','Shenyang':'沈阳',
|
||||||
|
'Yinchuan':'银川','Xining':'西宁',"Xi'an":"西安",'Jinan':'济南',
|
||||||
|
'Taiyuan':'太原','Chengdu':'成都','Shenzhen':'深圳','Hangzhou':'杭州',
|
||||||
|
'Suzhou':'苏州','Wuxi':'无锡','Ningbo':'宁波','Dongguan':'东莞',
|
||||||
|
'Foshan':'佛山','Zhuhai':'珠海','Qingdao':'青岛','Dalian':'大连',
|
||||||
|
'Xiamen':'厦门','Kunming':'昆明','Lhasa':'拉萨','Urumqi':'乌鲁木齐',
|
||||||
|
'Linyi':'临沂','Wenzhou':'温州','Quanzhou':'泉州',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 常见英文ISP → 中文 */
|
||||||
|
const CN_ISP: Record<string, string> = {
|
||||||
|
'China Telecom':'中国电信','China Mobile':'中国移动','China Unicom':'中国联通',
|
||||||
|
'Chinanet':'中国电信','ChinaNet':'中国电信','CMNET':'中国移动',
|
||||||
|
'CNC Group':'中国联通','unicom':'中国联通','telecom':'中国电信','mobile':'中国移动',
|
||||||
|
'China Education and Research Network':'教育网','CERNET':'教育网',
|
||||||
|
'China Networks':'中国网络','China163':'中国电信','CHINANET BACKBONE':'中国电信',
|
||||||
|
'Tencent Cloud':'腾讯云','Alibaba Cloud':'阿里云','Aliyun':'阿里云','Huawei Cloud':'华为云',
|
||||||
|
'Baidu':'百度','Beijing Baidu':'百度',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLocation(loc: string): string {
|
||||||
|
// Remove "China" prefix
|
||||||
|
let s = loc.replace(/^(中国|China)\s*/i, '')
|
||||||
|
// Split into parts
|
||||||
|
const parts = s.split(/\s+/).filter(Boolean)
|
||||||
|
// Translate each part
|
||||||
|
return parts.map(p => CN_PLACES[p] || CN_ISP[p] || p).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClassName({ row }: { row: SaveRecord }) {
|
||||||
|
return row.status === 'failed' ? 'row-failed' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ──
|
||||||
|
async function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowOpen(url: string) {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpandChange(row: SaveRecord, expanded: boolean[]) {
|
||||||
|
// just placeholder for potential future use
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter helpers ──
|
||||||
|
function setTimeFilter(key: string) {
|
||||||
|
activeTimeBtn.value = key
|
||||||
|
dateRange.value = null
|
||||||
|
const now = new Date()
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = now.getMonth()
|
||||||
|
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
switch (key) {
|
||||||
|
case 'today':
|
||||||
|
start = new Date(y, m, now.getDate())
|
||||||
|
end = start
|
||||||
|
break
|
||||||
|
case 'week': {
|
||||||
|
const dow = now.getDay()
|
||||||
|
start = new Date(y, m, now.getDate() + (dow === 0 ? -6 : 1 - dow))
|
||||||
|
end = now
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
start = new Date(y, m, 1)
|
||||||
|
end = now
|
||||||
|
break
|
||||||
|
case 'lastMonth':
|
||||||
|
start = new Date(y, m - 1, 1)
|
||||||
|
end = new Date(y, m, 0)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
start = new Date(y, m, now.getDate())
|
||||||
|
end = start
|
||||||
|
}
|
||||||
|
timeStart.value = fmt(start)
|
||||||
|
const nextDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1)
|
||||||
|
timeEnd.value = fmt(nextDay)
|
||||||
|
loadRecords(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateRangeChange(val: string[] | null) {
|
||||||
|
if (val && val.length === 2) {
|
||||||
|
activeTimeBtn.value = ''
|
||||||
|
timeStart.value = val[0]
|
||||||
|
const next = new Date(val[1])
|
||||||
|
next.setDate(next.getDate() + 1)
|
||||||
|
timeEnd.value = fmt(next)
|
||||||
|
loadRecords(1)
|
||||||
|
} else {
|
||||||
|
setTimeFilter('today')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
statusFilter.value = ''
|
||||||
|
cloudFilter.value = ''
|
||||||
|
searchKeyword.value = ''
|
||||||
|
dateRange.value = null
|
||||||
|
setTimeFilter('today')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(page = 1) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
currentPage.value = page
|
||||||
|
const s = statusFilter.value || undefined
|
||||||
|
const c = cloudFilter.value || undefined
|
||||||
|
const kw = searchKeyword.value || undefined
|
||||||
|
const res = await getSaveRecords(page, pageSize.value, timeStart.value, timeEnd.value, s, c, kw)
|
||||||
|
records.value = res.records
|
||||||
|
total.value = res.total
|
||||||
|
summary.value = res.summary || null
|
||||||
|
extractCloudTypes(res.records)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载转存记录失败', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeFilter('today')
|
||||||
|
loadCloudTypes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.save-records { padding: 0; }
|
||||||
|
|
||||||
|
/* ── Toolbar ── */
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light, #ebeef5);
|
||||||
|
}
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.record-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #909399);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Time buttons ── */
|
||||||
|
.time-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--el-fill-color-light, #f0f2f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
.time-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .25s ease;
|
||||||
|
}
|
||||||
|
.time-btn:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: rgba(64,158,255,.06);
|
||||||
|
}
|
||||||
|
.time-btn.active {
|
||||||
|
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3, #79bbff));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 6px rgba(64,158,255,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table ── */
|
||||||
|
.save-records :deep(.el-table) {
|
||||||
|
border: 1px solid var(--el-border-color-light, #ebeef5);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.save-records .el-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.save-records :deep(.el-table th.el-table__cell) {
|
||||||
|
background-color: var(--el-fill-color-light, #f5f7fa);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary, #303133);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.save-records :deep(.el-table .el-table__cell) {
|
||||||
|
padding: 8px 8px;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
.save-records :deep(.el-table .el-table__cell .cell) {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cell content: one-line with ellipsis ── */
|
||||||
|
.cell-nowrap {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.save-records :deep(.el-table__row:hover > .el-table__cell) {
|
||||||
|
background-color: var(--el-color-primary-light-9, #ecf5ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row highlight for failed */
|
||||||
|
.save-records :deep(.row-failed > .el-table__cell) {
|
||||||
|
background-color: rgba(245,108,108,.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status badge ── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.status-ok {
|
||||||
|
background: rgba(103,194,58,.15);
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
.status-reuse {
|
||||||
|
background: rgba(64,158,255,.15);
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.status-fail {
|
||||||
|
background: rgba(245,108,108,.15);
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cell helpers ── */
|
||||||
|
.ip-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.loc-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #e8f4ff, #f0f8ff);
|
||||||
|
color: #1a6ea0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #b8d9f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.err-msg {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.reuse-msg {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
color: var(--text-secondary, #c0c4cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Duration color ── */
|
||||||
|
.duration { font-size: 12px; font-family: monospace; }
|
||||||
|
.dur-fast { color: #67c23a; }
|
||||||
|
.dur-warn { color: #e6a23c; }
|
||||||
|
.dur-slow { color: #f56c6c; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Expand detail (flex rows) ── */
|
||||||
|
.expand-detail {
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--el-fill-color-lighter, #fafafa);
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.detail-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.detail-cell.detail-full {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
.detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.detail-link {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.detail-link:hover { text-decoration: underline; }
|
||||||
|
.detail-code {
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.detail-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f56c6c;
|
||||||
|
background: rgba(245,108,108,.08);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action cell ── */
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--el-fill-color-light, #f5f7fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #909399);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 转存统计汇总 ── */
|
||||||
|
.save-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--el-fill-color-light, #f5f7fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.summary-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.summary-success strong { color: #67c23a; }
|
||||||
|
.summary-failed strong { color: #f56c6c; }
|
||||||
|
.summary-reused strong { color: #e6a23c; }
|
||||||
|
.summary-rate { color: #909399; }
|
||||||
|
.summary-rate strong { color: #409eff; }
|
||||||
|
.summary-divider { color: #dcdfe6; font-size: 12px; user-select: none; }
|
||||||
|
</style>
|
||||||
318
packages/frontend/src/pages/admin/StatsView.vue
Executable file
318
packages/frontend/src/pages/admin/StatsView.vue
Executable file
@@ -0,0 +1,318 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stats-view">
|
||||||
|
<!-- 概览卡片 -->
|
||||||
|
<div class="overview-cards">
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value primary">{{ stats.todaySearches }}</div>
|
||||||
|
<div class="overview-label">今日搜索</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value success">{{ stats.todaySaves }}</div>
|
||||||
|
<div class="overview-label">今日保存</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value primary">{{ stats.monthSearches }}</div>
|
||||||
|
<div class="overview-label">本月搜索</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value success">{{ stats.monthSaves }}</div>
|
||||||
|
<div class="overview-label">本月保存</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value info">{{ stats.totalSearches }}</div>
|
||||||
|
<div class="overview-label">总搜索量</div>
|
||||||
|
</el-card>
|
||||||
|
<el-card class="overview-card" shadow="never">
|
||||||
|
<div class="overview-value warning">{{ stats.totalSaves }}</div>
|
||||||
|
<div class="overview-label">总保存量</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<!-- 近7天趋势 -->
|
||||||
|
<el-card class="stats-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>📈 近 7 天趋势</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="stats.trendTrend" stripe size="small" style="width: 100%">
|
||||||
|
<el-table-column label="日期" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.date) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="搜索" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="primary" effect="plain">{{ row.searches }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="保存" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="success" effect="plain">{{ row.saves }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="趋势" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="trend-bar-wrap">
|
||||||
|
<div class="trend-bar" :style="{ width: trendBarWidth(row) + '%', background: trendBarColor(row) }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 热门搜索关键词 -->
|
||||||
|
<el-card class="stats-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>🔥 热门搜索关键词 Top 20</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="stats.hotKeywords" stripe size="small" style="width: 100%" :max-height="480">
|
||||||
|
<el-table-column type="index" label="排名" width="60" />
|
||||||
|
<el-table-column prop="keyword" label="关键词" min-width="200" />
|
||||||
|
<el-table-column prop="count" label="搜索次数" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag>{{ row.count }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="stats.hotKeywords.length === 0" description="暂无数据" :image-size="60" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 网盘使用空间 -->
|
||||||
|
<el-card class="stats-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>💾 网盘使用空间</span>
|
||||||
|
</template>
|
||||||
|
<div v-for="item in stats.cloudUsage" :key="item.cloudType" class="storage-item">
|
||||||
|
<div class="storage-header">
|
||||||
|
<span class="storage-label">{{ item.nickname || item.cloudType }}</span>
|
||||||
|
<span class="storage-badge" :class="item.isActive ? 'active' : 'inactive'">{{ item.isActive ? '正常' : '停用' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="storage-detail">
|
||||||
|
<span class="storage-usage">{{ item.storageUsed || '未知' }} / {{ item.storageTotal || '未知' }}</span>
|
||||||
|
<span class="storage-pct">{{ storagePercent(item) }}%</span>
|
||||||
|
</div>
|
||||||
|
<el-progress :percentage="storagePercent(item)" :stroke-width="14" :color="storagePercent(item) > 80 ? '#f56c6c' : '#67c23a'" />
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="stats.cloudUsage.length === 0" description="暂无网盘数据" :image-size="60" />
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 保存操作来源 IP Top 10 -->
|
||||||
|
<el-card class="stats-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span>🌐 保存操作来源 IP Top 10</span>
|
||||||
|
</template>
|
||||||
|
<el-table :data="stats.topIps" stripe size="small" style="width: 100%">
|
||||||
|
<el-table-column type="index" label="排名" width="60" />
|
||||||
|
<el-table-column prop="ip" label="IP" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-family: monospace; font-size: 12px">{{ row.ip }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="访问地址" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.ip_location" style="font-size: 12px">{{ formatLocation(row.ip_location) }}</span>
|
||||||
|
<span v-else class="no-data">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="count" label="操作次数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag type="warning" effect="plain">{{ row.count }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="stats.topIps.length === 0" description="暂无数据" :image-size="60" />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getStats } from '../../api'
|
||||||
|
import type { StatsData } from '../../types'
|
||||||
|
|
||||||
|
const stats = ref<StatsData>({
|
||||||
|
todaySearches: 0,
|
||||||
|
todaySaves: 0,
|
||||||
|
monthSearches: 0,
|
||||||
|
monthSaves: 0,
|
||||||
|
totalSearches: 0,
|
||||||
|
totalSaves: 0,
|
||||||
|
hotKeywords: [],
|
||||||
|
trendTrend: [],
|
||||||
|
cloudUsage: [],
|
||||||
|
topIps: [],
|
||||||
|
provinceRankings: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
stats.value = await getStats()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载统计数据失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(d: string): string {
|
||||||
|
if (!d) return '-'
|
||||||
|
return d.split(' ')[0] || d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip "中国/China" prefix from location string for consistent display */
|
||||||
|
function formatLocation(loc: string): string {
|
||||||
|
if (!loc) return ''
|
||||||
|
return loc.replace(/^(中国|China)\s*/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function storagePercent(item: { storageUsed: string; storageTotal: string }): number {
|
||||||
|
if (!item.storageUsed || !item.storageTotal) return 0
|
||||||
|
const used = parseFloat(item.storageUsed)
|
||||||
|
const total = parseFloat(item.storageTotal)
|
||||||
|
if (total <= 0) return 0
|
||||||
|
return Math.round((used / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 趋势条宽度(基于当日搜索+保存之和)
|
||||||
|
function trendBarWidth(row: { searches: number; saves: number }): number {
|
||||||
|
const max = Math.max(...stats.value.trendTrend.map(r => r.searches + r.saves), 1)
|
||||||
|
return ((row.searches + row.saves) / max) * 100
|
||||||
|
}
|
||||||
|
function trendBarColor(row: { searches: number; saves: number }): string {
|
||||||
|
const total = row.searches + row.saves
|
||||||
|
if (total === 0) return '#ebeef5'
|
||||||
|
// Color gradient: more saves → more green
|
||||||
|
const ratio = row.saves / Math.max(total, 1)
|
||||||
|
if (ratio > 0.5) return '#67c23a'
|
||||||
|
if (ratio > 0.2) return '#409eff'
|
||||||
|
return '#909399'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 概览卡片:阴影 + 悬浮 + 渐变底色 --- */
|
||||||
|
.overview-card {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.overview-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.overview-card :deep(.el-card__body) {
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 16px;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.overview-value {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.overview-value.primary { color: var(--el-color-primary); }
|
||||||
|
.overview-value.success { color: var(--el-color-success); }
|
||||||
|
.overview-value.info { color: var(--el-color-info); }
|
||||||
|
.overview-value.warning { color: var(--el-color-warning); }
|
||||||
|
.overview-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- stats-card 美化阴影和圆角 --- */
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.stats-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
border: 1px solid var(--el-border-color-light, #ebeef5);
|
||||||
|
}
|
||||||
|
.stats-card:hover {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.09);
|
||||||
|
}
|
||||||
|
.stats-card :deep(.el-card__header) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: linear-gradient(90deg, #fafbfc 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 表格行 hover 高亮 --- */
|
||||||
|
.stats-view :deep(.el-table__body tr:hover > td.el-table__cell) {
|
||||||
|
background-color: #ecf5ff !important;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.stats-view :deep(.el-table__body tr) {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 趋势条 --- */
|
||||||
|
.trend-bar-wrap {
|
||||||
|
height: 16px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.trend-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: width .3s;
|
||||||
|
min-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 存储进度条更圆润 --- */
|
||||||
|
.stats-view :deep(.el-progress-bar__outer) {
|
||||||
|
border-radius: 99px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stats-view :deep(.el-progress-bar__inner) {
|
||||||
|
border-radius: 99px !important;
|
||||||
|
}
|
||||||
|
.storage-item {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.storage-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.storage-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.storage-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.storage-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.storage-badge.active { background: #f0f9eb; color: #67c23a; }
|
||||||
|
.storage-badge.inactive { background: #fef0f0; color: #f56c6c; }
|
||||||
|
.storage-detail {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.storage-pct {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1344
packages/frontend/src/pages/admin/SystemConfig.vue
Executable file
1344
packages/frontend/src/pages/admin/SystemConfig.vue
Executable file
File diff suppressed because it is too large
Load Diff
76
packages/frontend/src/router.ts
Executable file
76
packages/frontend/src/router.ts
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('./pages/HomePage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('./pages/SearchResult.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/result/:id',
|
||||||
|
name: 'result-detail',
|
||||||
|
component: () => import('./pages/ResultDetail.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/login',
|
||||||
|
name: 'admin-login',
|
||||||
|
component: () => import('./pages/admin/AdminLogin.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
component: () => import('./pages/admin/AdminLayout.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: '/admin/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
component: () => import('./pages/admin/AdminDashboard.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cloud-configs',
|
||||||
|
name: 'admin-cloud-configs',
|
||||||
|
component: () => import('./pages/admin/CloudConfig.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cleanup',
|
||||||
|
name: 'admin-cleanup',
|
||||||
|
component: () => import('./pages/admin/Cleanup.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'system',
|
||||||
|
name: 'admin-system',
|
||||||
|
component: () => import('./pages/admin/SystemConfig.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'save-records',
|
||||||
|
name: 'admin-save-records',
|
||||||
|
component: () => import('./pages/admin/SaveRecords.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const token = localStorage.getItem('admin_token')
|
||||||
|
if (to.meta.requiresAuth && !token) {
|
||||||
|
next('/admin/login')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
27
packages/frontend/src/styles/global.css
Executable file
27
packages/frontend/src/styles/global.css
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
/* 全局样式 */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #409eff;
|
||||||
|
--primary-dark: #337ecc;
|
||||||
|
--text-secondary: #909399;
|
||||||
|
--border-color: #e4e7ed;
|
||||||
|
--bg-white: #ffffff;
|
||||||
|
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--radius-card: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
199
packages/frontend/src/types/index.ts
Executable file
199
packages/frontend/src/types/index.ts
Executable file
@@ -0,0 +1,199 @@
|
|||||||
|
/* ===== 搜索结果类型 ===== */
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
cover?: string
|
||||||
|
file_size?: string
|
||||||
|
update_time?: string
|
||||||
|
datetime?: string
|
||||||
|
cloud_type: CloudType
|
||||||
|
share_url?: string
|
||||||
|
source?: string
|
||||||
|
password?: string
|
||||||
|
file_id?: string
|
||||||
|
valid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudType =
|
||||||
|
| 'quark' | 'baidu' | 'aliyun' | '115'
|
||||||
|
| 'tianyi' | '123pan' | 'uc' | 'xunlei'
|
||||||
|
| 'pikpak' | 'magnet' | 'ed2k' | 'others'
|
||||||
|
|
||||||
|
export const CLOUD_LABELS: Record<CloudType, string> = {
|
||||||
|
quark: '夸克网盘',
|
||||||
|
baidu: '百度网盘',
|
||||||
|
aliyun: '阿里云盘',
|
||||||
|
'115': '115网盘',
|
||||||
|
tianyi: '天翼云盘',
|
||||||
|
'123pan': '123云盘',
|
||||||
|
uc: 'UC网盘',
|
||||||
|
xunlei: '迅雷云盘',
|
||||||
|
pikpak: 'PikPak',
|
||||||
|
magnet: '磁力链接',
|
||||||
|
ed2k: '电驴链接',
|
||||||
|
others: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLOUD_COLORS: Record<CloudType, string> = {
|
||||||
|
quark: '#07c160',
|
||||||
|
baidu: '#4e6ef2',
|
||||||
|
aliyun: '#ff6a00',
|
||||||
|
'115': '#9b59b6',
|
||||||
|
tianyi: '#00a1d6',
|
||||||
|
'123pan': '#e74c3c',
|
||||||
|
uc: '#f39c12',
|
||||||
|
xunlei: '#2ecc71',
|
||||||
|
pikpak: '#8e44ad',
|
||||||
|
magnet: '#95a5a6',
|
||||||
|
ed2k: '#7f8c8d',
|
||||||
|
others: '#95a5a6',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网盘图标映射 — 全部使用内联 SVG data URI,无需外部文件
|
||||||
|
* 每个网盘类型使用其品牌色圆角底 + 首字母/中文标识
|
||||||
|
*/
|
||||||
|
function makeSvgIcon(bg: string, letter: string): string {
|
||||||
|
const c = encodeURIComponent(bg)
|
||||||
|
const l = encodeURIComponent(letter)
|
||||||
|
return `data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22${c}%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2213%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E${l}%3C%2Ftext%3E%3C%2Fsvg%3E`
|
||||||
|
}
|
||||||
|
const ICON_SVGS: Record<string, string> = {
|
||||||
|
magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E',
|
||||||
|
ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E',
|
||||||
|
others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E',
|
||||||
|
}
|
||||||
|
export const CLOUD_ICONS: Record<string, string> = {
|
||||||
|
baidu: makeSvgIcon('#4e6ef2', '百'),
|
||||||
|
aliyun: makeSvgIcon('#ff6a00', '阿'),
|
||||||
|
quark: makeSvgIcon('#07c160', '夸'),
|
||||||
|
'115': makeSvgIcon('#9b59b6', '1'),
|
||||||
|
tianyi: makeSvgIcon('#00a1d6', '天'),
|
||||||
|
'123pan': makeSvgIcon('#e74c3c', '1'),
|
||||||
|
uc: makeSvgIcon('#f39c12', 'U'),
|
||||||
|
xunlei: makeSvgIcon('#2ecc71', '迅'),
|
||||||
|
pikpak: makeSvgIcon('#8e44ad', 'P'),
|
||||||
|
magnet: ICON_SVGS.magnet,
|
||||||
|
ed2k: ICON_SVGS.ed2k,
|
||||||
|
others: ICON_SVGS.others,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 视频解析类型 ===== */
|
||||||
|
export interface VideoParseResult {
|
||||||
|
title: string
|
||||||
|
cover: string
|
||||||
|
video_url: string
|
||||||
|
author?: string
|
||||||
|
platform: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 保存结果类型 ===== */
|
||||||
|
export interface SaveResult {
|
||||||
|
success: boolean
|
||||||
|
share_url: string
|
||||||
|
share_pwd?: string
|
||||||
|
expire_at?: string
|
||||||
|
file_name: string
|
||||||
|
file_size: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 排行榜类型 ===== */
|
||||||
|
export interface RankingItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
search_count: number
|
||||||
|
cloud_type: CloudType
|
||||||
|
cover?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 推广类型 ===== */
|
||||||
|
export interface Promotion {
|
||||||
|
id?: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
image_url: string
|
||||||
|
link_url: string
|
||||||
|
position: 'home_banner' | 'search_top' | 'sidebar'
|
||||||
|
sort_order: number
|
||||||
|
active: boolean
|
||||||
|
click_count?: number
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 网盘配置类型 ===== */
|
||||||
|
export interface CloudConfig {
|
||||||
|
id?: number
|
||||||
|
cloud_type: CloudType
|
||||||
|
nickname?: string
|
||||||
|
is_active: boolean
|
||||||
|
cookie?: string
|
||||||
|
cookie_preview?: string
|
||||||
|
storage_used?: string
|
||||||
|
storage_total?: string
|
||||||
|
checkin_status?: 'none' | 'success' | 'failed' | 'pending' | 'skipped'
|
||||||
|
last_checkin_at?: string
|
||||||
|
checkin_message?: string
|
||||||
|
consecutive_failures?: number
|
||||||
|
last_used_at?: string
|
||||||
|
total_saves?: number
|
||||||
|
verification_status?: 'untested' | 'valid' | 'invalid'
|
||||||
|
last_verified_at?: string
|
||||||
|
promotion_account?: string
|
||||||
|
is_transfer_enabled?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== API 响应类型 ===== */
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
results: SearchResult[]
|
||||||
|
total: number
|
||||||
|
filtered: number
|
||||||
|
channels?: ChannelGroup[]
|
||||||
|
link_validation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelGroup {
|
||||||
|
cloud_type: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
count: number
|
||||||
|
items: any[]
|
||||||
|
newestTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsData {
|
||||||
|
todaySearches: number
|
||||||
|
todaySaves: number
|
||||||
|
monthSearches: number
|
||||||
|
monthSaves: number
|
||||||
|
totalSearches: number
|
||||||
|
totalSaves: number
|
||||||
|
hotKeywords: { keyword: string; count: number }[]
|
||||||
|
trendTrend: { date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }[]
|
||||||
|
cloudUsage: { cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean }[]
|
||||||
|
topIps: { ip: string; ip_location: string | null; count: number }[]
|
||||||
|
provinceRankings: { province: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 意图识别结果 ===== */
|
||||||
|
export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE'
|
||||||
|
|
||||||
|
export interface QueryResponse {
|
||||||
|
intent: IntentType
|
||||||
|
results: SearchResult[] | VideoParseResult[]
|
||||||
|
channels?: ChannelGroup[]
|
||||||
|
total: number
|
||||||
|
filtered?: number
|
||||||
|
platform?: string
|
||||||
|
link_validation?: boolean
|
||||||
|
}
|
||||||
20
packages/frontend/tsconfig.json
Executable file
20
packages/frontend/tsconfig.json
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
packages/frontend/tsconfig.node.json
Executable file
10
packages/frontend/tsconfig.node.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user