Compare commits

...

31 Commits

Author SHA1 Message Date
timaa ce3f95b8b9 deploy: sync server CloudSearch 0.5.6 2026-06-25 02:08:39 +08:00
timaa 75f5d26964 feat: add CloudSearch observability 2026-05-27 10:08:59 +08:00
timaa 4c161b63c6 feat: integrate Quark and UC drive APIs
Add optional drive API capabilities for Quark and UC adapters, including directory lookup/creation, rename, move, delete, task polling, Quark recycle cleanup, and UC share staging folder support.\n\nAdd unittest coverage for capability declarations, production transfer path save_dir resolution, staging-folder flow, delete_files behavior, task polling params, and HTTP/JSON error handling.\n\nDocument the Hong Kong test server deployment boundary and verification commands.
2026-05-22 19:02:46 +08:00
timaa d79c11fb15 chore: normalize test-server health governance 2026-05-21 14:21:27 +08:00
timaa 3b67df19e7 release: v0.5.5 2026-05-20 02:48:17 +08:00
timaa c4ca8edd58 fix: 搜索路由参数校验 — page>=1、limit 1-500 2026-05-20 02:46:13 +08:00
timaa d7b055f88b v0.5.4: 全面修复 — template literal URL, Cookie验证, 用户默认is_active, 默认账号路由, 空间信息, 密钥清理, promoForm修复
修复:
- quark-share.ts/storage.ts: 9处template literal ${}缺失导致fetch URL写死
- user/routes.ts: testCloudConnectionWithCookie缺await + 按cloudType分发驱动
- credential.service.ts: INSERT缺?参数 (9values/10cols)
- user/routes.ts: 用户新增网盘默认is_active=0
- admin.routes.ts: 新增PUT /admin/cloud-configs/:id/primary路由
- database.ts: is_primary列迁移
- UserDashboard.vue: 保存时传递storage_used/storage_total
- SystemConfig.vue: promoForm const重赋值bug
- config/index.ts: 移除泄露的默认密钥token
2026-05-19 23:09:11 +08:00
timaa 39724e6e73 fix(audit): 修复3个审计发现的bug
- migrateCloudConfigs 缺 } 导致 notify_config/cloud_type_uid 嵌套在 promotion_account 内不迁移
- cloud_configs_v2 INSERT/SELECT 缺 cloud_type_uid/cookie_uid 导致数据丢失
- deploy.sh 密码嵌入改成 docker exec -e 传参(防特殊字符注入)
2026-05-19 14:23:26 +08:00
timaa 288d30698a hotfix: 补回system_configs表缺失的); 2026-05-19 05:21:28 +08:00
timaa a9dc056506 hotfix: 移除push_users表多余的);(导致SQL语法错误→容器重启) 2026-05-19 05:13:24 +08:00
timaa 9ef58b5724 fix: push_users表 + cookie_uid列 + __uid正则修复 + deploy.sh重写 2026-05-19 05:06:17 +08:00
timaa c9067179ff fix: save_records表补全config_id/promotion_account列(修复转存500) 2026-05-19 04:52:08 +08:00
timaa 5e3b5e83a4 fix(deploy): 不再硬编码CORS_ORIGIN + 强制覆盖管理员密码(解决401) 2026-05-19 04:44:19 +08:00
timaa d4e62b1fb1 feat: 一键部署脚本 deploy.sh (curl | bash 方式) 2026-05-19 04:32:19 +08:00
timaa 7471d9aece v0.4.8: docker-compose添加DATA_DIR=/data确保secrets.json路径一致 2026-05-19 03:34:23 +08:00
timaa f0c0d0f487 v0.4.8: 同步根VERSION文件 2026-05-19 02:29:12 +08:00
timaa e4e1890da3 v0.4.8: 补播link_invalid_keywords种子键 2026-05-19 02:28:49 +08:00
timaa 470ebac20e v0.4.7: 修复网盘类型开关key不匹配+补全10个缺失种子配置键 2026-05-19 02:02:48 +08:00
timaa f284e14630 v0.4.6: 保存网盘配置后自动验证+获取昵称空间 2026-05-19 00:36:42 +08:00
timaa bff955e45b v0.4.5: 修复双重解密/配置key/版本号同步/buil+ssh脚本 2026-05-18 20:25:40 +08:00
timaa 6f7ab6dbc6 feat: deploy.sh三重Redis密码检测(参数/conf/cli) + URL编码特殊字符 2026-05-18 18:16:33 +08:00
timaa 498b593b28 fix: 添加缺失的cloud_type_uid列(CREATE TABLE和迁移) 2026-05-18 18:00:55 +08:00
timaa c57af012b1 feat: 自动生成JWT_SECRET和ADMIN_PASSWORD并持久化到secrets.json 2026-05-18 16:48:04 +08:00
timaa 86d79e550b fix: docker-compose.yml 镜像版本改为 latest 2026-05-18 16:35:53 +08:00
timaa 07d66ac666 fix: 数据库初始化顺序(索引移到migrateCloudConfigs后) + deploy.sh自动检测Redis密码 2026-05-18 06:14:06 +08:00
timaa 42d2b5bf1c deploy.sh: 自动下载docker-compose.yml,真正一键部署 2026-05-18 06:03:49 +08:00
timaa d7c2a9cfad 智能部署脚本:自动检测Redis,有则跳过无则创建 2026-05-18 06:03:09 +08:00
timaa 8da99d6861 docker-compose: fill real credentials 2026-05-18 06:00:14 +08:00
timaa 6f20c662eb simplify docker-compose.yml 2026-05-18 05:57:16 +08:00
timaa 48d6b642e0 add docker-compose.yml for one-click deployment 2026-05-18 05:28:23 +08:00
timaa da5bd01535 release: v0.4.0 2026-05-18 05:11:57 +08:00
452 changed files with 18333 additions and 7201 deletions
+4
View File
@@ -0,0 +1,4 @@
version=0.5.6
source=restored-cloudsearch-existing-source
updated_at=2026-05-23T19:14:59+08:00
backup=/root/cloudsearch_deploy_backups/source_clean_before_d7e1d90_20260523-191331.tgz
+1 -1
View File
@@ -1 +1 @@
0.3.53
0.5.6
+1 -1
View File
@@ -4,7 +4,7 @@ cd "$(dirname "$0")/source_clean"
VERSION=$(cat ../VERSION)
echo "🔨 Building CloudSearch v${VERSION}..."
cp ../VERSION ./VERSION
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
+7 -4
View File
@@ -2,10 +2,13 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
ENV PYTHONPATH=/app
COPY cloudsearch_transfer/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY cloudsearch_transfer ./cloudsearch_transfer
RUN mkdir -p /data
ENV PORT=9528
ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
@@ -13,6 +16,6 @@ ENV TRANSFER_CONFIG_PATH=/data/transfer_config.json
EXPOSE 9528
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["python", "server.py"]
CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.getenv('PORT', '9528'), timeout=3)"
CMD ["python", "server.py"]
CMD ["python", "-m", "cloudsearch_transfer.server"]
+87 -11
View File
@@ -15,8 +15,8 @@ import logging
from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ..config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode
from ...config import PlatformConfig, TransferConfig
from ...errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer
@@ -47,23 +47,29 @@ class AliyunAdapter(BaseCloudDriveAdapter):
"Referer": "https://aliyundrive.com",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
# 创建凭证管理器(AliyunCredentialManager
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None
super().__init__(config, transfer_config)
def _setup_session(self):
"""初始化 session 和凭证"""
if self._credential.refresh_token:
refresh_token = getattr(self._credential, "refresh_token", "")
if refresh_token:
# 验证 refresh_token 并获取 drive_id
if self._credential.validate():
self._drive_id = self._credential.get_drive_id()
@@ -164,8 +170,10 @@ class AliyunAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY,
)
# 确定目标目录
# 确定目标目录:路径先解析/创建为 file_id。
to_parent = save_dir if save_dir and save_dir != "/" else "root"
if isinstance(to_parent, str) and to_parent.startswith("/"):
to_parent = self.ensure_dir(to_parent)
transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
@@ -237,6 +245,74 @@ class AliyunAdapter(BaseCloudDriveAdapter):
# ─── 扩展功能 ──────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def get_fids(self, file_paths: List[str]) -> List[Dict]:
wanted = {p.rstrip("/") or "/" for p in file_paths}
found: List[Dict] = []
for path in wanted:
if path == "/":
found.append({"file_path": "/", "fid": "root"})
continue
parent = path.rsplit("/", 1)[0] or "/"
name = path.rsplit("/", 1)[-1]
for item in self.get_files(parent):
if item.is_dir and item.name == name:
found.append({"file_path": path, "fid": item.fid})
break
return found
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "root"
current = ""
last_fid = "root"
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
matches = self.get_fids([current])
if matches:
last_fid = matches[0].get("fid") or matches[0].get("file_id") or last_fid
continue
created = self.mkdir(current)
data = created.get("data", created) if isinstance(created, dict) else {}
last_fid = data.get("fid") or data.get("file_id") or data.get("id") or last_fid
return last_fid
def mkdir(self, dir_path: str) -> Dict:
parent = dir_path.rsplit("/", 1)[0] or "/"
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
parent_fid = "root" if parent == "/" else self.ensure_dir(parent)
url = "https://api.aliyundrive.com/adrive/v2/file/createWithFolders"
body = {
"drive_id": self._drive_id or self._credential.get_drive_id(),
"parent_file_id": parent_fid,
"name": name,
"type": "folder",
"check_name_mode": "refuse",
}
resp = self._post(url, json_data=body, headers=self._credential.get_headers())
data = self._drive_api_json(resp, context="阿里云盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
url = "https://api.aliyundrive.com/v3/file/update"
body = {"drive_id": self._drive_id or self._credential.get_drive_id(), "file_id": fid, "name": file_name, "check_name_mode": "refuse"}
data = self._drive_api_json(self._post(url, json_data=body, headers=self._credential.get_headers()), context="阿里云盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
if isinstance(to_pdir_fid, str) and to_pdir_fid.startswith("/"):
to_pdir_fid = self.ensure_dir(to_pdir_fid)
drive_id = self._drive_id or self._credential.get_drive_id()
requests = [{"id": fid, "method": "POST", "url": "/file/move", "headers": {"Content-Type": "application/json"}, "body": {"drive_id": drive_id, "file_id": fid, "to_parent_file_id": to_pdir_fid}} for fid in fids]
data = self._drive_api_json(self._post("https://api.aliyundrive.com/adrive/v4/batch", json_data={"requests": requests, "resource": "file"}, headers=self._credential.get_headers()), context="阿里云盘移动文件")
return {"code": 0, "status": 200, "data": data}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def cleanup_files(self, file_ids: List[str]) -> Dict:
"""
清理文件(移入回收站),返回详细结果。
+79 -3
View File
@@ -6,7 +6,7 @@
"""
import logging
from typing import List, Tuple
from typing import List, Tuple, Dict
from ..base import BaseCloudDriveAdapter, FileInfo
from ...config import PlatformConfig, TransferConfig
@@ -36,6 +36,15 @@ class BaiduAdapter(BaseCloudDriveAdapter):
r'pan\.baidu\.com/s/1([A-Za-z0-9_-]+)',
]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"rename": True,
"move_files": True,
"delete_files": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
@@ -123,11 +132,13 @@ class BaiduAdapter(BaseCloudDriveAdapter):
fs_ids = detail["fs_ids"]
filenames = detail.get("filenames", [])
target_dir = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "/")
# ③ 转存
self._transfer._transfer_files(shareid, uk, fs_ids, save_dir, bdstoken)
self._transfer._transfer_files(shareid, uk, fs_ids, target_dir, bdstoken)
# ④ 列出目录匹配新 fs_id
new_fs_ids = self._transfer._list_and_match(save_dir, filenames, bdstoken)
new_fs_ids = self._transfer._list_and_match(target_dir, filenames, bdstoken)
# 暂存文件信息供 _filter_ads + _create_share 使用
self._last_transfer_files = [
@@ -248,6 +259,71 @@ class BaiduAdapter(BaseCloudDriveAdapter):
# ─── 扩展方法 ────────────────────────────────────────────
# ─── Optional Drive API capability methods ─────────────────────
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return "/"
current = ""
for part in [p for p in normalized.split("/") if p]:
parent = current or "/"
current = f"{current}/{part}" if current else f"/{part}"
exists = any(item.is_dir and item.name == part for item in self.get_files(parent))
if not exists:
self.mkdir(current)
return normalized
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing Baidu paths by listing their parent directories."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": "/", "path": "/"})
continue
parent, name = normalized.rsplit("/", 1)
parent = parent or "/"
match = next((item for item in self.get_files(parent) if item.name == name), None)
if match:
results.append({"file_path": path, "fid": match.fid, "path": normalized})
return results
def mkdir(self, dir_path: str) -> Dict:
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/create"
params = {"a": "commit", "bdstoken": bdstoken}
data = {"path": dir_path, "isdir": 1, "block_list": "[]"}
resp = self._post(url, data=data, params=params, headers=self.credential.get_headers())
payload = self._drive_api_json(resp, context="百度网盘创建目录")
errno = payload.get("errno", 0)
if errno not in (0, -8):
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度创建目录失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": {"path": dir_path, **payload}}
def rename(self, fid: str, file_name: str) -> Dict:
return self._filemanager("rename", [{"path": fid, "newname": file_name}])
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
return self._filemanager("move", [{"path": fid, "dest": target} for fid in fids])
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def _filemanager(self, opera: str, filelist: List[Dict]) -> Dict:
import json
bdstoken = self.credential.get_bdstoken()
url = "https://pan.baidu.com/api/filemanager"
params = {"opera": opera, "bdstoken": bdstoken}
data = {"filelist": json.dumps(filelist, ensure_ascii=False)}
payload = self._drive_api_json(self._post(url, data=data, params=params, headers=self.credential.get_headers()), context=f"百度网盘{opera}")
errno = payload.get("errno", 0)
if errno != 0:
raise TransferError(TransferErrorCode.NETWORK_ERROR, message=f"百度文件操作失败 errno={errno}", platform=self.PLATFORM_KEY, details=payload)
return {"code": 0, "status": 200, "data": payload}
def delete_paths(self, paths: List[str]) -> bool:
"""便捷删除方法(直接调用 cleanup)"""
return self._cleanup.delete_files(paths)
+72 -2
View File
@@ -76,6 +76,18 @@ class BaseCloudDriveAdapter(ABC):
# URL匹配正则(子类覆盖)
URL_PATTERNS: List[str] = []
# 可选 Drive API 能力;子类按需覆盖为 True 并实现对应方法。
capabilities: Dict[str, bool] = {
"ensure_dir": False,
"save_files": False,
"poll_task": False,
"rename": False,
"move_files": False,
"delete_files": False,
"cleanup_recycle": False,
"share_staging_folder": False,
}
# 默认请求头
DEFAULT_HEADERS: Dict[str, str] = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -235,6 +247,40 @@ class BaseCloudDriveAdapter(ABC):
"""广告过滤(默认不实现,子类可覆盖)"""
return file_ids
# ─── Optional Drive API capability protocol ─────────────────────
def _unsupported_capability(self, capability: str) -> None:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{self.PLATFORM_KEY or self.PLATFORM_NAME} 不支持 Drive API 能力: {capability}",
platform=self.PLATFORM_KEY,
)
def ensure_dir(self, dir_path: str) -> str:
self._unsupported_capability("ensure_dir")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
self._unsupported_capability("get_fids")
def mkdir(self, dir_path: str) -> Dict[str, Any]:
self._unsupported_capability("mkdir")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
self._unsupported_capability("rename")
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
self._unsupported_capability("move_files")
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("delete_files")
def query_task(self, task_id: str) -> Dict[str, Any]:
self._unsupported_capability("poll_task")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
self._unsupported_capability("cleanup_recycle")
# ─── HTTP 工具方法 ─────────────────────────────────────
def _get(self, url: str, params: dict = None, headers: dict = None,
@@ -271,6 +317,28 @@ class BaseCloudDriveAdapter(ABC):
raise TransferError(TransferErrorCode.NETWORK_ERROR,
message=str(last_exc), platform=self.PLATFORM_KEY)
def _drive_api_json(self, resp: requests.Response, context: str = "网盘 API") -> Dict[str, Any]:
"""Validate HTTP response and decode JSON for drive helper APIs."""
try:
resp.raise_for_status()
except requests.HTTPError as exc:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} HTTP错误: {exc}",
platform=self.PLATFORM_KEY,
details={"status_code": getattr(resp, "status_code", None)},
) from exc
try:
return resp.json()
except ValueError as exc:
text = getattr(resp, "text", "") or ""
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"{context} JSON解析失败: {text[:200]}",
platform=self.PLATFORM_KEY,
) from exc
def _poll_task(self, task_url: str, task_id: str,
status_field: str = "status",
success_value: Any = 2,
@@ -292,10 +360,12 @@ class BaseCloudDriveAdapter(ABC):
details={"task_id": task_id})
try:
params = query_params or {}
base_params = query_params(attempt) if callable(query_params) else (query_params or {})
params = dict(base_params)
params["task_id"] = task_id
resp = self._get(task_url, params=params, retry=1)
data = resp.json().get("data", resp.json())
payload = resp.json()
data = payload.get("data", payload)
current_status = data.get(status_field)
if current_status == success_value:
+175 -22
View File
@@ -55,19 +55,21 @@ class QuarkAdapter(BaseCloudDriveAdapter):
r"pan\.quark\.cn/s/(\w+)",
]
capabilities: Dict[str, bool] = {
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
"delete_files": True,
"cleanup_recycle": True,
"share_staging_folder": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化夸克适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: QuarkCredentialManager = QuarkCredentialManager(
cookie=config.cookie
)
"""初始化适配器。"""
self._credential: QuarkCredentialManager = QuarkCredentialManager(cookie=config.cookie)
self._transfer_engine: QuarkTransfer = QuarkTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
@@ -78,6 +80,7 @@ class QuarkAdapter(BaseCloudDriveAdapter):
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
@@ -116,8 +119,9 @@ class QuarkAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 目标目录:支持 fid 或路径;路径会先创建/解析为 fid。
requested_dir: str = save_dir or self.config.save_dir or "/"
target_dir: str = requested_dir if requested_dir and not str(requested_dir).startswith("/") else self.ensure_dir(requested_dir)
# 分享密码
pwd: str = share_password or self.config.share_password or ""
@@ -250,18 +254,13 @@ class QuarkAdapter(BaseCloudDriveAdapter):
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""转存文件到自己的夸克网盘(基类 transfer() 流程中的步骤③④)。
Args:
pwd_id: 分享 ID。
detail: 分享详情(来自 _get_share_detail)。
save_dir: 目标目录 ID。
Returns:
转存后的新文件 ID 列表。
save_dir 可传 fid 或路径;路径会先通过 ensure_dir 创建/解析为 fid。
"""
# 需要 stoken,从 detail 间接获取(重新请求)
stoken: str = self._transfer_engine._get_stoken(pwd_id)
target_fid = save_dir if save_dir and not str(save_dir).startswith("/") else self.ensure_dir(save_dir or "/")
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=save_dir
pwd_id, stoken, detail, to_pdir_fid=target_fid
)
return self._transfer_engine._poll_save_task(task_id)
@@ -351,6 +350,160 @@ class QuarkAdapter(BaseCloudDriveAdapter):
logger.warning("[QuarkAdapter] Cannot fetch file list for ad filtering, skipping")
return file_ids
# ─── Drive API capability helpers ─────────────────────────────
@staticmethod
def _normalize_dir_path(dir_path: str) -> str:
path = "/" + str(dir_path or "").strip().strip("/")
return "/" if path == "/" else path
@staticmethod
def _api_success(payload: Dict[str, Any]) -> bool:
if not isinstance(payload, dict):
return False
code = payload.get("code")
status = payload.get("status")
return code == 0 or status in (0, 200)
def ensure_dir(self, dir_path: str) -> str:
normalized = self._normalize_dir_path(dir_path)
if normalized == "/":
return "0"
parts = [part for part in normalized.strip("/").split("/") if part]
prefixes = ["/" + "/".join(parts[:idx]) for idx in range(1, len(parts) + 1)]
existing = {
item.get("file_path"): str(item.get("fid"))
for item in self.get_fids(prefixes)
if item.get("file_path") and item.get("fid")
}
leaf_fid = existing.get(normalized)
if leaf_fid:
return leaf_fid
for prefix in prefixes:
if prefix in existing:
continue
result = self.mkdir(prefix)
if self._api_success(result) and result.get("data", {}).get("fid"):
existing[prefix] = str(result["data"]["fid"])
continue
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"创建目录失败: {result.get('message', result)}",
platform=self.PLATFORM_KEY,
)
return existing[normalized]
def mkdir(self, dir_path: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file"
params = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""}
payload = {
"pdir_fid": "0",
"file_name": "",
"dir_path": self._normalize_dir_path(dir_path),
"dir_init_lock": False,
}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/rename"
params = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""}
payload = {"fid": fid, "file_name": file_name}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
pending = [self._normalize_dir_path(p) for p in file_paths]
result: List[Dict[str, Any]] = []
while pending:
batch, pending = pending[:50], pending[50:]
url = "https://drive-pc.quark.cn/1/clouddrive/file/info/path_list"
params = {"pr": "ucpro", "fr": "pc"}
payload = {"file_path": batch, "namespace": "0"}
data = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="按路径获取文件ID")
if not self._api_success(data):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取目录ID失败: {data.get('message', data)}",
platform=self.PLATFORM_KEY,
)
result.extend(data.get("data", []))
return result
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
if not fids:
return {"code": 0, "message": "无文件需要移动"}
last: Dict[str, Any] = {"code": 0, "message": "success"}
for offset in range(0, len(fids), 100):
batch = fids[offset:offset + 100]
url = "https://drive-pc.quark.cn/1/clouddrive/file/move"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
payload = {"filelist": batch, "to_pdir_fid": to_pdir_fid, "exclude_fids": [], "action_type": 1}
last = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="移动网盘文件")
if not self._api_success(last):
return last
task_id = last.get("data", {}).get("task_id")
if task_id:
task = self.query_task(task_id)
if not self._api_success(task) or task.get("data", {}).get("status") == -1:
return {"code": 1, "message": task.get("data", {}).get("message", task.get("message", "移动任务失败")), "data": task.get("data", {})}
return {"code": 0, "message": "移动完成", "data": last.get("data", {})}
def _task_query_params(self, retry_index: int = 0) -> Dict[str, Any]:
now_ms = int(time.time() * 1000)
return {
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"retry_index": retry_index,
"__dt": 300,
"__t": now_ms,
}
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
if not fids:
return {"code": 0, "status": 200}
if self.delete(fids):
return {"code": 0, "status": 200}
return {"code": 1, "status": 500, "message": "删除文件失败"}
def query_task(self, task_id: str) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/task"
try:
data = self._poll_task(url, task_id, query_params=self._task_query_params)
return {"code": 0, "status": 200, "data": data}
except TransferError as exc:
return {"code": 1, "status": 500, "message": str(exc), "data": {"status": -1}}
def recycle_list(self, page: int = 1, size: int = 30) -> List[Dict[str, Any]]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/list"
params = {"_page": page, "_size": size, "pr": "ucpro", "fr": "pc", "uc_param_str": ""}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出回收站")
return data.get("data", {}).get("list", [])
def recycle_remove(self, record_list: List[Dict[str, Any]]) -> Dict[str, Any]:
url = "https://drive-pc.quark.cn/1/clouddrive/file/recycle/remove"
params = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"}
payload = {"select_mode": 2, "record_list": record_list}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def cleanup_recycle(self, fids: List[str]) -> Dict[str, Any]:
target_fids = {str(fid) for fid in fids if fid}
if not target_fids:
return {"code": 0, "message": "无回收站记录需要清理", "data": {"removed": 0}}
records = [
item for item in self.recycle_list()
if str(item.get("fid") or item.get("file_id") or "") in target_fids
]
if not records:
return {"code": 0, "message": "未找到匹配的回收站记录", "data": {"removed": 0}}
result = self.recycle_remove(records)
if self._api_success(result):
result.setdefault("data", {})["removed"] = len(records)
return result
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
+194 -49
View File
@@ -56,19 +56,21 @@ class UcAdapter(BaseCloudDriveAdapter):
r"drive\.uc\.cn/s/(\w+)",
]
capabilities: Dict[str, bool] = {
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
"delete_files": True,
"cleanup_recycle": False,
"share_staging_folder": True,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig) -> None:
"""初始化 UC 适配器。
Args:
config: 平台配置(含 Cookie 等)。
transfer_config: 全局转存配置(超时、重试、轮询参数等)。
"""
super().__init__(config, transfer_config)
# 初始化三个子模块
self._credential: UcCredentialManager = UcCredentialManager(
cookie=config.cookie
)
"""初始化适配器。"""
self._credential: UcCredentialManager = UcCredentialManager(cookie=config.cookie)
self._transfer_engine: UcTransfer = UcTransfer(
credential=self._credential,
timeout=transfer_config.request_timeout,
@@ -79,6 +81,7 @@ class UcAdapter(BaseCloudDriveAdapter):
credential=self._credential,
timeout=transfer_config.request_timeout,
)
super().__init__(config, transfer_config)
# ═══════════════════════════════════════════════════════════════
# 公开接口实现
@@ -93,21 +96,8 @@ class UcAdapter(BaseCloudDriveAdapter):
def transfer(self, share_url: str, save_dir: str = "",
share_password: str = "") -> TransferResult:
"""执行转存的核心逻辑(覆盖基类实现 UC 专用流程)。
通过 UcTransfer 引擎执行完整的 7 步流程。
Args:
share_url: UC 分享链接。
save_dir: 目标目录,空则使用配置的默认目录。
share_password: 新分享的密码。
Returns:
TransferResult 包含转存结果。
"""
start: float = time.time()
# 凭证检查
if not self._credential.validate():
raise TransferError(
TransferErrorCode.NOT_LOGIN,
@@ -115,18 +105,36 @@ class UcAdapter(BaseCloudDriveAdapter):
platform=self.PLATFORM_KEY,
)
# 目标目录:默认根目录 "0"
target_dir: str = save_dir or self.config.save_dir or "0"
# 分享密码
requested_dir: str = save_dir or self.config.save_dir or "/"
target_dir: str = requested_dir if requested_dir and not str(requested_dir).startswith("/") else self.ensure_dir(requested_dir)
staging_dir: str = self.get_or_create_share_folder() or target_dir
pwd: str = share_password or self.config.share_password or ""
try:
result: Dict[str, Any] = self._transfer_engine.transfer(
share_url=share_url,
save_dir=target_dir,
share_password=pwd,
pwd_id, passcode = self._parse_share_url(share_url)
stoken: str = self._transfer_engine._get_stoken(pwd_id, passcode)
detail: Dict[str, Any] = self._transfer_engine._get_detail(pwd_id, stoken)
task_id: str = self._transfer_engine._init_save(
pwd_id, stoken, detail, to_pdir_fid=staging_dir
)
new_fids: List[str] = self._transfer_engine._poll_save_task(task_id)
if not new_fids:
raise RuntimeError("转存完成但未获取到文件ID")
if staging_dir != target_dir:
move_result = self.move_files(new_fids, target_dir)
if not self._api_success(move_result):
raise RuntimeError(f"移动到目标目录失败: {move_result.get('message', move_result)}")
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise RuntimeError("广告过滤后无可分享文件")
title: str = detail.get("title", "分享")
share_task_id: str = self._transfer_engine._init_share(new_fids, title)
share_id: str = self._transfer_engine._poll_share_task(share_task_id)
share_url_new, passcode_new = self._transfer_engine._set_password(share_id, pwd)
except ValueError as exc:
raise TransferError(
TransferErrorCode.URL_INVALID,
@@ -148,24 +156,13 @@ class UcAdapter(BaseCloudDriveAdapter):
) from exc
elapsed: int = int((time.time() - start) * 1000)
# 广告过滤
new_fids: List[str] = result.get("new_file_ids", [])
if self.transfer_config.ad_filter_enabled and new_fids:
new_fids = self._filter_ads(new_fids)
if not new_fids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
return TransferResult(
success=True,
platform=self.PLATFORM_KEY,
new_file_id=",".join(new_fids),
file_name=result.get("file_name", ""),
share_url=result.get("share_url", ""),
share_password=result.get("passcode", pwd),
file_name=title,
share_url=share_url_new,
share_password=passcode_new,
original_url=share_url,
elapsed_ms=elapsed,
)
@@ -204,8 +201,8 @@ class UcAdapter(BaseCloudDriveAdapter):
files=files,
)
except TransferError:
raise
except TransferError as exc:
return VerifyResult(valid=False, platform=self.PLATFORM_KEY, error=exc)
except (ValueError, RuntimeError) as exc:
return VerifyResult(
valid=False,
@@ -344,6 +341,154 @@ class UcAdapter(BaseCloudDriveAdapter):
)
return file_ids
# ─── Drive API capability helpers ─────────────────────────────
@staticmethod
def _normalize_dir_path(dir_path: str) -> str:
path = "/" + str(dir_path or "").strip().strip("/")
return "/" if path == "/" else path
@staticmethod
def _api_success(payload: Dict[str, Any]) -> bool:
if not isinstance(payload, dict):
return False
code = payload.get("code")
status = payload.get("status")
return code == 0 or status in (0, 200)
def ensure_dir(self, dir_path: str) -> str:
normalized = self._normalize_dir_path(dir_path)
if normalized == "/":
return "0"
parts = [part for part in normalized.strip("/").split("/") if part]
prefixes = ["/" + "/".join(parts[:idx]) for idx in range(1, len(parts) + 1)]
existing = {
item.get("file_path"): str(item.get("fid"))
for item in self.get_fids(prefixes)
if item.get("file_path") and item.get("fid")
}
leaf_fid = existing.get(normalized)
if leaf_fid:
return leaf_fid
for prefix in prefixes:
if prefix in existing:
continue
result = self.mkdir(prefix)
if self._api_success(result) and result.get("data", {}).get("fid"):
existing[prefix] = str(result["data"]["fid"])
continue
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"创建目录失败: {result.get('message', result)}",
platform=self.PLATFORM_KEY,
)
return existing[normalized]
def mkdir(self, dir_path: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file"
params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""}
payload = {
"pdir_fid": "0",
"file_name": "",
"dir_path": self._normalize_dir_path(dir_path),
"dir_init_lock": False,
}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def rename(self, fid: str, file_name: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file/rename"
params = {"pr": "UCBrowser", "fr": "pc", "uc_param_str": ""}
payload = {"fid": fid, "file_name": file_name}
return self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="写入网盘目录")
def get_fids(self, file_paths: List[str]) -> List[Dict[str, Any]]:
pending = [self._normalize_dir_path(p) for p in file_paths]
result: List[Dict[str, Any]] = []
while pending:
batch, pending = pending[:50], pending[50:]
url = "https://pc-api.uc.cn/1/clouddrive/file/info/path_list"
params = {"pr": "UCBrowser", "fr": "pc"}
payload = {"file_path": batch, "namespace": "0"}
data = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="按路径获取文件ID")
if not self._api_success(data):
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=f"获取目录ID失败: {data.get('message', data)}",
platform=self.PLATFORM_KEY,
)
result.extend(data.get("data", []))
return result
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict[str, Any]:
if not fids:
return {"code": 0, "message": "无文件需要移动"}
last: Dict[str, Any] = {"code": 0, "message": "success"}
for offset in range(0, len(fids), 100):
batch = fids[offset:offset + 100]
url = "https://pc-api.uc.cn/1/clouddrive/file/move"
params = {"uc_param_str": "", "fr": "pc", "pr": "UCBrowser"}
payload = {"filelist": batch, "to_pdir_fid": to_pdir_fid, "exclude_fids": [], "action_type": 1}
last = self._drive_api_json(self._post(url, json_data=payload, params=params, headers=self._credential.get_headers()), context="移动网盘文件")
if not self._api_success(last):
return last
task_id = last.get("data", {}).get("task_id")
if task_id:
task = self.query_task(task_id)
if not self._api_success(task) or task.get("data", {}).get("status") == -1:
return {"code": 1, "message": task.get("data", {}).get("message", task.get("message", "移动任务失败")), "data": task.get("data", {})}
return {"code": 0, "message": "移动完成", "data": last.get("data", {})}
def _task_query_params(self, retry_index: int = 0) -> Dict[str, Any]:
now_ms = int(time.time() * 1000)
return {
"pr": "UCBrowser",
"fr": "pc",
"uc_param_str": "",
"retry_index": retry_index,
"__dt": 300,
"__t": now_ms,
}
def delete_files(self, fids: List[str]) -> Dict[str, Any]:
if not fids:
return {"code": 0, "status": 200}
if self.delete(fids):
return {"code": 0, "status": 200}
return {"code": 1, "status": 500, "message": "删除文件失败"}
def query_task(self, task_id: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/task"
try:
data = self._poll_task(url, task_id, query_params=self._task_query_params)
return {"code": 0, "status": 200, "data": data}
except TransferError as exc:
return {"code": 1, "status": 500, "message": str(exc), "data": {"status": -1}}
def get_or_create_share_folder(self) -> Optional[str]:
if getattr(self, "_share_folder_fid", None):
return self._share_folder_fid
root = self.ls_dir("0")
if self._api_success(root):
for item in root.get("data", {}).get("list", []):
if item.get("file_name") == "来自:分享" and item.get("dir"):
self._share_folder_fid = str(item["fid"])
return self._share_folder_fid
result = self.mkdir("/来自:分享")
if self._api_success(result) and result.get("data", {}).get("fid"):
self._share_folder_fid = str(result["data"]["fid"])
return self._share_folder_fid
return None
def ls_dir(self, pdir_fid: str) -> Dict[str, Any]:
url = "https://pc-api.uc.cn/1/clouddrive/file/sort"
params = {"pr": "UCBrowser", "fr": "pc", "pdir_fid": pdir_fid or "0", "_page": 1, "_size": 50, "_fetch_total": 1, "_fetch_sub_dirs": 0, "_sort": "file_type:asc,updated_at:desc"}
return self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="列出网盘目录")
# ─── get_files / delete ────────────────────────────────────
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
+102 -14
View File
@@ -8,7 +8,7 @@ PLATFORM_KEY = 'xunlei'
from __future__ import annotations
import logging
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Dict
from ..base import (
BaseCloudDriveAdapter,
@@ -32,11 +32,24 @@ class XunleiAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "xunlei"
URL_PATTERNS = [r"pan\.xunlei\.com/s/([A-Za-z0-9]+)"]
capabilities: Dict[str, bool] = {
**BaseCloudDriveAdapter.capabilities,
"ensure_dir": True,
"save_files": True,
"poll_task": True,
"rename": True,
"move_files": True,
# batchDelete is permanent on Xunlei, so do not advertise it as generic delete_files.
"delete_files": False,
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
# BaseCloudDriveAdapter.__init__ calls _setup_session(), so credential
# state must exist before super().__init__.
self._credential = XunleiCredentialManager(config)
self._transfer_engine: Optional[XunleiTransfer] = None
self._cleanup = XunleiCleanup()
self._cleanup = XunleiCleanup(self._credential)
super().__init__(config, transfer_config)
def _setup_session(self):
"""初始化 session 认证头"""
@@ -54,27 +67,35 @@ class XunleiAdapter(BaseCloudDriveAdapter):
"""懒加载转存引擎"""
if self._transfer_engine is None:
self._transfer_engine = XunleiTransfer(
self.session,
self._credential,
self.config,
self.transfer_config,
credential=self._credential,
timeout=self.transfer_config.request_timeout,
poll_interval=self.transfer_config.task_poll_interval,
poll_max_attempts=self.transfer_config.task_poll_max_attempts,
)
self._transfer_engine.session = self.session
return self._transfer_engine
# ─── 抽象方法实现 ──────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
self._ensure_auth()
return self._transfer.get_share_info(pwd_id, passcode)
return self._transfer._get_share_info(pwd_id, passcode)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
self._ensure_auth()
return self._transfer.save_files(pwd_id, detail, save_dir)
target = self.ensure_dir(save_dir) if save_dir and save_dir.startswith("/") else (save_dir or "")
files = detail.get("files", [])
file_ids = [f.get("file_id") or f.get("fid") or f.get("id") for f in files if f.get("file_id") or f.get("fid") or f.get("id")]
if not file_ids:
raise RuntimeError("无法从分享中提取文件ID")
task_id = self._transfer._restore_files(pwd_id, detail.get("pass_code_token", ""), file_ids, parent_id=target)
mapping = self._transfer._poll_restore_task(task_id)
return [mapping.get(fid, "") for fid in file_ids if mapping.get(fid, "")]
def _create_share(self, file_ids: List[str], title: str,
password: str = "") -> Tuple[str, str]:
self._ensure_auth()
return self._transfer.create_share(file_ids, title, password)
return self._transfer._create_share(file_ids, password=password)
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
files = detail.get("files", [])
@@ -94,13 +115,80 @@ class XunleiAdapter(BaseCloudDriveAdapter):
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
self._ensure_auth()
return self._transfer.list_files(parent_fid)
url = "https://api-pan.xunlei.com/drive/v1/files"
params = {"parent_id": "" if parent_fid in ("0", "/") else parent_fid}
data = self._drive_api_json(self._get(url, params=params, headers=self._credential.get_headers()), context="迅雷网盘列目录")
items = data.get("files", data.get("list", []))
return [FileInfo(fid=item.get("id") or item.get("file_id", ""), name=item.get("name", ""), size=item.get("size", 0), is_dir=item.get("kind") == "drive#folder" or item.get("is_dir", False)) for item in items]
def ensure_dir(self, dir_path: str) -> str:
normalized = "/" + (dir_path or "/").strip("/")
if normalized == "/":
return ""
parent_id = ""
current = ""
for part in [p for p in normalized.split("/") if p]:
current = f"{current}/{part}" if current else f"/{part}"
existing = next((item for item in self.get_files(parent_id or "0") if item.is_dir and item.name == part), None)
if existing:
parent_id = existing.fid
continue
result = self.mkdir(current if not parent_id else part, parent_id=parent_id)
parent_id = result.get("data", {}).get("fid", parent_id)
return parent_id
def get_fids(self, file_paths: List[str]) -> List[Dict]:
"""Resolve existing file/directory paths without creating anything."""
results: List[Dict] = []
for path in file_paths:
normalized = "/" + (path or "").strip("/")
if normalized == "/":
results.append({"file_path": path, "fid": ""})
continue
parent_id = ""
found: Optional[FileInfo] = None
missing = False
parts = [p for p in normalized.split("/") if p]
for index, part in enumerate(parts):
found = next((item for item in self.get_files(parent_id or "0") if item.name == part), None)
if not found:
missing = True
break
if index < len(parts) - 1 and not found.is_dir:
missing = True
break
parent_id = found.fid
if not missing and found:
results.append({"file_path": path, "fid": found.fid})
return results
def mkdir(self, dir_path: str, parent_id: str = "") -> Dict:
name = dir_path.rstrip("/").rsplit("/", 1)[-1]
body = {"kind": "drive#folder", "name": name, "parent_id": parent_id or ""}
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files", json_data=body, headers=self._credential.get_headers_with_captcha(action="mkdir")), context="迅雷网盘创建目录")
return {"code": 0, "status": 200, "data": {"fid": data.get("id") or data.get("file_id", ""), **data}}
def rename(self, fid: str, file_name: str) -> Dict:
data = self._drive_api_json(self._post(f"https://api-pan.xunlei.com/drive/v1/files/{fid}", json_data={"name": file_name}, headers=self._credential.get_headers()), context="迅雷网盘重命名")
return {"code": 0, "status": 200, "data": data}
def move_files(self, fids: List[str], to_pdir_fid: str) -> Dict:
target = self.ensure_dir(to_pdir_fid) if to_pdir_fid.startswith("/") else to_pdir_fid
data = self._drive_api_json(self._post("https://api-pan.xunlei.com/drive/v1/files:batchMove", json_data={"ids": fids, "parent_id": target}, headers=self._credential.get_headers()), context="迅雷网盘移动文件")
return {"code": 0, "status": 200, "data": data}
def query_task(self, task_id: str) -> Dict:
return self.poll_task(task_id)
def poll_task(self, task_id: str) -> Dict:
return {"code": 0, "status": 200, "data": self._transfer._poll_restore_task(task_id)}
def delete_files(self, fids: List[str]) -> Dict:
return {"code": 0, "status": 200} if self.delete(fids) else {"code": -1, "status": 500}
def delete(self, file_ids: List[str]) -> bool:
self._ensure_auth()
return self._cleanup.delete_files(
self.session, self._credential, file_ids
)
return self._cleanup.delete_files(file_ids)
def _get_banned_keywords(self) -> List[str]:
return self.config.banned_keywords or self.transfer_config.default_banned_keywords
@@ -70,7 +70,7 @@ class XunleiTransfer:
# ─── 步骤 ①:获取分享详情 ─────────────────────────────────────
def _get_share_info(self, share_id: str) -> Dict[str, Any]:
def _get_share_info(self, share_id: str, passcode: str = "") -> Dict[str, Any]:
"""步骤①:获取分享详情。
GET /drive/v1/share?share_id=<share_id>
@@ -88,6 +88,8 @@ class XunleiTransfer:
"""
url = f"{XUNLEI_PAN_API}/drive/v1/share"
params: Dict[str, str] = {"share_id": share_id}
if passcode:
params["pass_code"] = passcode
headers = self.credential.get_headers()
logger.info("[XunleiTransfer] ① Fetching share info for share_id=%s", share_id)
@@ -303,6 +305,7 @@ class XunleiTransfer:
def _create_share(
self,
file_ids: List[str],
password: str = "",
expiration_days: str = "-1",
) -> Tuple[str, str]:
"""步骤④:创建新分享链接。
@@ -331,6 +334,8 @@ class XunleiTransfer:
"file_ids": file_ids,
"expiration_days": expiration_days,
}
if password:
body["pass_code"] = password
# share 操作可能需要 captcha_token
headers = self.credential.get_headers_with_captcha(action="share")
headers.setdefault("Content-Type", "application/json")
@@ -367,7 +372,7 @@ class XunleiTransfer:
share_url,
pass_code,
)
return share_url, pass_code
return share_url, pass_code or password
# ─── 公开入口 ─────────────────────────────────────────────────
+2 -2
View File
@@ -150,8 +150,8 @@ class ConfigManager:
"platforms": {
name: {
"enabled": cfg.enabled,
"cookie": cfg.cookie[:20] + "..." if cfg.cookie else "",
"refresh_token": cfg.refresh_token[:20] + "..." if cfg.refresh_token else "",
"cookie": cfg.cookie,
"refresh_token": cfg.refresh_token,
"account_name": cfg.account_name,
"save_dir": cfg.save_dir,
"share_password": cfg.share_password,
+19 -4
View File
@@ -7,8 +7,8 @@ import os
import uuid
import logging
from flask import Flask, request, jsonify
from config import ConfigManager
from orchestration.transfer import TransferOrchestrator
from cloudsearch_transfer.config import ConfigManager
from cloudsearch_transfer.orchestration.transfer import TransferOrchestrator
# ─── 初始化 ────────────────────────────────────────────
@@ -149,6 +149,18 @@ def stats():
return jsonify(orchestrator.get_stats())
def require_config_auth():
token = os.getenv("TRANSFER_API_TOKEN", "")
if not token:
return jsonify({"error": "TRANSFER_API_TOKEN is required"}), 401
supplied = request.headers.get("X-Transfer-Token") or request.headers.get("Authorization", "")
if supplied.startswith("Bearer "):
supplied = supplied[7:]
if supplied != token:
return jsonify({"error": "unauthorized"}), 401
return None
# ─── 配置管理 ──────────────────────────────────────────
@app.route("/api/config/platforms", methods=["GET"])
@@ -169,14 +181,17 @@ def get_platforms():
@app.route("/api/config/platforms/<name>", methods=["PUT"])
def update_platform(name):
"""更新平台配置"""
auth_error = require_config_auth()
if auth_error:
return auth_error
data = request.get_json() or {}
if name not in config.platforms:
from config import PlatformConfig
from cloudsearch_transfer.config import PlatformConfig
config.platforms[name] = PlatformConfig()
cfg = config.platforms[name]
if "enabled" in data:
cfg.enabled = data["enabled"]
cfg.enabled = bool(data["enabled"])
if "cookie" in data:
cfg.cookie = data["cookie"]
if "refresh_token" in data:
@@ -0,0 +1,198 @@
import unittest
from unittest.mock import patch
from cloudsearch_transfer.adapter.aliyun import AliyunAdapter
from cloudsearch_transfer.adapter.baidu import BaiduAdapter
from cloudsearch_transfer.adapter.xunlei import XunleiAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import FileInfo
class DummyCredential:
cookie = "k=xxxxxxxx"
refresh_token = "rt"
def validate(self):
return True
def get_bdstoken(self):
return "bdstoken"
def get_drive_id(self):
return "drive-id"
def get_headers(self):
return {}
def get_auth_headers(self):
return {}
def make_config(**kwargs):
data = dict(enabled=True, cookie="k=" + "x" * 80, refresh_token="rt", account_name="test", save_dir="/")
data.update(kwargs)
return PlatformConfig(**data)
def make_transfer_config():
return TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False, task_poll_interval=0, task_poll_max_attempts=2)
class P1DriveApiCapabilityTests(unittest.TestCase):
def make_aliyun(self):
with patch("cloudsearch_transfer.adapter.aliyun.AliyunCredentialManager", return_value=DummyCredential()):
return AliyunAdapter(make_config(), make_transfer_config())
def make_baidu(self):
with patch("cloudsearch_transfer.adapter.baidu.BaiduCredentialManager", return_value=DummyCredential()):
return BaiduAdapter(make_config(), make_transfer_config())
def make_xunlei(self):
with patch("cloudsearch_transfer.adapter.xunlei.XunleiCredentialManager", return_value=DummyCredential()):
return XunleiAdapter(make_config(), make_transfer_config())
def test_p1_adapters_declare_callable_core_drive_api_methods(self):
expectations = {
"aliyun": (self.make_aliyun(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"baidu": (self.make_baidu(), ["ensure_dir", "save_files", "rename", "move_files", "delete_files"]),
"xunlei": (self.make_xunlei(), ["ensure_dir", "save_files", "poll_task", "rename", "move_files"]),
}
for platform, (adapter, required) in expectations.items():
with self.subTest(platform=platform):
for capability in required:
self.assertTrue(adapter.capabilities.get(capability), f"{platform}.{capability} capability not declared")
for method in ["ensure_dir", "mkdir", "rename", "move_files"]:
self.assertNotEqual(getattr(type(adapter), method), getattr(adapter.__class__.__mro__[1], method, None), f"{platform}.{method} not overridden")
if adapter.capabilities.get("delete_files"):
self.assertNotEqual(getattr(type(adapter), "delete_files"), getattr(adapter.__class__.__mro__[1], "delete_files", None), f"{platform}.delete_files not overridden")
def test_aliyun_save_files_resolves_path_to_file_id_before_copy(self):
adapter = self.make_aliyun()
adapter.ensure_dir = lambda path: "target-folder-id"
captured = {}
transfer = adapter._get_transfer()
def fake_batch_copy(share_id, share_token, file_ids, to_parent):
captured["to_parent"] = to_parent
return ["new-file"]
transfer._batch_copy = fake_batch_copy
detail = {"share_token": "share-token", "files": [{"file_id": "src-file"}]}
self.assertEqual(adapter._save_files("share-id", detail, "/Media"), ["new-file"])
self.assertEqual(captured["to_parent"], "target-folder-id")
def test_aliyun_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_aliyun()
existing = {"/A": "fid-A"}
created = []
adapter.get_fids = lambda paths: [{"file_path": p, "fid": existing[p]} for p in paths if p in existing]
def fake_mkdir(path):
created.append(path)
existing[path] = "fid-" + path.rsplit("/", 1)[-1]
return {"code": 0, "data": {"fid": existing[path]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A/B"])
def test_baidu_save_files_ensures_path_before_transfer_and_matching(self):
adapter = self.make_baidu()
adapter.ensure_dir = lambda path: "/CloudSearch/Media"
captured = {}
adapter.credential.get_bdstoken = lambda: "bdstoken"
def fake_transfer_files(shareid, uk, fs_ids, save_dir, token):
captured["transfer_dir"] = save_dir
adapter._transfer._transfer_files = fake_transfer_files
def fake_list_and_match(save_dir, filenames, token):
captured["list_dir"] = save_dir
return ["1001"]
adapter._transfer._list_and_match = fake_list_and_match
detail = {"shareid": "sid", "uk": "uk", "fs_ids": ["old"], "filenames": ["demo.mkv"]}
self.assertEqual(adapter._save_files("pwd", detail, "/CloudSearch/Media"), ["1001"])
self.assertEqual(captured["transfer_dir"], "/CloudSearch/Media")
self.assertEqual(captured["list_dir"], "/CloudSearch/Media")
def test_baidu_ensure_dir_creates_nested_paths_progressively(self):
adapter = self.make_baidu()
dirs = {"/A"}
created = []
adapter.get_files = lambda parent="/": [FileInfo(fid=parent.rstrip("/") + "/A", name="A", is_dir=True)] if parent == "/" else []
def fake_mkdir(path):
created.append(path)
dirs.add(path)
return {"code": 0, "data": {"path": path}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "/A/B")
self.assertEqual(created, ["/A/B"])
def test_xunlei_save_files_resolves_path_and_polls_restore_task(self):
adapter = self.make_xunlei()
adapter.ensure_dir = lambda path: "target-parent-id"
transfer = adapter._transfer
transfer._restore_files = lambda share_id, token, fids, parent_id="": "task-1" if parent_id == "target-parent-id" else (_ for _ in ()).throw(AssertionError(parent_id))
transfer._poll_restore_task = lambda task_id: {"old-1": "new-1"}
detail = {"share_id": "share", "pass_code_token": "pct", "files": [{"id": "old-1", "name": "demo"}]}
self.assertEqual(adapter._save_files("share", detail, "/Media"), ["new-1"])
def test_xunlei_query_task_delegates_to_restore_poll(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.query_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_does_not_advertise_permanent_delete_as_drive_delete_capability(self):
adapter = self.make_xunlei()
self.assertFalse(adapter.capabilities.get("delete_files"), "迅雷 batchDelete is permanent and must not be advertised as generic delete_files")
def test_xunlei_get_share_detail_passes_extraction_code_to_transfer_engine(self):
adapter = self.make_xunlei()
captured = {}
def fake_get_share_info(share_id, passcode=""):
captured["share_id"] = share_id
captured["passcode"] = passcode
return {"title": "demo", "files": [{"id": "old"}], "pass_code_token": "pct"}
adapter._transfer._get_share_info = fake_get_share_info
detail = adapter._get_share_detail("share-id", "abcd")
self.assertEqual(detail["title"], "demo")
self.assertEqual(captured, {"share_id": "share-id", "passcode": "abcd"})
def test_xunlei_create_share_forwards_requested_share_password(self):
adapter = self.make_xunlei()
captured = {}
def fake_create_share(file_ids, password=""):
captured["file_ids"] = file_ids
captured["password"] = password
return ("https://pan.xunlei.com/s/new", password)
adapter._transfer._create_share = fake_create_share
self.assertEqual(adapter._create_share(["fid-1"], "demo", "xy12"), ("https://pan.xunlei.com/s/new", "xy12"))
self.assertEqual(captured, {"file_ids": ["fid-1"], "password": "xy12"})
def test_xunlei_poll_task_alias_matches_declared_capability(self):
adapter = self.make_xunlei()
adapter._transfer._poll_restore_task = lambda task_id: {"old": "new"}
self.assertEqual(adapter.poll_task("task-1"), {"code": 0, "status": 200, "data": {"old": "new"}})
def test_xunlei_get_fids_is_read_only_and_does_not_create_missing_paths(self):
adapter = self.make_xunlei()
calls = []
def fake_get_files(parent="0"):
calls.append(("get_files", parent))
if parent in ("0", ""):
return [FileInfo(fid="fid-A", name="A", is_dir=True)]
if parent == "fid-A":
return [FileInfo(fid="fid-B", name="B", is_dir=False)]
return []
def fail_mkdir(*args, **kwargs):
raise AssertionError("get_fids must not create directories")
adapter.get_files = fake_get_files
adapter.mkdir = fail_mkdir
self.assertEqual(adapter.get_fids(["/A/B", "/A/Missing"]), [{"file_path": "/A/B", "fid": "fid-B"}])
self.assertEqual(calls, [("get_files", "0"), ("get_files", "fid-A"), ("get_files", "0"), ("get_files", "fid-A")])
def test_baidu_get_fids_checks_existing_parent_listing(self):
adapter = self.make_baidu()
calls = []
def fake_get_files(parent="/"):
calls.append(parent)
if parent == "/A":
return [FileInfo(fid="1001", name="movie.mkv", is_dir=False)]
return []
adapter.get_files = fake_get_files
self.assertEqual(adapter.get_fids(["/A/movie.mkv", "/A/missing.mkv"]), [{"file_path": "/A/movie.mkv", "fid": "1001", "path": "/A/movie.mkv"}])
self.assertEqual(calls, ["/A", "/A"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,308 @@
import unittest
import requests
from unittest.mock import patch
from cloudsearch_transfer.adapter.quark import QuarkAdapter
from cloudsearch_transfer.adapter.uc import UcAdapter
from cloudsearch_transfer.config import PlatformConfig, TransferConfig
from cloudsearch_transfer.adapter.base import BaseCloudDriveAdapter
from cloudsearch_transfer.errors import TransferError
class DummyResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def raise_for_status(self):
return None
def make_adapter(adapter_cls):
return adapter_cls(
PlatformConfig(enabled=True, cookie="k=" + "x" * 80, account_name="test"),
TransferConfig(request_timeout=1, max_retries=0, ad_filter_enabled=False),
)
class QuarkUcCapabilityTests(unittest.TestCase):
def test_quark_declares_p0_drive_api_capabilities(self):
adapter = make_adapter(QuarkAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["cleanup_recycle"])
def test_uc_declares_p0_drive_api_capabilities_and_share_staging(self):
adapter = make_adapter(UcAdapter)
self.assertTrue(adapter.capabilities["ensure_dir"])
self.assertTrue(adapter.capabilities["save_files"])
self.assertTrue(adapter.capabilities["poll_task"])
self.assertTrue(adapter.capabilities["rename"])
self.assertTrue(adapter.capabilities["move_files"])
self.assertTrue(adapter.capabilities["delete_files"])
self.assertTrue(adapter.capabilities["share_staging_folder"])
def test_quark_ensure_dir_uses_path_lookup_before_mkdir(self):
adapter = make_adapter(QuarkAdapter)
mkdir_calls = []
adapter.get_fids = lambda paths: [{"file_path": "/Media", "fid": "fid-media"}]
adapter.mkdir = lambda path: mkdir_calls.append(path) or {"code": 0, "data": {"fid": "new"}}
self.assertEqual(adapter.ensure_dir("/Media"), "fid-media")
self.assertEqual(mkdir_calls, [])
def test_quark_ensure_dir_creates_missing_path(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "created-fid"}}
self.assertEqual(adapter.ensure_dir("Media/Shows"), "created-fid")
def test_uc_share_folder_created_when_missing(self):
adapter = make_adapter(UcAdapter)
adapter.ls_dir = lambda parent: {"code": 0, "data": {"list": []}}
adapter.mkdir = lambda path: {"code": 0, "data": {"fid": "share-folder-fid"}}
self.assertEqual(adapter.get_or_create_share_folder(), "share-folder-fid")
def test_uc_move_files_polls_task_until_complete(self):
adapter = make_adapter(UcAdapter)
calls = []
def fake_post(url, json_data=None, params=None, headers=None):
calls.append((url, json_data))
return DummyResponse({"code": 0, "status": 200, "data": {"task_id": "task-1"}})
adapter._post = fake_post
adapter.query_task = lambda task_id: {"code": 0, "status": 200, "data": {"status": 2}}
result = adapter.move_files(["fid-1", "fid-2"], "target-fid")
self.assertEqual(result["code"], 0)
self.assertEqual(calls[0][1]["filelist"], ["fid-1", "fid-2"])
self.assertEqual(calls[0][1]["to_pdir_fid"], "target-fid")
def test_quark_save_files_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._transfer_engine._get_stoken = lambda pwd_id: "stoken"
captured = {}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: captured.setdefault("to_pdir_fid", to_pdir_fid) or "task-1"
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
self.assertEqual(adapter._save_files("pwd", {"fid": "src"}, "/Media"), ["new-fid"])
self.assertEqual(captured["to_pdir_fid"], "target-fid")
def test_uc_transfer_resolves_path_to_fid(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "target-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_dir"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_quark_transfer_resolves_path_to_fid(self):
adapter = make_adapter(QuarkAdapter)
adapter.ensure_dir = lambda path: "target-fid"
adapter._credential.validate = lambda: True
captured = {}
def fake_transfer(share_url, save_dir, share_password):
captured["save_dir"] = save_dir
return {
"new_file_ids": ["new-fid"],
"file_name": "demo",
"share_url": "https://pan.quark.cn/s/new",
"passcode": share_password,
}
adapter._transfer_engine.transfer = fake_transfer
result = adapter._transfer("https://pan.quark.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_dir"], "target-fid")
def test_ensure_dir_creates_nested_paths_progressively(self):
adapter = make_adapter(QuarkAdapter)
adapter.get_fids = lambda paths: []
created = []
def fake_mkdir(path):
created.append(path)
return {"code": 0, "data": {"fid": "fid-" + path.rsplit("/", 1)[-1]}}
adapter.mkdir = fake_mkdir
self.assertEqual(adapter.ensure_dir("/A/B"), "fid-B")
self.assertEqual(created, ["/A", "/A/B"])
def test_uc_transfer_uses_staging_folder_then_moves_to_target(self):
adapter = make_adapter(UcAdapter)
adapter._credential.validate = lambda: True
adapter.ensure_dir = lambda path: "target-fid"
adapter.get_or_create_share_folder = lambda: "staging-fid"
adapter._parse_share_url = lambda url: ("pwd", "pass")
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode="": "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "demo", "fid": "src"}
captured = {}
def fake_init_save(pwd_id, stoken, detail, to_pdir_fid):
captured["save_to"] = to_pdir_fid
return "save-task"
adapter._transfer_engine._init_save = fake_init_save
adapter._transfer_engine._poll_save_task = lambda task_id: ["new-fid"]
def fake_move(fids, to_pdir_fid):
captured["move_to"] = to_pdir_fid
return {"code": 0, "status": 200}
adapter.move_files = fake_move
adapter._transfer_engine._init_share = lambda fids, title: "share-task"
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, password: ("https://drive.uc.cn/s/new", password)
result = adapter.transfer("https://drive.uc.cn/s/abcdef", save_dir="/Media", share_password="pw")
self.assertTrue(result.success)
self.assertEqual(captured["save_to"], "staging-fid")
self.assertEqual(captured["move_to"], "target-fid")
def test_api_success_requires_explicit_success_code_or_status(self):
adapter = make_adapter(QuarkAdapter)
self.assertFalse(adapter._api_success({}))
self.assertFalse(adapter._api_success({"message": "bad"}))
self.assertTrue(adapter._api_success({"code": 0}))
self.assertTrue(adapter._api_success({"status": 200}))
def test_query_task_passes_retry_index_and_timestamp_params(self):
adapter = make_adapter(QuarkAdapter)
captured_params = []
attempts = {"count": 0}
def fake_get(url, params=None, retry=None):
captured_params.append(dict(params))
attempts["count"] += 1
if attempts["count"] == 1:
return DummyResponse({"data": {"status": 1}})
return DummyResponse({"data": {"status": 2, "task_id": "task-1"}})
adapter._get = fake_get
result = adapter.query_task("task-1")
self.assertEqual(result["code"], 0)
self.assertEqual(captured_params[0]["retry_index"], 0)
self.assertEqual(captured_params[1]["retry_index"], 1)
self.assertIn("__dt", captured_params[0])
self.assertIn("__t", captured_params[0])
def test_quark_cleanup_recycle_only_removes_matching_fids(self):
adapter = make_adapter(QuarkAdapter)
adapter.recycle_list = lambda: [
{"fid": "keep", "record_id": "r-keep"},
{"fid": "target", "record_id": "r-target"},
]
captured = {}
def fake_recycle_remove(records):
captured["records"] = records
return {"code": 0}
adapter.recycle_remove = fake_recycle_remove
result = adapter.cleanup_recycle(["target"])
self.assertEqual(result["code"], 0)
self.assertEqual(captured["records"], [{"fid": "target", "record_id": "r-target"}])
def test_drive_api_json_raises_transfer_error_on_http_error(self):
adapter = make_adapter(QuarkAdapter)
class ErrorResponse(DummyResponse):
status_code = 429
text = "too many requests"
def raise_for_status(self):
raise requests.HTTPError("429 Too Many Requests")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(ErrorResponse({"code": 0}), context="限流测试")
self.assertIn("限流测试", str(ctx.exception))
def test_drive_api_json_raises_transfer_error_on_invalid_json(self):
adapter = make_adapter(QuarkAdapter)
class InvalidJsonResponse(DummyResponse):
status_code = 200
text = "<html>bad gateway</html>"
def json(self):
raise ValueError("not json")
with self.assertRaises(Exception) as ctx:
adapter._drive_api_json(InvalidJsonResponse({}), context="JSON测试")
self.assertIn("JSON测试", str(ctx.exception))
def test_quark_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(QuarkAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1", "fid-2"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1", "fid-2"]])
def test_uc_delete_files_capability_calls_drive_delete(self):
adapter = make_adapter(UcAdapter)
calls = []
adapter.delete = lambda fids: calls.append(list(fids)) or True
self.assertEqual(adapter.delete_files(["fid-1"]), {"code": 0, "status": 200})
self.assertEqual(calls, [["fid-1"]])
def test_uc_transfer_filters_ads_after_staging_save(self):
adapter = make_adapter(UcAdapter)
adapter.ensure_dir = lambda path: "target-dir"
adapter.get_or_create_share_folder = lambda: "staging-dir"
adapter._parse_share_url = lambda url: ("pwd-id", "")
adapter._filter_ads = lambda fids: [fid for fid in fids if fid != "ad-fid"]
adapter.transfer_config.ad_filter_enabled = True
adapter._transfer_engine._get_stoken = lambda pwd_id, passcode: "stoken"
adapter._transfer_engine._get_detail = lambda pwd_id, stoken: {"title": "Title"}
adapter._transfer_engine._init_save = lambda pwd_id, stoken, detail, to_pdir_fid: "save-task"
adapter._transfer_engine._poll_save_task = lambda task_id: ["keep-fid", "ad-fid"]
adapter.move_files = lambda fids, target: {"code": 0}
adapter._transfer_engine._init_share = lambda fids, title: "share-task" if fids == ["keep-fid"] else (_ for _ in ()).throw(AssertionError(f"unfiltered fids: {fids}"))
adapter._transfer_engine._poll_share_task = lambda task_id: "share-id"
adapter._transfer_engine._set_password = lambda share_id, pwd: ("https://share", pwd)
result = adapter.transfer("https://drive.uc.cn/s/abc")
self.assertEqual(result.new_file_id, "keep-fid")
def test_uc_ls_dir_wraps_http_and_json_errors(self):
adapter = make_adapter(UcAdapter)
class BadResponse:
text = "<html>bad gateway</html>"
def raise_for_status(self):
raise requests.HTTPError("HTTP 502")
def json(self):
raise AssertionError("json() should not be called after HTTP error")
adapter._get = lambda *args, **kwargs: BadResponse()
with self.assertRaises(TransferError):
adapter.ls_dir("0")
def test_base_declares_optional_drive_api_methods(self):
required = [
"ensure_dir", "get_fids", "mkdir", "rename", "move_files",
"delete_files", "query_task", "cleanup_recycle",
]
for name in required:
self.assertTrue(hasattr(BaseCloudDriveAdapter, name), name)
def test_base_optional_drive_api_methods_raise_transfer_error(self):
class MinimalAdapter(BaseCloudDriveAdapter):
PLATFORM_KEY = "minimal"
def verify(self, share_url):
raise NotImplementedError
def _get_share_detail(self, pwd_id, passcode=""):
raise NotImplementedError
def _save_files(self, pwd_id, detail, save_dir):
raise NotImplementedError
def _create_share(self, file_ids, title, password=""):
raise NotImplementedError
def get_files(self, parent_fid="0"):
raise NotImplementedError
def delete(self, file_ids):
raise NotImplementedError
adapter = MinimalAdapter(PlatformConfig(), TransferConfig())
with self.assertRaises(TransferError):
adapter.ensure_dir("/Media")
with self.assertRaises(TransferError):
adapter.move_files(["fid"], "target")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,135 @@
import subprocess
import unittest
from pathlib import Path
class TransferServerRuntimeTests(unittest.TestCase):
def test_transfer_image_imports_server_module_from_app_workdir(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config.json",
"cloudsearch-transfer:test",
"python", "-c",
"import cloudsearch_transfer.server as s; print(hasattr(s, 'app'))",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("True", result.stdout)
def test_update_platform_creates_config_with_package_import(self):
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_server_test.json",
"cloudsearch-transfer:test",
"python", "-c",
"""
import os
os.environ['TRANSFER_API_TOKEN'] = 'test-token'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'test-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(resp.status_code)
print(resp.get_data(as_text=True))
""",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("200", result.stdout)
self.assertIn("newplatform", result.stdout)
def test_dockerfile_healthcheck_uses_http_health_probe(self):
dockerfile = Path(__file__).resolve().parents[1] / "Dockerfile"
content = dockerfile.read_text()
healthcheck_block = content.split("HEALTHCHECK", 1)[1].split("\n\nCMD", 1)[0]
self.assertIn("/health", healthcheck_block)
self.assertNotIn('python -m cloudsearch_transfer.server', healthcheck_block)
def test_config_update_requires_api_token_when_configured(self):
script = """
import os
os.environ['TRANSFER_API_TOKEN'] = 'secret-token'
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
unauth = client.put('/api/config/platforms/newplatform', json={'enabled': True})
auth = client.put('/api/config/platforms/newplatform', headers={'X-Transfer-Token': 'secret-token'}, json={'enabled': True, 'save_dir': '/Media'})
print(unauth.status_code)
print(auth.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
self.assertIn("200", result.stdout)
def test_config_update_requires_token_by_default(self):
script = """
import os
os.environ.pop('TRANSFER_API_TOKEN', None)
os.environ['TRANSFER_CONFIG_PATH'] = '/tmp/transfer_config_default_auth_test.json'
from cloudsearch_transfer.server import app
client = app.test_client()
resp = client.put('/api/config/platforms/newplatform', json={'enabled': True})
print(resp.status_code)
"""
result = subprocess.run(
[
"docker", "run", "--rm",
"-e", "TRANSFER_CONFIG_PATH=/tmp/transfer_config_default_auth_test.json",
"cloudsearch-transfer:test",
"python", "-c", script,
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("401", result.stdout)
def test_config_save_persists_full_credentials_for_runtime_reload(self):
script = """
from cloudsearch_transfer.config import ConfigManager, PlatformConfig
path = '/tmp/transfer_config_persist_test.json'
cfg = ConfigManager(path)
cfg.platforms['quark'] = PlatformConfig(enabled=True, cookie='cookie-value-that-is-longer-than-twenty-characters', refresh_token='refresh-token-that-is-longer-than-twenty-characters')
cfg.save()
reloaded = ConfigManager(path)
print(reloaded.platforms['quark'].cookie)
print(reloaded.platforms['quark'].refresh_token)
"""
result = subprocess.run(
["docker", "run", "--rm", "cloudsearch-transfer:test", "python", "-c", script],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("cookie-value-that-is-longer-than-twenty-characters", result.stdout)
self.assertIn("refresh-token-that-is-longer-than-twenty-characters", result.stdout)
self.assertNotIn("...", result.stdout)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -70,6 +70,7 @@ services:
- CORS_ORIGIN=${CORS_ORIGIN}
# ── 数据库 & 缓存 ──
- DATA_DIR=/data
- DB_PATH=${DB_PATH:-/data/database.sqlite}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
+19
View File
@@ -0,0 +1,19 @@
# CloudSearch 测试服部署记录
- 服务器:泽御云香港测试服,SSH 入口 root@82.158.228.152:18924。
- 当前运行来源:`/root/cloudsearch_deploy`
- 主应用目录:`/root/cloudsearch_deploy/source_clean`
- 网盘能力层:`/root/cloudsearch_deploy/cloudsearch_transfer`
- 启动方式:Docker Compose,主容器挂载 `/root/cloudsearch_deploy` 相关目录。
- 集成边界:只集成 cloud-auto-save 中可复用的网盘 API 调用能力;不集成签到、收益事件、收益上报,也不长期部署独立 cloud-auto-save 服务。
- Video Parser 当前未部署,属于测试环境预期缺口,不作为主服务故障处理。
- 本轮隔离点:`backup-before-drive-api-20260522-152138`;开发分支:`feature/cloudsearch-drive-api-20260522-152138`
## 验证命令
```bash
python3 -m unittest discover -s cloudsearch_transfer/tests -p 'test_*.py' -v
python3 /tmp/verify_compile.py
docker compose ps
curl -fsS http://127.0.0.1:3000/health
```
+1 -1
View File
@@ -1 +1 @@
0.3.53
0.5.6
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/source_clean"
VERSION=$(cat ../VERSION)
echo "🔨 Building CloudSearch v${VERSION}..."
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
echo " Run: docker-compose up -d app"
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
set -e
# CloudSearch 一键部署
# curl -sS https://gitea.timxx.cn/admin/CloudSearch/raw/branch/master/source_clean/deploy.sh | bash
#
# 环境变量(可选):
# CORS_ORIGIN - 域名 (默认自动检测公网IP)
# ADMIN_PASSWORD - 管理员密码 (留空自动生成)
# JWT_SECRET - JWT密钥 (留空自动生成)
# DEPLOY_DIR - 部署目录 (默认 /opt/cloudsearch)
REPO_URL="https://gitea.timxx.cn/admin/CloudSearch.git"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/cloudsearch}"
info() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*"; }
err() { echo "[ERROR] $*"; }
command -v docker &>/dev/null || { err "Docker未安装"; exit 1; }
# -- CORS_ORIGIN --
if [ -z "$CORS_ORIGIN" ]; then
PUBLIC_IP=$(curl -s --connect-timeout 3 ifconfig.me 2>/dev/null || curl -s --connect-timeout 3 ip.sb 2>/dev/null || echo "")
if [ -n "$PUBLIC_IP" ]; then
CORS_ORIGIN="http://${PUBLIC_IP}:9527"
else
CORS_ORIGIN="http://localhost:9527"
fi
warn "CORS_ORIGIN=$CORS_ORIGIN (自动检测)"
fi
# -- 拉取仓库 --
info "拉取仓库..."
if [ -d "$DEPLOY_DIR/.git" ]; then
cd "$DEPLOY_DIR" && git pull --ff-only origin master 2>/dev/null || true
else
rm -rf "$DEPLOY_DIR"
git clone --depth 1 "$REPO_URL" "$DEPLOY_DIR"
fi
cd "$DEPLOY_DIR/source_clean"
# -- 切换镜像模式 --
if grep -q '^ build:' docker-compose.yml 2>/dev/null; then
info "切换镜像模式..."
sed -i 's/^ build:/ # build:/' docker-compose.yml
sed -i 's/^ context:/ # context:/' docker-compose.yml
sed -i 's/^ dockerfile:/ # dockerfile:/' docker-compose.yml
sed -i 's|^ # image: gitea.timxx.cn/admin/cloudsearch:latest| image: gitea.timxx.cn/admin/cloudsearch:latest|' docker-compose.yml
fi
# -- 检测 Redis --
info "检测Redis..."
EXISTING_REDIS=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i redis | grep -v CloudSearch | head -1)
if [ -n "$EXISTING_REDIS" ]; then
REDIS_URL="redis://${EXISTING_REDIS}:6379"
info "复用已有Redis: $EXISTING_REDIS"
else
REDIS_URL="redis://redis:6379"
info "使用Compose自带Redis"
fi
# -- 生成密钥 --
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(openssl rand -base64 12 | tr -d '=+/' | head -c 16)}"
# -- 生成 .env --
cat > .env <<ENVEOF
CORS_ORIGIN=${CORS_ORIGIN}
JWT_SECRET=${JWT_SECRET}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
REDIS_URL=${REDIS_URL}
LOG_LEVEL=${LOG_LEVEL:-info}
ENVEOF
info "管理员: admin / ${ADMIN_PASSWORD}"
# -- 部署 --
info "拉取镜像..."
docker compose pull app 2>/dev/null || true
info "停止旧服务..."
docker compose down --remove-orphans 2>/dev/null || true
info "启动服务..."
docker compose up -d
# -- 等待就绪 --
info "等待服务就绪..."
for i in $(seq 1 20); do
if curl -s -o /dev/null -w '%{http_code}' http://localhost:9527/health 2>/dev/null | grep -q '200'; then
break
fi
sleep 2
done
# -- 强制写入管理员密码 --
info "同步管理员密码..."
sleep 3
docker exec -e ADMIN_PASSWORD="$ADMIN_PASSWORD" CloudSearch_App node -e '
var bcrypt = require("bcryptjs");
var Database = require("better-sqlite3");
var db = new Database("/data/database.sqlite");
var pw = process.env.ADMIN_PASSWORD || ""; var hash = bcrypt.hashSync(pw, 10);
var existing = db.prepare("SELECT id FROM admins WHERE username = ?").get("admin");
if (existing) {
db.prepare("UPDATE admins SET password_hash = ? WHERE username = ?").run(hash, "admin");
} else {
db.prepare("INSERT INTO admins (username, password_hash) VALUES (?, ?)").run("admin", hash);
}
db.close();
' 2>/dev/null && info "密码已同步" || warn "密码同步失败,请稍后重试"
# -- 验证 --
if docker compose ps 2>/dev/null | grep -q 'Up'; then
echo ""
echo "=============================================="
echo " CloudSearch 部署完成"
echo "=============================================="
docker compose ps
echo ""
echo " 管理后台: ${CORS_ORIGIN}/admin/login"
echo " 用户名: admin"
echo " 密码: ${ADMIN_PASSWORD}"
else
err "启动失败: docker compose logs"
exit 1
fi
+42
View File
@@ -0,0 +1,42 @@
name: cloudsearch
services:
app:
image: gitea.timxx.cn/admin/cloudsearch:latest
container_name: CloudSearch_App
restart: unless-stopped
ports: ["9527:9527"]
environment:
REDIS_URL: ${REDIS_URL}
PANSOU_URL: http://pansou:8888
CORS_ORIGIN: ${CORS_ORIGIN:-https://zy.hk.timxx.cn}
JWT_SECRET: ${JWT_SECRET:-cloudsearch-jwt-secret-2024}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-0nL5kLhMIJ1121PYmQb25A}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes: ["cloudsearch-data:/app/data"]
depends_on: [pansou]
networks: [cloudsearch-net]
pansou:
image: ghcr.io/fish2018/pansou-web:latest
container_name: CloudSearch_PanSou
restart: unless-stopped
networks:
cloudsearch-net: { aliases: [pansou] }
# Redis — 仅当系统没有现成 Redis 时才启动
redis:
image: redis:7-alpine
container_name: CloudSearch_Redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes: ["redis-data:/data"]
networks: [cloudsearch-net]
profiles: [full]
volumes:
cloudsearch-data:
redis-data:
networks:
cloudsearch-net:
driver: bridge
@@ -0,0 +1,58 @@
# CloudSearch 单容器网盘能力 P0 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 将 UC 网盘转存能力和主应用云盘 driver 分发能力集成进 `source_clean`,由现有 `CloudSearch_App` 单容器构建和运行,不新增长期 transfer 容器。
**架构:** 在 Node/TypeScript 主应用内新增 `UcDriver` 和轻量 driver factory。`saveFromShare()`、凭据验证、连接测试仍走现有 `cloud_configs``save_records``/api/save` 路径,只扩展 `uc` 类型,不改变前端主流程。
**技术栈:** Node 20 fetch、TypeScript commonjs、Express、better-sqlite3、Vitest、Docker Compose。
---
## 文件结构
- 创建:`src/cloud/drivers/uc-api.ts` — UC API 常量、请求头、URL 解析、任务轮询辅助。
- 创建:`src/cloud/drivers/uc.driver.ts` — UC Cookie 验证、7 步转存、创建新分享,对齐现有 driver 返回结构。
- 创建:`src/cloud/drivers/uc.driver.test.ts` — UC driver 红绿回归测试,mock fetch 验证 API 流程。
- 创建:`src/cloud/driver-factory.ts` — 根据 `cloud_type` 创建 driver,避免 `credential.service.ts``cloud.service.ts` 分散 switch。
- 创建:`src/cloud/driver-factory.test.ts` — 验证 `uc` 使用 `UcDriver`,未知类型返回 `null`
- 修改:`src/cloud/cloud.service.ts` — 保存入口支持 `uc`,复用统一 driver factory。
- 修改:`src/cloud/credential.service.ts` — Cookie 测试和运行前凭据验证支持 `uc`
## 任务 1:写失败测试
- [ ] 编写 `src/cloud/driver-factory.test.ts`:断言 `createCloudDriver(uc, {cookie})` 产物 class 为 `UcDriver``unknown``null`
- [ ] 编写 `src/cloud/drivers/uc.driver.test.ts`:断言 UC URL 解析、Cookie 长度校验、mock fetch 的完整转存链路。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
- [ ] 预期:失败,原因是 `driver-factory``uc.driver` 不存在。
## 任务 2:实现 UC driver 和 factory
- [ ] 新增 `uc-api.ts``UC_API_BASE=https://pc-api.uc.cn``UC_SHARE_API``getUcHeaders()``parseUcShareUrl()`
- [ ] 新增 `uc.driver.ts`:实现 `validate()``saveFromShare()`,流程为 stoken、detail、save、poll save task、create share、poll share task、set password。
- [ ] 新增 `driver-factory.ts`:支持 `quark``baidu``aliyun``uc`
- [ ] 运行同一组测试,预期通过。
## 任务 3:接入主保存和凭据验证路径
- [ ] 修改 `cloud.service.ts`:用 factory 创建 driver`uc` 调用 `saveFromShare()`,返回字段写入现有 `save_records`
- [ ] 修改 `credential.service.ts``testCloudConnectionWithCookie(uc)``getAndValidateCredential(uc)` 使用 `UcDriver.validate()`
- [ ] 新增或扩展测试覆盖 factory 接入。
- [ ] 运行:`npm test -- src/cloud/driver-factory.test.ts src/cloud/drivers/uc.driver.test.ts`
## 任务 4:构建与单容器部署验证
- [ ] 运行:`npm run build`
- [ ] 运行:`docker compose build app`
- [ ] 运行:`docker compose up -d app`
- [ ] 验证:`docker compose ps` 只有 `app/redis/pansou`,没有 transfer 容器。
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/health`
- [ ] 验证:`curl -fsS http://127.0.0.1:9527/api/admin/cloud-types` 能看到 `uc`
## 验收标准
- `CloudSearch_App` 单容器内的主应用构建包含 `UcDriver`
- `/api/save``target_cloud=uc` 不再返回“暂不支持”。
- 无长期 `cloudsearch-transfer``cloud-auto-save-test` 容器。
- 没有收益上报、签到、独立 transfer 服务集成。
@@ -0,0 +1,24 @@
# CloudSearch 测试服治理说明
## 当前测试服定位
`/root/cloudsearch_deploy` 是当前 Docker Compose 实际运行目录。`CloudSearch_App` 的 compose label 与挂载均指向该目录。
## 健康检查口径
`Video Parser` 当前仍是待开发/未部署能力。测试服健康判断应区分核心必需组件与可选/开发中能力。
源码中的 `computeOverallHealth()` 支持通过 `VIDEO_PARSER_REQUIRED=true` 将 Video Parser 纳入必需健康判定;未设置时按可选能力处理。
## 版本来源
根目录 `VERSION``source_clean/VERSION` 当前均为 `0.5.5`。构建脚本会从根目录 `VERSION` 复制到 `source_clean/VERSION` 再构建镜像。
## 验证命令
```bash
cd /root/cloudsearch_deploy/source_clean
npm run build
npm test -- --run src/health/health.test.ts
curl -fsS http://127.0.0.1:9527/health
```
@@ -1,923 +0,0 @@
<!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="/h5/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,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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>
-599
View File
@@ -1,599 +0,0 @@
// ===== 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.(function(){
var q=QUOTES[Math.floor(Math.random()*QUOTES.length)];
document.getElementById('homeQuote').textContent=' '+q+' ';
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,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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
View File
@@ -1,160 +0,0 @@
<!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="/h5/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="/h5/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>
File diff suppressed because one or more lines are too long
-179
View File
@@ -1,179 +0,0 @@
/* ===== 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}
+41 -12
View File
@@ -2,23 +2,52 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="robots" content="index,follow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="CloudSearch - 网盘资源搜索" />
<meta property="og:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta property="og:url" content="https://zy.hk.timxx.cn/" />
<meta property="og:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="CloudSearch - 网盘资源搜索" />
<meta name="twitter:description" content="随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。" />
<meta name="twitter:image" content="https://zy.hk.timxx.cn/api/uploads/logo/site_logo.png" />
<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" />
<link rel="canonical" href="https://zy.hk.timxx.cn/" />
<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');
function setMeta(selector, attr, value) {
var el = document.querySelector(selector);
if (!el) return;
el.setAttribute(attr, value);
}
function sync(cfg) {
var origin = window.location.origin;
var siteName = cfg.site_name || 'CloudSearch';
var title = siteName + ' - 网盘资源搜索';
var description = cfg.site_disclaimer || '随便下 | 资源小站,提供网盘资源搜索、热门榜单和免责声明页面。';
var image = cfg.site_logo ? new URL(cfg.site_logo, origin).href : origin + '/api/uploads/logo/site_logo.png';
var canonical = origin + '/';
document.title = title;
setMeta('meta[name="description"]', 'content', description);
setMeta('meta[name="robots"]', 'content', 'index,follow');
setMeta('meta[property="og:title"]', 'content', title);
setMeta('meta[property="og:description"]', 'content', description);
setMeta('meta[property="og:url"]', 'content', canonical);
setMeta('meta[property="og:image"]', 'content', image);
setMeta('meta[name="twitter:title"]', 'content', title);
setMeta('meta[name="twitter:description"]', 'content', description);
setMeta('meta[name="twitter:image"]', 'content', image);
setMeta('link[rel="canonical"]', 'href', canonical);
}
sync({});
fetch('/api/site-config').then(function(r){return r.json()}).then(function(cfg){ sync(cfg || {}); }).catch(function(){});
})();
</script>
</head>
+387
View File
@@ -23,6 +23,7 @@
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
},
@@ -869,6 +870,121 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -1086,6 +1202,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1121,6 +1246,15 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1141,6 +1275,31 @@
"node": ">=6"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
@@ -1194,6 +1353,23 @@
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
"dev": true
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -1202,6 +1378,15 @@
"node": ">=0.10.0"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1294,6 +1479,12 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1362,6 +1553,15 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1568,6 +1768,12 @@
"lodash-es": "*"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1623,6 +1829,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1698,6 +1910,21 @@
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1845,6 +2072,12 @@
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1853,6 +2086,18 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1877,6 +2122,45 @@
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
@@ -1960,6 +2244,93 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
@@ -2060,6 +2431,22 @@
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+3 -1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -24,6 +25,7 @@
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.1.0"
}
}
}
@@ -1,923 +0,0 @@
<!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="/h5/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,"&#39;")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"&#39;")+'\'>'
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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>
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://zy.hk.timxx.cn/sitemap.xml
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://zy.hk.timxx.cn/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/search</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/disclaimer/</loc>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/admin/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>https://zy.hk.timxx.cn/user/login</loc>
<changefreq>monthly</changefreq>
<priority>0.2</priority>
</url>
</urlset>
+44 -117
View File
@@ -1,28 +1,51 @@
<template>
<router-view />
<button class="theme-toggle" @click="toggleTheme" :title="isDark ? '切换亮色' : '切换暗色'">
{{ isDark ? '☀️' : '🌙' }}
</button>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { applySeo, resolveSeo } from './seo'
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')
interface SiteConfig {
site_name?: string
site_logo?: string
site_disclaimer?: string
}
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')
const route = useRoute()
let currentSiteName = 'CloudSearch'
let currentSiteLogo = ''
let currentSiteDisclaimer = ''
function refreshSeo() {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
applySeo(resolveSeo(route.path, route.query as Record<string, unknown>, currentSiteName, origin, currentSiteLogo, currentSiteDisclaimer))
}
async function loadSiteConfig() {
try {
const resp = await fetch('/api/site-config')
if (!resp.ok) return
const cfg = (await resp.json()) as SiteConfig
if (cfg.site_name) currentSiteName = cfg.site_name
if (cfg.site_logo) currentSiteLogo = cfg.site_logo
if (cfg.site_disclaimer) currentSiteDisclaimer = cfg.site_disclaimer
} catch {
// keep defaults
}
}
onMounted(async () => {
await loadSiteConfig()
refreshSeo()
})
watch(
() => route.fullPath,
() => refreshSeo(),
{ immediate: true },
)
</script>
<style>
@@ -68,86 +91,6 @@ onMounted(() => {
--sidebar-w: 240px;
}
[data-theme="dark"] {
--bg: #141414;
--bg-card: #1d1d1d;
--bg-input: #2a2a2a;
--bg-page: #0f0f0f;
--bg-card-header: linear-gradient(135deg, #1d1d1d 0%, #1a1a1a 100%);
--text: #e5e5e5;
--text-secondary: #999999;
--text-tertiary: #666666;
--text-placeholder: #555555;
--border: #333333;
--border-light: #2a2a2a;
--primary: #409eff;
--primary-hover: #66b1ff;
--primary-light: rgba(64, 158, 255, 0.15);
--primary-soft: rgba(64, 158, 255, 0.1);
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] body { background: var(--bg); color: var(--text); }
[data-theme="dark"] .el-card,
[data-theme="dark"] .el-dialog,
[data-theme="dark"] .el-menu,
[data-theme="dark"] .el-table,
[data-theme="dark"] .el-select-dropdown,
[data-theme="dark"] .el-popover {
--el-bg-color: var(--bg-card) !important;
--el-border-color: var(--border) !important;
--el-text-color-primary: var(--text) !important;
}
[data-theme="dark"] .el-dialog__body,
[data-theme="dark"] .el-table__body td {
background-color: var(--bg-card) !important;
}
[data-theme="dark"] .el-table__header th {
background-color: #262626 !important;
}
[data-theme="dark"] .el-input__wrapper,
[data-theme="dark"] .el-select .el-input__wrapper {
background-color: var(--bg-input) !important;
border-color: var(--border) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
}
[data-theme="dark"] .el-input__inner,
[data-theme="dark"] .el-textarea__inner {
background-color: var(--bg-input) !important;
color: var(--text) !important;
}
[data-theme="dark"] .el-button--default {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
.theme-toggle {
position: fixed;
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-md);
transition: all 0.3s;
}
.theme-toggle:hover { transform: scale(1.1); border-color: var(--primary); }
#app { min-height: 100vh; background: var(--bg-page); color: var(--text); }
/* ── Global element-plus overrides ── */
@@ -157,35 +100,19 @@ onMounted(() => {
box-shadow: var(--shadow-sm) !important;
transition: box-shadow 0.2s ease;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
}
.el-card:hover { box-shadow: var(--shadow-md) !important; }
.el-card__header {
padding: 16px 20px !important;
border-bottom: 1px solid var(--border-light) !important;
font-weight: 600;
font-size: 14px;
font-weight: 600; font-size: 14px;
background: var(--bg-card-header);
}
.el-card__body {
padding: 20px !important;
}
.el-card__body { padding: 20px !important; }
/* ── Typography helpers ── */
.form-tip {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.section-divider {
border-top: 1px solid var(--border-light);
margin: 20px 0;
}
.form-tip { font-size: 12px; color: var(--text-tertiary); line-height: 1.6; }
.page-title { font-size: 20px; font-weight: 700; color: var(--text); }
.section-divider { border-top: 1px solid var(--border-light); margin: 20px 0; }
/* ── Fade transition ── */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
+193
View File
@@ -0,0 +1,193 @@
<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>
/* =============================================
CloudSearch Design System — Global Tokens
============================================= */
:root {
/* ── Backgrounds ── */
--bg: #f0f2f5;
--bg-card: #ffffff;
--bg-input: #f5f7fa;
--bg-page: #f0f2f5;
--bg-card-header: linear-gradient(135deg, #f8f9fc 0%, #eef1f8 100%);
/* ── Text ── */
--text: #1d2129;
--text-secondary: #4e5969;
--text-tertiary: #86909c;
--text-placeholder: #c9cdd4;
/* ── Borders ── */
--border: #e5e6eb;
--border-light: #f2f3f5;
/* ── Primary ── */
--primary: #409eff;
--primary-hover: #66b1ff;
--primary-light: rgba(64, 158, 255, 0.08);
--primary-soft: #ecf5ff;
/* ── Shadows ── */
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.08);
/* ── Radius ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* ── Sizing ── */
--sidebar-w: 240px;
}
[data-theme="dark"] {
--bg: #141414;
--bg-card: #1d1d1d;
--bg-input: #2a2a2a;
--bg-page: #0f0f0f;
--bg-card-header: linear-gradient(135deg, #1d1d1d 0%, #1a1a1a 100%);
--text: #e5e5e5;
--text-secondary: #999999;
--text-tertiary: #666666;
--text-placeholder: #555555;
--border: #333333;
--border-light: #2a2a2a;
--primary: #409eff;
--primary-hover: #66b1ff;
--primary-light: rgba(64, 158, 255, 0.15);
--primary-soft: rgba(64, 158, 255, 0.1);
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] body { background: var(--bg); color: var(--text); }
[data-theme="dark"] .el-card,
[data-theme="dark"] .el-dialog,
[data-theme="dark"] .el-menu,
[data-theme="dark"] .el-table,
[data-theme="dark"] .el-select-dropdown,
[data-theme="dark"] .el-popover {
--el-bg-color: var(--bg-card) !important;
--el-border-color: var(--border) !important;
--el-text-color-primary: var(--text) !important;
}
[data-theme="dark"] .el-dialog__body,
[data-theme="dark"] .el-table__body td {
background-color: var(--bg-card) !important;
}
[data-theme="dark"] .el-table__header th {
background-color: #262626 !important;
}
[data-theme="dark"] .el-input__wrapper,
[data-theme="dark"] .el-select .el-input__wrapper {
background-color: var(--bg-input) !important;
border-color: var(--border) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
}
[data-theme="dark"] .el-input__inner,
[data-theme="dark"] .el-textarea__inner {
background-color: var(--bg-input) !important;
color: var(--text) !important;
}
[data-theme="dark"] .el-button--default {
background: var(--bg-card);
border-color: var(--border);
color: var(--text);
}
.theme-toggle {
position: fixed;
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-md);
transition: all 0.3s;
}
.theme-toggle:hover { transform: scale(1.1); border-color: var(--primary); }
#app { min-height: 100vh; background: var(--bg-page); color: var(--text); }
/* ── Global element-plus overrides ── */
.el-card {
border-radius: var(--radius-lg) !important;
border: 1px solid var(--border) !important;
box-shadow: var(--shadow-sm) !important;
transition: box-shadow 0.2s ease;
}
.el-card:hover {
box-shadow: var(--shadow-md) !important;
}
.el-card__header {
padding: 16px 20px !important;
border-bottom: 1px solid var(--border-light) !important;
font-weight: 600;
font-size: 14px;
background: var(--bg-card-header);
}
.el-card__body {
padding: 20px !important;
}
/* ── Typography helpers ── */
.form-tip {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.section-divider {
border-top: 1px solid var(--border-light);
margin: 20px 0;
}
/* ── Fade transition ── */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -383,6 +383,38 @@ export async function changePassword(
return data
}
// ===== 推广平台管理 =====
export interface PromotionPlatform {
id: number
name: string
join_url: string
sort_order: number
created_at?: string
}
export async function getPromotionPlatforms(): Promise<PromotionPlatform[]> {
const { data } = await axios.get('/api/promotion-platforms')
return data
}
export async function getAdminPromotionPlatforms(): Promise<PromotionPlatform[]> {
const { data } = await api.get('/admin/promotion-platforms')
return data
}
export async function createPromotionPlatform(params: { name: string; join_url: string; sort_order?: number }): Promise<PromotionPlatform> {
const { data } = await api.post('/admin/promotion-platforms', params)
return data
}
export async function updatePromotionPlatform(id: number, params: { name: string; join_url: string; sort_order?: number }): Promise<void> {
await api.put(`/admin/promotion-platforms/${id}`, params)
}
export async function deletePromotionPlatform(id: number): Promise<void> {
await api.delete(`/admin/promotion-platforms/${id}`)
}
export default api
export { query as searchQuery }
@@ -0,0 +1,42 @@
<template>
<main class="disclaimer-page">
<section class="disclaimer-card">
<h1>免责声明</h1>
<p>CloudSearch 仅聚合和展示公开网络资源线索不存储不上传不分发任何网盘文件内容</p>
<p>搜索结果来自第三方公开页面或接口资源有效性合法性与安全性需由用户自行判断</p>
<p>请勿使用本站搜索保存或传播侵权违法违规内容如权利人认为相关信息侵犯权益请联系站点管理员处理</p>
<router-link class="back-link" to="/">返回首页</router-link>
</section>
</main>
</template>
<style scoped>
.disclaimer-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
background: #f8fafc;
}
.disclaimer-card {
max-width: 760px;
padding: 32px;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
color: #334155;
line-height: 1.8;
}
.disclaimer-card h1 {
margin: 0 0 20px;
color: #0f172a;
}
.back-link {
display: inline-flex;
margin-top: 20px;
color: #2563eb;
text-decoration: none;
font-weight: 600;
}
</style>
+81 -314
View File
@@ -1,359 +1,126 @@
<template>
<div class="home-page">
<div class="hero-section">
<h1 class="sr-only">网盘资源搜索</h1>
<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 v-model="query" aria-label="搜索网盘资源" name="q" 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>
<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 class="quote-line" v-if="currentQuote"> {{ currentQuote }} ---{{ quoteAuthor }}</div>
</div>
<div class="cloud-zone" v-if="words.length > 0">
<p class="cloud-title">大家都在搜</p>
<div class="word-cloud">
<span v-for="w in words" :key="w.k" class="wc-word" :style="{fontSize:w.s+'px',color:w.c,fontWeight:w.w,transform:'rotate('+w.r+'deg)',opacity:w.o,animationDelay:w.d+'s'}" @click="searchTag(w.k)">{{ w.k }}</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-else-if="configLoaded" class="empty-area"><p>搜索脉搏采集中...</p></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>
<el-button class="footer-btn" size="small" @click="openDisclaimer">免责声明</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { getCategorizedRankings, getSiteConfig } from '../api'
import { createDisclaimerTarget, createSearchPath } from '../utils/home-helpers'
const router = useRouter()
const query = ref('')
const categories = ref<any[]>([])
const expanded = reactive<Record<string, boolean>>({})
const activeTab = reactive<Record<string, string>>({})
const configLoaded = ref(false)
const siteLogo = ref('')
const siteName = ref('')
const siteDisclaimer = ref('')
const configLoaded = ref(false)
const currentQuote = ref('')
const quoteAuthor = ref('')
const QUOTES = ['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。']
const words = ref<any[]>([])
const fetchedAt = ref('')
const QS = ['学而时习之,不亦说乎。', '温故而知新,可以为师矣。', '千里之行,始于足下。', '书山有路,勤为径。', '学以致用,知行合一。', '路漫漫其修远兮,吾将上下而求索。']
const CS = ['#e74c3c', '#e67e22', '#f39c12', '#27ae60', '#2980b9', '#8e44ad', '#2c3e50', '#16a085', '#d35400', '#c0392b', '#1abc9c', '#3498db', '#9b59b6', '#34495e', '#e91e63', '#009688', '#ff5722', '#795548', '#607d8b', '#673ab7']
const SS = [48,42,38,34,30,28,26,24,22,20,18,17,16,15,14,13,12]
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')
}
function handleSearch() { const path = createSearchPath(query.value); if(path) router.push(path) }
function searchTag(t:string) { const path = createSearchPath(t); if(path) router.push(path) }
function openDisclaimer() { window.location.assign(createDisclaimerTarget()) }
onMounted(async () => {
// 一言(内置格言)
const q = QUOTES[Math.floor(Math.random() * QUOTES.length)]
currentQuote.value = q
currentQuote.value = QS[Math.floor(Math.random()*QS.length)]
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 : []
const [catsData, siteCfg] = await Promise.all([getCategorizedRankings(), getSiteConfig()])
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
if(catsData?.categories){
const hotCat=catsData.categories.find((c:any)=>c.category==='hot')
if(hotCat?.hot?.length){
const arr=hotCat.hot.slice(0,25).map((item:any,idx:number)=>({
k:item.keyword, s:SS[Math.min(idx,SS.length-1)],
c:CS[idx%CS.length], w:idx<5?800:idx<10?600:400,
r:(Math.random()-.5)*6, o:1-idx*.02, d:idx*.08
}))
for(let i=arr.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[arr[i],arr[j]]=[arr[j],arr[i]]}
words.value=arr
}
}
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)
}
}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; }
.home-page{min-height:100vh;background:#f5f7fa}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.hero-section{text-align:center;padding:56px 24px 36px;background:#f5f7fa}
.logo-text{font-size:48px;font-weight:700;color:#1d2129;margin-bottom:24px}
.logo-img{display:block;max-width:400px;max-height:100px;margin:0 auto 24px}
.search-box{display:inline-flex;align-items:center;width:100%;max-width:600px;border-radius:28px;background:#fff;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08)}
.search-box :deep(.el-input__wrapper){border:none;box-shadow:none;background:transparent;padding:4px 20px}
.search-box :deep(.el-input__inner){font-size:15px}
.search-btn{flex-shrink:0;border:none;border-radius:24px;padding:0 28px;height:40px;font-size:14px;font-weight:600;background:#409eff;color:#fff;margin:4px;cursor:pointer;transition:all .2s}
.search-btn:hover{background:#337ecc;transform:scale(1.02)}
.quote-line{margin-top:14px;font-size:13px;color:#86909c;font-style:italic}
.cloud-zone{max-width:1100px;margin:0 auto;padding:32px 24px 60px}
.cloud-title{text-align:center;font-size:14px;color:#86909c;letter-spacing:4px;margin-bottom:40px}
.word-cloud{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:12px 24px;padding:20px;line-height:1.4}
.wc-word{display:inline-block;cursor:pointer;transition:all .25s cubic-bezier(.4,0,.2,1);animation:pop-in .8s ease-out both;padding:2px 6px;border-radius:6px;user-select:none}
.wc-word:hover{transform:scale(1.3) rotate(0deg)!important;opacity:1!important}
@keyframes pop-in{from{opacity:0;transform:translateY(20px) scale(.8)}to{opacity:1;transform:translateY(0) scale(1)}}
.empty-area{text-align:center;padding:80px 20px;color:#c0c4cc}
.site-footer{text-align:center;padding:20px 16px 32px;background:#f5f7fa;border-top:1px solid #e5e6eb}
.footer-inner{max-width:800px;margin:0 auto 12px;font-size:12px;line-height:1.8;color:#86909c;white-space:pre-line}
.footer-btn{font-size:12px!important;color:#86909c!important}
.hero-section {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 60px 24px 40px;
/* Responsive */
@media(max-width:768px){
.hero-section{padding:36px 16px 24px}
.logo-text{font-size:28px}
.search-box{border-radius:24px}
.search-btn{padding:0 18px;height:36px;font-size:13px}
.quote-line{font-size:12px}
.cloud-zone{padding:24px 12px 40px}
.cloud-title{margin-bottom:24px;font-size:13px;letter-spacing:2px}
.word-cloud{gap:8px 16px;padding:12px}
.wc-word{padding:1px 4px}
}
.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;
@media(max-width:480px){
.hero-section{padding:28px 12px 20px}
.logo-text{font-size:22px;margin-bottom:16px}
.search-box{max-width:100%}
.search-box :deep(.el-input__inner){font-size:13px}
}
.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>
</style>
@@ -0,0 +1,27 @@
<template>
<main class="not-found-page">
<section class="not-found-card">
<p class="eyebrow">404</p>
<h1>页面不存在</h1>
<p class="description">你访问的页面不存在或已被移动</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</section>
</main>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goHome() {
router.push('/')
}
</script>
<style scoped>
.not-found-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f5f7fa;padding:24px}
.not-found-card{width:min(460px,100%);padding:40px 32px;text-align:center;background:#fff;border-radius:18px;box-shadow:0 12px 32px rgba(31,45,61,.08)}
.eyebrow{margin:0 0 8px;color:#409eff;font-size:14px;font-weight:700;letter-spacing:4px}
h1{margin:0 0 12px;font-size:28px;color:#1d2129}
.description{margin:0 0 24px;color:#606266;line-height:1.7}
</style>
@@ -28,14 +28,6 @@
<el-button type="primary" size="large" @click="handleSearch" class="result-search-btn"> </el-button>
</div>
</div>
<!-- 右上角登录/用户信息独立于搜索栏 -->
<div class="top-right-user">
<template v-if="userInfo">
<span class="user-badge">{{ userInfo.username }}</span>
<el-button size="small" text @click="handleLogout">退出</el-button>
</template>
<el-button v-else size="small" @click="showLogin = true">登录</el-button>
</div>
</div>
<!-- 滚动通知条跑马灯 -->
@@ -151,7 +143,7 @@
:data="item"
:fallbackTags="contentTags"
:fallbackImage="fallbackImage"
:loggedIn="userInfo !== null"
:loggedIn="false"
:cloudTypeMap="cloudTypeMap"
@save="handleSave"
/>
@@ -183,7 +175,7 @@
:data="item"
:fallbackTags="contentTags"
:fallbackImage="fallbackImage"
:loggedIn="userInfo !== null"
:loggedIn="false"
:cloudTypeMap="cloudTypeMap"
@save="handleSave"
/>
@@ -346,21 +338,6 @@
</el-dialog>
</div>
<!-- 登录弹窗 -->
<el-dialog v-model="showLogin" title="登录" width="380px" :close-on-click-modal="false" top="25vh">
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" label-width="0" @keyup.enter="handleLogin">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loginLoading" style="width: 100%" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
</el-dialog>
<div v-if="siteDisclaimer" class="site-footer">
<div class="footer-inner">{{ siteDisclaimer }}</div>
<div class="footer-actions">
@@ -376,7 +353,7 @@ import { Search, Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import ResultCard from '../components/ResultCard.vue'
import VideoResultCard from '../components/VideoResultCard.vue'
import { query as searchQuery, saveToCloud, saveVideoToCloud, getSystemConfigs, getCloudTypes, streamSearch, adminLogin, getMe } from '../api'
import { query as searchQuery, saveToCloud, saveVideoToCloud, getSystemConfigs, getCloudTypes, streamSearch, getSiteConfig } from '../api'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import type { SearchResult, VideoParseResult, SaveResult, IntentType, CloudType, ChannelGroup } from '../types'
@@ -417,17 +394,6 @@ const siteDisclaimer = ref('')
const siteMarquee = ref('')
const coverError = ref(false)
// 登录状态
const userInfo = ref<{ username: string } | null>(null)
const showLogin = ref(false)
const loginFormRef = ref<FormInstance>()
const loginForm = reactive({ username: '', password: '' })
const loginLoading = ref(false)
const loginError = ref('')
const loginRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const allResultsMap = ref(new Map<string, any>())
const validResults = ref<any[]>([])
@@ -456,43 +422,22 @@ async function loadCloudTypes() {
}
onMounted(async () => {
// 预加载网站配置(logo/名称/公告等)
try {
const sc = await getSiteConfig()
if (sc.site_logo) siteLogo.value = sc.site_logo
if (sc.site_name) siteName.value = sc.site_name
if (sc.site_disclaimer) siteDisclaimer.value = sc.site_disclaimer
if (sc.site_marquee) siteMarquee.value = sc.site_marquee
} catch {}
const q = (route.query.q as string) || ''
if (q) {
query.value = q
doSearch(q)
}
// 检查登录状态
const me: any = await getMe().catch(() => ({ loggedIn: false }))
if (me.loggedIn && me.username) {
userInfo.value = { username: me.username }
}
loadCloudTypes()
})
async function handleLogin() {
const valid = await loginFormRef.value?.validate().catch(() => false)
if (!valid) return
loginLoading.value = true
loginError.value = ''
try {
const res = await adminLogin(loginForm.username, loginForm.password)
localStorage.setItem('admin_token', res.token)
userInfo.value = { username: loginForm.username }
showLogin.value = false
loginForm.password = ''
ElMessage.success('登录成功')
} catch (e: any) {
loginError.value = e?.response?.data?.error || e?.message || '登录失败'
} finally {
loginLoading.value = false
}
}
function handleLogout() {
localStorage.removeItem('admin_token')
userInfo.value = null
ElMessage.success('已退出')
}
// 网盘分类标签 — 始终展示所有已知云盘类型(0 也显示)
const cloudTabs = computed(() => {
@@ -237,6 +237,7 @@ watch(() => stats.value.trendTrend, () => {
}, { deep: true })
// Reload chart when switching back to dashboard
const activeMenu = ref("dashboard")
watch(() => activeMenu.value, (val, oldVal) => {
if (val === 'dashboard' && oldVal !== 'dashboard') {
nextTick(() => {
@@ -306,7 +307,6 @@ async function switchTrendDays(d: number) {
}
// Menu selection
const activeMenu = ref('dashboard')
const activeSystemSection = ref('')
// ── Recent saves ──
@@ -380,6 +380,7 @@ const sysSectionTitles: Record<string, string> = {
'sys-validation': '链接验证配置',
'sys-filter': '搜索标题过滤规则',
'sys-password': '修改管理员密码',
'sys-platforms': '推广平台管理',
}
const pageTitle = computed(() => {
@@ -2,7 +2,10 @@
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="sidebar-brand">
<div class="sidebar-logo"></div>
<div class="sidebar-logo">
<img v-if="siteLogo" :src="siteLogo" :alt="siteName || 'CloudSearch'" class="sidebar-logo-img" @error="(e: any) => { (e.target as HTMLElement).style.display='none'; siteLogo='' }" />
<span v-else class="sidebar-logo-fallback"></span>
</div>
<div class="sidebar-brand-text">
<h2>{{ siteName || 'CloudSearch' }}</h2>
<p>管理控制台</p>
@@ -39,6 +42,7 @@
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
<el-menu-item index="sys-notify">📬 消息推送</el-menu-item>
<el-menu-item index="sys-daily-report">📊 每日汇报</el-menu-item>
<el-menu-item index="sys-platforms">👥 推广平台管理</el-menu-item>
</el-sub-menu>
<el-menu-item index="save-records">
@@ -86,6 +90,7 @@ const router = useRouter()
const route = useRoute()
const siteName = ref('')
const siteLogo = ref('')
const appVersion = ref('')
const pageTitles: Record<string, string> = {
@@ -98,6 +103,7 @@ const pageTitles: Record<string, string> = {
'sys-password': '修改管理员密码',
'sys-notify': '消息推送',
'sys-daily-report': '每日汇报',
'sys-platforms': '推广平台管理',
'save-records': '转存日志',
}
@@ -142,6 +148,7 @@ onMounted(async () => {
try {
const cfg = await getSiteConfig()
siteName.value = cfg.site_name || ''
siteLogo.value = cfg.site_logo || ''
} catch {}
try {
const h = await fetch('/health')
@@ -180,7 +187,14 @@ onMounted(async () => {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-logo-img { width: 36px; height: 36px; object-fit: contain; border-radius: 6px; }
.sidebar-logo-fallback { font-size: 28px; }
.sidebar-brand-text h2 {
font-size: 16px;
font-weight: 700;
@@ -21,13 +21,13 @@
</el-form-item>
<el-form-item label="白名单目录">
<div style="width: 100%;">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;" v-if="whitelistDirs.length">
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
</div>
<div style="display: flex; gap: 6px;">
<el-input v-model="newWhitelistDir" placeholder="输入目录名" size="small" style="width: 160px" @keyup.enter="addWhitelistDir" />
<el-button type="primary" size="small" @click="addWhitelistDir">添加</el-button>
</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;" v-if="whitelistDirs.length">
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
</div>
</div>
</el-form-item>
</el-form>
@@ -95,7 +95,7 @@
<el-table-column label="转存启用" width="80" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_transfer_enabled !== 0"
:model-value="row.is_active !== 0"
size="small"
@change="(val: boolean) => handleToggleTransfer(row, val)"
/>
@@ -105,7 +105,7 @@
<template #default="{ row }">
<el-switch
:model-value="row.is_primary === 1"
:disabled="!row.is_transfer_enabled"
:disabled="!row.is_active"
size="small"
@change="(val: boolean) => handleTogglePrimary(row, val)"
/>
@@ -386,11 +386,10 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
cloud_type: row.cloud_type,
nickname: row.nickname || '',
promotion_account: row.promotion_account || '',
is_transfer_enabled: newVal,
is_active: row.is_active,
is_active: newVal,
cookie: undefined, // don't send cookie on toggle-only
})
row.is_transfer_enabled = newVal
row.is_active = newVal
ElMessage.success(enabled ? '转存已开启' : '转存已关闭')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
@@ -455,7 +454,7 @@ function openDialog(row: CloudConfig | 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.is_transfer_enabled = row.is_active !== 0
form.cookie = row.cookie || ''
form._verifying = false
form._storageUsed = ''
@@ -506,9 +505,8 @@ async function handleSave() {
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
is_active: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie || undefined,
is_active: 1,
})
ElMessage.success('配置更新成功')
} else {
@@ -516,9 +514,8 @@ async function handleSave() {
cloud_type: form.cloud_type as CloudType,
nickname: form.nickname,
promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
is_active: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie,
is_active: 1,
})
ElMessage.success('配置保存成功')
}
@@ -754,6 +754,57 @@
</template>
</el-dialog>
<!-- 👥 推广平台管理 -->
<el-card id="section-sys-platforms" v-show="!activeSection || activeSection === 'sys-platforms'">
<template #header>
<span>👥 推广平台管理</span>
</template>
<div class="form-tip" style="margin-bottom: 12px;">配置注册页面可选的推广平台每个平台需提供邀请链接将自动生成二维码供用户扫码加入</div>
<div style="margin-bottom: 12px;">
<label style="font-size: 13px; color: #606266; display: block; margin-bottom: 4px;">二维码标题</label>
<el-input v-model="configs.promotion_qr_title" placeholder="扫码加入推广团队" style="max-width: 360px" />
<div class="form-tip">注册页面二维码区域的标题文字保存后生效</div>
</div>
<el-table :data="promoPlatforms" stripe size="small" empty-text="暂无推广平台">
<el-table-column prop="name" label="平台名称" width="160" />
<el-table-column prop="join_url" label="邀请链接" min-width="300" show-overflow-tooltip />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="editPromoPlatform(row)">编辑</el-button>
<el-popconfirm title="确定删除该平台?" @confirm="deletePromoPlatform(row.id)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 12px;">
<el-button size="small" type="primary" @click="showPromoDialog = true; editingPromoId = null; promoForm.name = ''; promoForm.join_url = ''; promoForm.sort_order = 0">新增平台</el-button>
</div>
</el-card>
<!-- 推广平台弹窗 -->
<el-dialog v-model="showPromoDialog" :title="editingPromoId ? '编辑推广平台' : '新增推广平台'" width="480px">
<el-form :model="promoForm" label-width="100px">
<el-form-item label="平台名称">
<el-input v-model="promoForm.name" placeholder="如:蜂小推" />
</el-form-item>
<el-form-item label="邀请链接">
<el-input v-model="promoForm.join_url" placeholder="https://..." />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="promoForm.sort_order" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPromoDialog = false">取消</el-button>
<el-button type="primary" :loading="promoSaving" @click="savePromoPlatform">保存</el-button>
</template>
</el-dialog>
<!-- 保存按钮 -->
<div class="save-bar">
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
@@ -768,7 +819,7 @@ import { ref, reactive, onMounted, computed, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { ElMessage } from 'element-plus'
import type { ElForm } from 'element-plus'
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService, testNotifyChannel, getAllNotifierProviders, getCloudConfigs } from "../../api"
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService, testNotifyChannel, getAllNotifierProviders, getCloudConfigs, getAdminPromotionPlatforms, createPromotionPlatform, updatePromotionPlatform, deletePromotionPlatform } from "../../api"
import { Upload, Loading } from "@element-plus/icons-vue"
@@ -814,6 +865,12 @@ const ipGeoTesting = ref(false)
const pansouInfo = ref<any>(null)
const pansouInfoLoading = ref(true)
const pansouUpdating = ref(false)
const promoPlatforms = ref<any[]>([])
const showPromoDialog = ref(false)
const editingPromoId = ref<number | null>(null)
const promoForm = reactive({ name: '', join_url: '', sort_order: 0 })
const promoSaving = ref(false)
const proxyEnabled = computed({
get: () => String(configs.search_proxy_enabled) === 'true',
set: (val: boolean) => { configs.search_proxy_enabled = val ? 'true' : 'false' },
@@ -828,6 +885,7 @@ const searchAllChannels = computed({
set: (val: boolean) => { configs.search_all_channels = val ? 'true' : 'false' },
})
const autoUpdateEnabled = computed({
get: () => String(configs.auto_update_enabled) === 'true',
set: (val: boolean) => { configs.auto_update_enabled = val ? 'true' : 'false' },
})
@@ -1262,6 +1320,7 @@ const passwordRules = {
}
onMounted(async () => {
loadPromoPlatforms();
try {
rawConfigs.value = await getSystemConfigs()
for (const cfg of rawConfigs.value) {
@@ -1617,6 +1676,11 @@ async function handleSave() {
key: cfg.key,
value: String(configs[cfg.key] ?? cfg.value),
}))
// Always save promotion QR title
if (configs.promotion_qr_title !== undefined) {
const hasQrTitle = entries.some((e: any) => e.key === 'promotion_qr_title')
if (!hasQrTitle) entries.push({ key: 'promotion_qr_title', value: String(configs.promotion_qr_title || '') })
}
await saveDailyReportConfig()
// Add global_notify_config as JSON entry
entries.push({
@@ -1631,6 +1695,27 @@ async function handleSave() {
saving.value = false
}
}
async function loadPromoPlatforms() {
try { promoPlatforms.value = await getAdminPromotionPlatforms() } catch { /* */ }
}
function editPromoPlatform(row: any) {
editingPromoId.value = row.id; promoForm.name = row.name; promoForm.join_url = row.join_url; promoForm.sort_order = row.sort_order; showPromoDialog.value = true;
}
async function savePromoPlatform() {
if (!promoForm.name || !promoForm.join_url) { ElMessage.warning("平台名称和邀请链接不能为空"); return; }
promoSaving.value = true;
try {
if (editingPromoId.value) { await updatePromotionPlatform(editingPromoId.value, { ...promoForm }); ElMessage.success("更新成功"); }
else { await createPromotionPlatform({ ...promoForm }); ElMessage.success("添加成功"); }
showPromoDialog.value = false; editingPromoId.value = null; loadPromoPlatforms();
} catch (e: any) { ElMessage.error(e.response?.data?.error || "保存失败"); }
finally { promoSaving.value = false; }
}
async function deletePromoPlatform(id: number) {
try { await deletePromotionPlatform(id); ElMessage.success("已删除"); loadPromoPlatforms(); }
catch (e: any) { ElMessage.error(e.response?.data?.error || "删除失败"); }
}
async function handleChangePassword() {
const valid = await passwordFormRef.value?.validate().catch(() => false)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,606 @@
<template>
<div class="user-layout">
<!-- Header -->
<div class="user-header">
<span class="user-title">{{ siteName }} 用户中心</span>
<div class="header-right">
<span class="user-account">{{ account }}</span>
<el-button size="small" @click="handleLogout">退出登录</el-button>
</div>
</div>
<div class="user-body">
<!-- Sidebar -->
<div class="user-sidebar">
<div
v-for="item in menuItems"
:key="item.key"
:class="['sidebar-item', { active: activeMenu === item.key }]"
@click="activeMenu = item.key"
>
<span>{{ item.icon }} {{ item.label }}</span>
</div>
</div>
<!-- Content -->
<div class="user-content">
<!-- 📋 转存日志 -->
<div v-show="activeMenu === 'records'">
<el-card shadow="never">
<template #header><span>📋 转存日志</span></template>
<el-table :data="records" stripe empty-text="暂无转存记录" size="small">
<el-table-column prop="source_title" label="资源名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="source_type" label="来源" width="80" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="90" />
<el-table-column label="耗时" width="90">
<template #default="{ row }">
{{ row.duration_ms ? (row.duration_ms / 1000).toFixed(1) + 's' : '-' }}
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ row.created_at }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="recordsTotal > 0"
v-model:current-page="recordsPage"
:page-size="recordsPageSize"
:total="recordsTotal"
layout="prev, pager, next"
small
style="margin-top: 16px; justify-content: center;"
@current-change="loadRecords"
/>
</el-card>
</div>
<!-- 💾 网盘管理 复刻 admin CloudConfig 布局 -->
<div v-show="activeMenu === 'drives'">
<el-card shadow="never">
<template #header><span>💾 我的网盘</span></template>
<div style="margin-bottom: 12px;">
<el-button type="primary" size="small" @click="openAddDrive">新增配置</el-button>
</div>
<el-table :data="drives" stripe empty-text="暂无网盘配置" size="small">
<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">{{ row.nickname }}</span>
<span v-else style="color:#909399;font-size:12px;">未设置</span>
</template>
</el-table-column>
<el-table-column prop="promotion_account" label="推广账号" width="160">
<template #default="{ row }">
<span v-if="row.promotion_account">{{ row.promotion_account }}</span>
<span v-else style="color:#909399;font-size:12px;">-</span>
</template>
</el-table-column>
<el-table-column label="验证" width="80" align="center">
<template #default="{ row }">
<el-tag v-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 }">
<template v-if="row.storage_total && row.storage_total !== '-'">
<div>{{ row.storage_used || '计算中...' }} / {{ row.storage_total }}</div>
</template>
<span v-else style="color:#909399"></span>
</template>
</el-table-column>
<el-table-column label="转存数" width="80" align="center">
<template #default="{ row }">{{ row.total_saves > 0 ? row.total_saves + '次' : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="openEditDrive(row)">编辑</el-button>
<el-popconfirm title="确定删除该配置?" @confirm="handleDeleteDrive(row.id)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 🔔 消息推送 复刻 admin SystemConfig notify 布局 -->
<div v-show="activeMenu === 'notify'">
<el-card shadow="never">
<template #header><span>🔔 消息推送配置</span></template>
<el-form label-width="120px" size="small">
<el-form-item label="推送通道">
<el-select v-model="notifyChannel" style="width: 200px" placeholder="选择推送通道">
<el-option label="Feishu Bot" value="feishu" />
</el-select>
</el-form-item>
<template v-if="notifyChannel === 'feishu'">
<el-form-item label="Webhook 地址">
<el-input v-model="notifyWebhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="width: 420px" />
</el-form-item>
<el-form-item label="推送事件">
<el-checkbox-group v-model="notifyEvents">
<el-checkbox label="on_save_success">转存成功</el-checkbox>
<el-checkbox label="on_save_fail">转存失败</el-checkbox>
<el-checkbox label="on_cookie_expire">Cookie过期</el-checkbox>
</el-checkbox-group>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" :loading="savingNotify" @click="saveNotify">保存推送配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑网盘弹窗 复刻 admin CloudConfig dialog -->
<el-dialog v-model="showAddDrive" :title="editingDrive ? '编辑网盘配置' : '新增网盘配置'" width="560px">
<el-form ref="driveFormRef" :model="driveForm" :rules="driveRules" label-width="120px">
<!-- 白名单目录提示 -->
<el-alert
v-show="whitelistDirs.length > 0"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 18px;"
>
<template #title>
<div style="line-height: 1.6;">
<div>🧹 请在网盘主目录内创建<b>{{ whitelistDirs.join('、') }}</b> 目录</div>
<div>并将你的重要文件移至该目录<b>只有这个目录不会被自动清理</b></div>
</div>
</template>
</el-alert>
<el-form-item label="网盘类型" prop="cloud_type">
<el-select v-model="driveForm.cloud_type" style="width: 100%" :disabled="!!editingDrive" @change="onDriveTypeChange">
<el-option
v-for="ct in enabledCloudTypes"
:key="ct.type"
:label="ct.label"
:value="ct.type"
/>
</el-select>
</el-form-item>
<el-form-item label="Cookie" prop="cookie">
<el-input
v-model="driveForm.cookie"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="driveCookiePlaceholder"
input-style="font-family: monospace; font-size: 12px;"
/>
</el-form-item>
<!-- Cookie 获取教程根据网盘类型切换 -->
<el-form-item label=" " v-if="driveForm.cloud_type && driveForm.cloud_type !== ''" class="cookie-tips-item">
<div class="cookie-tips" :class="`cookie-tips-${driveForm.cloud_type}`">
<div class="cookie-tips-header">
<span class="cookie-tips-title">📖 {{ driveCloudTypeLabel }} Cookie 获取教程</span>
</div>
<ol class="cookie-tips-steps" v-html="driveCookieTutorialHtml"></ol>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDrive = false; editingDrive = null">取消</el-button>
<el-button type="primary" :loading="adding" @click="handleSaveDrive">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { CLOUD_LABELS } from '../../types'
import { getSystemConfigs } from '../../api'
import type { CloudType } from '../../types'
import CloudBadge from '../../components/CloudBadge.vue'
import type { ElForm } from 'element-plus'
const router = useRouter()
const account = ref('')
const siteName = ref('CloudSearch')
/** 白名单目录提示 */
const systemConfigs = ref<Record<string, string>>({})
const whitelistDirs = computed(() => {
try {
const raw = systemConfigs.value.cleanup_whitelist_dirs || '[]'
const arr = JSON.parse(raw)
return Array.isArray(arr) && arr.length > 0 ? arr : []
} catch { return [] }
})
async function loadSystemConfigs() {
try {
const { data } = await axios.get('/api/system-configs')
const map: Record<string, string> = {}
for (const item of data) {
map[item.key] = item.value
}
systemConfigs.value = map
} catch (e) { console.error('加载系统配置失败', e) }
}
function userApi() {
const token = localStorage.getItem('user_token')
return axios.create({
baseURL: '/api/user',
headers: { Authorization: `Bearer ${token}` },
})
}
onMounted(async () => {
const token = localStorage.getItem('user_token')
if (!token) { router.push('/user/login'); return }
try {
const { data } = await userApi().get('/profile')
account.value = data.account
} catch {
localStorage.removeItem('user_token')
localStorage.removeItem('user_account')
router.push('/user/login')
return
}
//
try {
const { data } = await axios.get('/api/site-config')
if (data.site_name) siteName.value = data.site_name
} catch { /* use default */ }
loadSystemConfigs()
})
function handleLogout() {
localStorage.removeItem('user_token')
localStorage.removeItem('user_account')
router.push('/user/login')
}
// Menu
const activeMenu = ref('records')
const menuItems = [
{ key: 'records', icon: '📋', label: '转存日志' },
{ key: 'drives', icon: '💾', label: '网盘管理' },
{ key: 'notify', icon: '🔔', label: '消息推送' },
]
//
const records = ref<any[]>([])
const recordsTotal = ref(0)
const recordsPage = ref(1)
const recordsPageSize = ref(20)
async function loadRecords() {
try {
const { data } = await userApi().get('/save-records', { params: { page: recordsPage.value, pageSize: recordsPageSize.value } })
records.value = data.records
recordsTotal.value = data.total
} catch { /* */ }
}
//
const drives = ref<any[]>([])
const showAddDrive = ref(false)
const editingDrive = ref<any>(null)
const adding = ref(false)
const driveFormRef = ref<InstanceType<typeof ElForm>>()
interface EnabledCloudType { type: string; label: string; icon: string; enabled: boolean }
const enabledCloudTypes = ref<EnabledCloudType[]>([])
const defaultDriveForm = () => ({
cloud_type: '' as CloudType | '',
cookie: '',
})
const driveForm = reactive(defaultDriveForm())
const driveRules = computed(() => ({
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
}))
const driveCookiePlaceholder = computed(() => {
if (!driveForm.cloud_type) return '请先选择网盘类型'
const t = driveForm.cloud_type
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
return editingDrive.value ? '留空则保持原有' : '输入完整 Cookie'
})
const driveCloudTypeLabel = computed(() => {
return CLOUD_LABELS[driveForm.cloud_type as CloudType] || driveForm.cloud_type || ''
})
/** Cookie 获取教程 HTML(根据不同网盘类型) */
const driveCookieTutorialHtml = computed(() => {
const t = driveForm.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>按钮提交配置</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>按钮提交配置</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>`
})
async function loadEnabledCloudTypes() {
try {
const { data } = await userApi().get('/enabled-cloud-types')
enabledCloudTypes.value = data
} catch { enabledCloudTypes.value = [] }
}
async function loadDrives() {
try {
const { data } = await userApi().get('/cloud-configs')
drives.value = data
} catch { /* */ }
}
function openAddDrive() {
editingDrive.value = null
Object.assign(driveForm, defaultDriveForm())
showAddDrive.value = true
}
function openEditDrive(row: any) {
editingDrive.value = row
driveForm.cloud_type = row.cloud_type
driveForm.cookie = ''
showAddDrive.value = true
}
function onDriveTypeChange() {
// placeholder auto-updates via computed
}
async function handleSaveDrive() {
const valid = await driveFormRef.value?.validate().catch(() => false)
if (!valid) return
adding.value = true
try {
let verifyNickname = ''
let verifyStorageUsed = ''
let verifyStorageTotal = ''
// Cookie
if (driveForm.cookie) {
try {
const api = userApi()
const { data: verifyResult } = await api.post(`/cloud-configs/${driveForm.cloud_type}/test`, { cookie: driveForm.cookie })
if (!verifyResult.success) {
ElMessage.error(`Cookie验证失败:${verifyResult.message}`)
adding.value = false
return
}
//
if (verifyResult.nickname) verifyNickname = verifyResult.nickname
if (verifyResult.storage_used) verifyStorageUsed = verifyResult.storage_used
if (verifyResult.storage_total) verifyStorageTotal = verifyResult.storage_total
} catch (e: any) {
ElMessage.error(`Cookie验证失败:${e.response?.data?.error || '网络错误'}`)
adding.value = false
return
}
}
const promotionAccount = account.value // 使广-
if (editingDrive.value) {
await userApi().put(`/cloud-configs/${editingDrive.value.id}`, {
cloud_type: driveForm.cloud_type,
nickname: verifyNickname || undefined,
promotion_account: promotionAccount,
cookie: driveForm.cookie || undefined,
storage_used: verifyStorageUsed || undefined,
storage_total: verifyStorageTotal || undefined,
})
ElMessage.success('更新成功')
} else {
if (!driveForm.cookie) { ElMessage.warning('请填写Cookie'); adding.value = false; return }
await userApi().post('/cloud-configs', {
cloud_type: driveForm.cloud_type,
cookie: driveForm.cookie,
nickname: verifyNickname || undefined,
promotion_account: promotionAccount,
storage_used: verifyStorageUsed || undefined,
storage_total: verifyStorageTotal || undefined,
})
ElMessage.success('添加成功')
}
showAddDrive.value = false
editingDrive.value = null
loadDrives()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally { adding.value = false }
}
async function handleDeleteDrive(id: number) {
try {
await userApi().delete(`/cloud-configs/${id}`)
ElMessage.success('已删除')
loadDrives()
} catch (e: any) { ElMessage.error(e.response?.data?.error || '删除失败') }
}
//
const notifyChannel = ref('')
const notifyWebhook = ref('')
const notifyEvents = ref<string[]>([])
const savingNotify = ref(false)
async function loadNotify() {
try {
const { data } = await userApi().get('/notify-config')
const cfg = JSON.parse(data.notifyConfig || '{}')
if (cfg.webhook) {
notifyChannel.value = 'feishu'
notifyWebhook.value = cfg.webhook
notifyEvents.value = cfg.events || []
}
} catch { /* */ }
}
async function saveNotify() {
savingNotify.value = true
try {
const config = notifyChannel.value === 'feishu'
? JSON.stringify({ webhook: notifyWebhook.value, events: notifyEvents.value })
: '{}'
await userApi().put('/notify-config', { notifyConfig: config })
ElMessage.success('推送配置已保存')
} catch (e: any) { ElMessage.error(e.response?.data?.error || '保存失败') }
finally { savingNotify.value = false }
}
watch(activeMenu, (tab) => {
if (tab === 'records') loadRecords()
else if (tab === 'drives') { loadEnabledCloudTypes(); loadDrives() }
else if (tab === 'notify') loadNotify()
}, { immediate: true })
</script>
<style scoped>
.user-layout { min-height: 100vh; background: #f0f2f5; }
.user-header {
display: flex; justify-content: space-between; align-items: center;
padding: 0 24px; height: 56px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.user-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; align-items: center; gap: 12px; }
.user-account { font-size: 13px; color: #606266; }
.user-body { display: flex; min-height: calc(100vh - 56px); }
.user-sidebar {
width: 180px; background: #fff; border-right: 1px solid #e4e7ed;
padding: 12px 0; flex-shrink: 0;
}
.sidebar-item {
padding: 10px 20px; cursor: pointer; font-size: 14px; color: #606266;
transition: all 0.2s;
}
.sidebar-item:hover { background: #f5f7fa; color: #303133; }
.sidebar-item.active { background: #ecf5ff; color: #409eff; font-weight: 500; border-right: 2px solid #409eff; }
.user-content { flex: 1; padding: 20px; overflow: auto; }
/* ── Cookie tutorial card (same as admin CloudConfig) ── */
.cookie-tips-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.cookie-tips {
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 6px;
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', 'Fira Code', '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>
@@ -0,0 +1,215 @@
<template>
<div class="user-login-page">
<div class="login-card">
<h1 class="login-title">CloudSearch 用户中心</h1>
<p class="login-subtitle">{{ isLogin ? '登录您的账号' : '注册新账号' }}</p>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" @keyup.enter="handleSubmit">
<el-form-item prop="platform">
<el-select v-model="form.platform" placeholder="选择推广平台" size="large" style="width: 100%">
<el-option
v-for="p in platforms"
:key="p.name"
:label="p.name"
:value="p.name"
/>
</el-select>
</el-form-item>
<el-form-item prop="phone">
<el-input v-model="form.phone" placeholder="手机号码" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password />
</el-form-item>
<el-form-item v-if="!isLogin" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="handleSubmit">
{{ isLogin ? '登 录' : '注 册' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-switch">
{{ isLogin ? '还没有账号?' : '已有账号?' }}
<el-button link type="primary" @click="isLogin = !isLogin">
{{ isLogin ? '立即注册' : '去登录' }}
</el-button>
</div>
</div>
<!-- 二维码区域 -->
<div v-if="platforms.length > 0" class="qr-section">
<div class="qr-title">{{ qrTitle }}</div>
<div class="qr-grid">
<div v-for="p in platforms" :key="p.name" class="qr-item">
<div class="qr-label">加入{{ p.name }}团队</div>
<div class="qr-img-wrap">
<img
:src="'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(p.join_url)"
:alt="'加入' + p.name"
class="qr-image"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const isLogin = ref(true)
const loading = ref(false)
const formRef = ref()
interface Platform {
id: number
name: string
join_url: string
sort_order: number
}
const platforms = ref<Platform[]>([])
const qrTitle = ref('扫码加入推广团队')
const form = reactive({
platform: '',
phone: '',
password: '',
confirmPassword: '',
})
const validatePhone = (_rule: any, value: string, callback: any) => {
if (!value) callback(new Error('请输入手机号码'))
else if (!/^1[3-9]\d{9}$/.test(value)) callback(new Error('请输入有效的手机号码'))
else callback()
}
const rules = {
platform: [{ required: true, message: '请选择推广平台', trigger: 'change' }],
phone: [{ validator: validatePhone, trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
confirmPassword: [{
validator: (_rule: any, value: string, callback: any) => {
if (!value) callback(new Error('请确认密码'))
else if (value !== form.password) callback(new Error('两次密码不一致'))
else callback()
}, trigger: 'blur'
}],
}
onMounted(async () => {
try {
const { data } = await axios.get('/api/promotion-platforms')
platforms.value = data.platforms
if (data.title) qrTitle.value = data.title
} catch { /* */ }
})
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isLogin.value) {
const account = form.platform + '-' + form.phone.trim()
const { data } = await axios.post('/api/user/login', {
account,
password: form.password,
})
localStorage.setItem('user_token', data.token)
localStorage.setItem('user_account', account)
ElMessage.success('登录成功')
router.push('/user/dashboard')
} else {
const { data } = await axios.post('/api/user/register', {
platform: form.platform,
phone: form.phone.trim(),
password: form.password,
})
ElMessage.success(data.message || '注册成功,请登录')
isLogin.value = true
form.password = ''
form.confirmPassword = ''
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.user-login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f0f2f5;
padding: 40px 20px;
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.login-title { text-align: center; font-size: 24px; margin: 0 0 8px; color: #303133; }
.login-subtitle { text-align: center; font-size: 14px; color: #909399; margin: 0 0 32px; }
.login-switch { text-align: center; font-size: 14px; color: #909399; margin-top: 16px; }
.qr-section {
margin-top: 40px;
text-align: center;
width: 100%;
max-width: 900px;
}
.qr-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
}
.qr-grid {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.qr-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.qr-img-wrap {
padding: 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
}
.qr-image {
display: block;
width: 100px;
height: 100px;
}
</style>
+28 -3
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
const routes = [
export const routes = [
{
path: '/',
name: 'home',
@@ -16,6 +16,12 @@ const routes = [
name: 'result-detail',
component: () => import('./pages/ResultDetail.vue'),
},
{
path: '/disclaimer',
alias: '/disclaimer/',
name: 'disclaimer',
component: () => import('./pages/Disclaimer.vue'),
},
{
path: '/admin/login',
name: 'admin-login',
@@ -57,10 +63,29 @@ const routes = [
},
],
},
{
path: '/user/login',
name: 'user-login',
component: () => import('./pages/user/UserLogin.vue'),
},
{
path: '/user/dashboard',
name: 'user-dashboard',
component: () => import('./pages/user/UserDashboard.vue'),
},
{
path: '/user',
redirect: '/user/login',
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('./pages/NotFound.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
history: typeof window === 'undefined' ? createMemoryHistory() : createWebHistory(),
routes,
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveSeo } from './seo'
describe('resolveSeo', () => {
it('builds home page seo for the site root', () => {
const seo = resolveSeo('/', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('随便下 | 资源小站 - 网盘资源搜索')
expect(seo.description).toContain('网盘资源搜索')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/')
expect(seo.robots).toBe('index,follow')
})
it('builds search page seo from the q parameter', () => {
const seo = resolveSeo('/search', { q: '阿凡达' }, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.title).toBe('阿凡达 - 搜索结果 | 随便下 | 资源小站')
expect(seo.description).toContain('阿凡达')
expect(seo.canonical).toBe('https://zy.hk.timxx.cn/search?q=%E9%98%BF%E5%87%A1%E8%BE%BE')
})
it('marks admin pages as noindex', () => {
const seo = resolveSeo('/admin/login', {}, '随便下 | 资源小站', 'https://zy.hk.timxx.cn')
expect(seo.robots).toBe('noindex,nofollow')
expect(seo.title).toContain('管理后台')
})
})
+125
View File
@@ -0,0 +1,125 @@
export interface SeoState {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
}
function getSearchQuery(query: Record<string, unknown>): string {
const q = query.q;
if (typeof q === 'string') return q.trim();
const input = query.input;
if (typeof input === 'string') return input.trim();
return '';
}
function normalizePath(pathname: string): string {
if (!pathname) return '/';
if (!pathname.startsWith('/')) return `/${pathname}`;
return pathname;
}
function buildCanonical(origin: string, pathname: string, query: Record<string, unknown>): string {
const base = origin ? origin.replace(/\/+$/, '') : '';
const normalizedPath = normalizePath(pathname);
const params = new URLSearchParams();
if (normalizedPath.startsWith('/search')) {
const q = getSearchQuery(query);
if (q) params.set('q', q);
}
const suffix = params.toString() ? `?${params.toString()}` : '';
return `${base}${normalizedPath}${suffix}` || normalizedPath;
}
export function resolveSeo(
pathname: string,
query: Record<string, unknown> = {},
siteName = 'CloudSearch',
origin = '',
siteLogo = '',
siteDisclaimer = '',
): SeoState {
const normalizedPath = normalizePath(pathname);
const q = getSearchQuery(query);
const titleSuffix = `${siteName} - 网盘资源搜索`;
let title = titleSuffix;
let description = `${siteName} 提供网盘资源搜索、热门榜单和内容聚合。`;
let robots = 'index,follow';
if (normalizedPath.startsWith('/search')) {
if (q) {
title = `${q} - 搜索结果 | ${siteName}`;
description = `搜索 ${q} 的网盘资源结果,查看链接、分类和保存记录。`;
} else {
title = `搜索结果 | ${siteName}`;
description = `${siteName} 的网盘资源搜索结果页。`;
}
} else if (normalizedPath.startsWith('/result/')) {
title = `资源详情 | ${siteName}`;
description = `查看资源详情、链接信息和关联内容。`;
} else if (normalizedPath.startsWith('/disclaimer')) {
title = `免责声明 | ${siteName}`;
description = siteDisclaimer || `查看 ${siteName} 的免责声明和使用说明。`;
} else if (normalizedPath.startsWith('/admin')) {
title = `管理后台 | ${siteName}`;
description = `访问 ${siteName} 的管理后台。`;
robots = 'noindex,nofollow';
} else if (normalizedPath.startsWith('/user')) {
title = `用户中心 | ${siteName}`;
description = `访问 ${siteName} 的用户中心。`;
robots = 'noindex,nofollow';
}
const canonical = buildCanonical(origin, normalizedPath, query);
const image = siteLogo
? new URL(siteLogo, origin || 'https://zy.hk.timxx.cn').href
: `${origin ? origin.replace(/\/+$/, '') : ''}/api/uploads/logo/site_logo.png` || '/api/uploads/logo/site_logo.png';
return {
title,
description,
canonical,
image,
robots,
};
}
function setMeta(selector: string, attr: string, value: string): void {
if (typeof document === 'undefined') return;
let el = document.querySelector(selector) as HTMLMetaElement | HTMLLinkElement | null;
if (!el) {
el = document.createElement(selector.startsWith('link') ? 'link' : 'meta');
if (selector.startsWith('meta[name="')) {
const name = selector.match(/meta\[name="([^"]+)"\]/)?.[1];
if (name) (el as HTMLMetaElement).setAttribute('name', name);
}
if (selector.startsWith('meta[property="')) {
const property = selector.match(/meta\[property="([^"]+)"\]/)?.[1];
if (property) (el as HTMLMetaElement).setAttribute('property', property);
}
if (selector.startsWith('link[')) {
const rel = selector.match(/link\[rel="([^"]+)"\]/)?.[1];
if (rel) (el as HTMLLinkElement).setAttribute('rel', rel);
}
document.head.appendChild(el);
}
el.setAttribute(attr, value);
}
export function applySeo(seo: SeoState): void {
if (typeof document === 'undefined') return;
document.title = seo.title;
setMeta('meta[name="description"]', 'content', seo.description);
setMeta('meta[name="robots"]', 'content', seo.robots);
setMeta('meta[property="og:title"]', 'content', seo.title);
setMeta('meta[property="og:description"]', 'content', seo.description);
setMeta('meta[property="og:type"]', 'content', 'website');
setMeta('meta[property="og:url"]', 'content', seo.canonical);
setMeta('meta[property="og:image"]', 'content', seo.image);
setMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
setMeta('meta[name="twitter:title"]', 'content', seo.title);
setMeta('meta[name="twitter:description"]', 'content', seo.description);
setMeta('meta[name="twitter:image"]', 'content', seo.image);
setMeta('link[rel="canonical"]', 'href', seo.canonical);
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { createDisclaimerTarget, createSearchPath, getHealthDisplay } from './home-helpers';
describe('home helpers', () => {
it('builds an encoded search path from trimmed user input', () => {
expect(createSearchPath(' 周杰伦 演唱会 ')).toBe('/search?q=%E5%91%A8%E6%9D%B0%E4%BC%A6%20%E6%BC%94%E5%94%B1%E4%BC%9A');
expect(createSearchPath(' ')).toBeNull();
});
it('uses same-tab navigation for the full disclaimer page', () => {
expect(createDisclaimerTarget()).toBe('/disclaimer');
});
it('marks optional video parser gaps as a test-environment note, not a core outage', () => {
const display = getHealthDisplay('ok', {
videoParser: 'unreachable',
videoParserRequired: false,
});
expect(display.isCoreHealthy).toBe(true);
expect(display.label).toBe('核心服务正常');
expect(display.note).toContain('视频解析');
expect(display.note).toContain('测试环境');
});
});
@@ -0,0 +1,35 @@
export interface HealthDisplayComponents {
videoParser?: string;
videoParserRequired?: boolean;
}
export interface HealthDisplay {
label: string;
isCoreHealthy: boolean;
note: string;
}
export function createSearchPath(query: string): string | null {
const trimmed = query.trim();
if (!trimmed) return null;
return `/search?q=${encodeURIComponent(trimmed)}`;
}
export function createDisclaimerTarget(path = '/disclaimer'): string {
return path;
}
export function getHealthDisplay(status: string, components: HealthDisplayComponents = {}): HealthDisplay {
const isCoreHealthy = status === 'ok' || (!components.videoParserRequired && status === 'degraded');
const label = isCoreHealthy ? '核心服务正常' : status === 'degraded' ? '部分服务降级' : '服务异常';
const videoParserOptionalGap = !components.videoParserRequired
&& components.videoParser
&& components.videoParser !== 'ok';
return {
label,
isCoreHealthy,
note: videoParserOptionalGap ? '视频解析仍在开发中,当前测试环境未启用,不影响核心搜索服务。' : '',
};
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { createRouter, createMemoryHistory } from 'vue-router';
import { routes } from '../router';
describe('application routes', () => {
function makeRouter() {
return createRouter({ history: createMemoryHistory(), routes });
}
it('resolves public and admin routes before the catch-all not-found route', () => {
const router = makeRouter();
expect(router.resolve('/').name).toBe('home');
expect(router.resolve('/search?q=test').name).toBe('search');
expect(router.resolve('/result/abc123').name).toBe('result-detail');
expect(router.resolve('/admin/login').name).toBe('admin-login');
expect(router.resolve('/admin/dashboard').matched.some(route => route.name === 'admin-dashboard')).toBe(true);
});
it('has an explicit disclaimer route instead of relying on the not-found fallback', () => {
const router = makeRouter();
expect(router.resolve('/disclaimer').name).toBe('disclaimer');
expect(router.resolve('/disclaimer/').name).toBe('disclaimer');
});
it('resolves unknown frontend routes to the not-found page', () => {
const router = makeRouter();
expect(router.resolve('/nonexistent-ui-audit').name).toBe('not-found');
expect(router.resolve('/unknown/path').name).toBe('not-found');
});
});
@@ -0,0 +1,14 @@
const KNOWN_PUBLIC_ROUTE_PATTERNS = [
/^\/$/,
/^\/search\/?$/,
/^\/result\/[^/]+\/?$/,
/^\/admin\/login\/?$/,
/^\/user\/login\/?$/,
/^\/user\/dashboard\/?$/,
/^\/user\/?$/,
];
export function isKnownPublicRoute(path: string): boolean {
const cleanPath = path.split('?')[0].split('#')[0] || '/';
return KNOWN_PUBLIC_ROUTE_PATTERNS.some(pattern => pattern.test(cleanPath));
}
-79
View File
@@ -1,79 +0,0 @@
<!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>
<style>
:root{--bg:#f5f6fa;--card:#fff;--text:#2c3e50;--sub:#7f8c8d;--pri:#3498db;--pri-hover:#2980b9;--danger:#e74c3c;--success:#27ae60;--warn:#f39c12;--border:#e8ecf1;--sidebar-bg:#1a1a2e;--sidebar-text:#b0b8c8;--sidebar-active:#fff;--radius:8px}
*{margin:0;padding:0;box-sizing:border-box}
#app{display:flex;flex:1}body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh}
.sidebar{width:220px;background:var(--sidebar-bg);color:var(--sidebar-text);flex-shrink:0;display:flex;flex-direction:column}
.sidebar-title{padding:20px;font-size:17px;font-weight:700;color:#fff;border-bottom:1px solid rgba(255,255,255,.08)}
.nav-item{display:flex;align-items:center;gap:8px;padding:9px 16px 9px 20px;cursor:pointer;color:var(--sidebar-text);font-size:13px;transition:.15s;border-left:3px solid transparent}
.nav-item:hover{color:#fff;background:rgba(255,255,255,.05)}
.nav-item.active{color:var(--sidebar-active);background:rgba(52,152,219,.15);border-left-color:var(--pri)}
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{padding:16px 24px;font-size:16px;font-weight:600;background:var(--card);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
#content{flex:1;overflow-y:auto;padding:24px}
.card{background:var(--card);border-radius:var(--radius);padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card-title{font-size:14px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin-bottom:20px}
.stat-card{background:var(--card);border-radius:var(--radius);padding:8px 12px;box-shadow:0 1px 3px rgba(0,0,0,.04);display:flex;align-items:center;justify-content:space-between}
.form-group{margin-bottom:14px;display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
.form-group label{font-size:13px;color:var(--text);font-weight:500;min-width:130px;padding-top:6px}
.form-control{padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;outline:none;transition:.15s;max-width:360px;width:100%}
.form-control:focus{border-color:var(--pri)}
.form-control.wide{max-width:500px}
textarea.form-control{min-height:70px;resize:vertical}
.btn{padding:7px 16px;border:none;border-radius:6px;font-size:13px;cursor:pointer;font-weight:500;transition:.15s;display:inline-flex;align-items:center;gap:4px}
.btn-pri{background:var(--pri);color:#fff}.btn-pri:hover{background:var(--pri-hover)}
.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover{background:#c0392b}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{background:var(--bg)}
.btn-sm{padding:4px 10px;font-size:12px}
.toggle{position:relative;display:inline-block;width:42px;height:22px}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:22px;transition:.2s}
.toggle-slider:before{position:absolute;content:"";height:16px;width:16px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--pri)}
.toggle input:checked+.toggle-slider:before{transform:translateX(20px)}
.toast{position:fixed;top:16px;right:16px;padding:12px 20px;border-radius:8px;color:#fff;font-size:13px;z-index:999;animation:slideIn .3s ease}
.toast-success{background:var(--success)}.toast-error{background:var(--danger)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.loading{text-align:center;padding:40px;color:var(--sub)}
</style>
</head>
<body>
<div id="app">
<div class="sidebar">
<div class="sidebar-title">CloudSearch</div>
<div class="nav-item active" data-page="dashboard" onclick="nav('dashboard')">📊 仪表盘</div>
<div class="nav-item" data-page="sys-site" onclick="nav('sys-site')">⚙️ 网站设置</div>
<div class="nav-item" data-page="sys-services" onclick="nav('sys-services')">🔗 外部服务</div>
<div class="nav-item" data-page="sys-manage" onclick="nav('sys-manage')">🖥 服务管理</div>
<div class="nav-item" data-page="sys-strategy" onclick="nav('sys-strategy')">🚀 性能配置</div>
<div class="nav-item" data-page="sys-password" onclick="nav('sys-password')">🔑 修改密码</div>
<div class="nav-item" data-page="cloud-config" onclick="nav('cloud-config')">☁️ 网盘设置</div>
<div class="nav-item" data-page="cleanup" onclick="nav('cleanup')">🗑 存储清理</div>
<div style="margin-top:auto;padding:12px"><button class="btn btn-outline btn-sm" onclick="logout()" style="width:100%;color:var(--sidebar-text)">退出登录</button></div>
</div>
<div class="main">
<div class="topbar"><span id="pageTitle">仪表盘</span><span id="appVersion" style="font-size:12px;color:var(--sub)">v-</span></div>
<div id="content"></div>
</div>
</div>
<script src="/admin/js/admin-core.js"></script>
<script src="/admin/js/admin-helpers.js"></script>
<script src="/admin/js/admin-login.js"></script>
<script src="/admin/js/admin-dashboard.js"></script>
<script src="/admin/js/admin-site.js"></script>
<script src="/admin/js/admin-services.js"></script>
<script src="/admin/js/admin-password.js"></script>
<script src="/admin/js/cloud/cloud-core.js"></script>
<script src="/admin/js/cloud/cloud-render.js"></script>
<script src="/admin/js/cloud/cloud-dialog.js"></script>
<script src="/admin/js/cloud/cloud-actions.js"></script>
<script src="/admin/js/admin-cleanup.js"></script>
<script src="/admin/js/admin-boot.js"></script>
</body>
</html>
@@ -1,15 +0,0 @@
// Fetch app version
fetch('/api/version').then(r => r.json()).then(d => {
var el = document.getElementById('appVersion');
if (el && d.version) el.textContent = 'v' + d.version;
}).catch(function(){});
(function(){
if(!TOKEN){
document.getElementById('app').innerHTML = '<div class="login-wrap"><div id="content"></div></div>';
page_login(document.getElementById('content'));
} else {
nav('dashboard');
}
})();
@@ -1,155 +0,0 @@
function page_cleanup(c){
c.innerHTML = '<div id="cleanupRoot"><p style="text-align:center;padding:40px">Loading...</p></div>';
loadCleanupPage();
}
function loadCleanupPage(){
Promise.all([
api('/api/admin/cloud-configs').catch(function(){ return []; }),
api('/api/admin/verify/status').catch(function(){ return {}; }),
api('/api/admin/system-configs').catch(function(){ return {}; })
]).then(function(res){
var accounts = res[0] || [];
var vStatus = res[1] || {};
var configs = res[2] || {};
renderCleanupPage(accounts, vStatus, configs);
});
}
function renderCleanupPage(accounts, vStatus, configs){
var lastVerify = vStatus.last_run ? new Date(vStatus.last_run).toLocaleString() : 'never';
var lastCleanup = configs.cleanup_last_run || 'never';
var h = '';
h += '<div class="card"><div class="card-title">Storage Cleanup & Management</div>';
h += '<p style="color:var(--sub);font-size:12px">Cloud verification, space refresh, scheduled cleanup</p></div>';
// Quick Actions
h += '<div class="card"><div class="card-title">Quick Actions</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
h += '<button class="btn btn-pri" onclick="runVerifyAll()">Verify All</button>';
h += '<button class="btn btn-pri" onclick="runStorageRefresh()">Refresh All Space</button>';
h += '<button class="btn btn-warn" onclick="runFullCleanup()">Full Cleanup</button>';
h += '<button class="btn btn-outline" onclick="runEmptyTrash()">Empty Trash</button>';
h += '</div>';
h += '<div id="actionStatus" style="font-size:12px;color:var(--sub);margin-top:8px"></div>';
h += '<div style="font-size:12px;color:var(--sub);margin-top:4px">Last verify: ' + lastVerify + ' | Last cleanup: ' + lastCleanup + '</div></div>';
// Account Table
h += '<div class="card"><div class="card-title">Account Verification</div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">';
h += '<th style="padding:8px 10px">Type</th><th style="padding:8px 10px">Nickname</th>';
h += '<th style="padding:8px 10px">Status</th><th style="padding:8px 10px">Space</th>';
h += '<th style="padding:8px 10px">Action</th></tr></thead><tbody>';
accounts.forEach(function(cfg){
var v = cfg.verification_status;
var vIcon = v==='valid'?'OK':(v==='invalid'?'FAIL':'PENDING');
var space = cfg.storage_used ? (cfg.storage_used + ' / ' + (cfg.storage_total||'?')) : '-';
h += '<tr style="border-bottom:1px solid var(--border)">';
h += '<td style="padding:8px 10px">' + (cfg.cloud_type||'?') + '</td>';
h += '<td style="padding:8px 10px">' + (cfg.nickname||'-') + '</td>';
h += '<td style="padding:8px 10px">' + vIcon + '</td>';
h += '<td style="padding:8px 10px;font-size:12px;color:var(--sub)">' + space + '</td>';
h += '<td style="padding:8px 10px"><button class="btn btn-sm btn-outline" onclick="runSingleVerify('+cfg.id+',this)">Verify</button></td></tr>';
});
if(accounts.length===0){
h += '<tr><td colspan="5" style="padding:20px;text-align:center;color:var(--sub)">No accounts found</td></tr>';
}
h += '</tbody></table></div></div>';
// Cleanup Config
h += '<div class="card"><div class="card-title">Cleanup Config</div>';
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">';
h += cfgRow('Cleanup Enabled', 'cleanup_enabled', configs, 'checkbox');
h += cfgRow('File Retention (days)', 'cleanup_file_retention_days', configs, 'number', '7');
h += cfgRow('Log Retention (days)', 'cleanup_log_retention_days', configs, 'number', '30');
h += cfgRow('Empty Trash After', 'cleanup_empty_trash', configs, 'checkbox');
h += cfgRow('Space Threshold Cleanup', 'cleanup_space_threshold_enabled', configs, 'checkbox');
h += cfgRow('Threshold %', 'cleanup_space_threshold_percent', configs, 'number', '90');
h += cfgRow('Delete %', 'cleanup_space_threshold_delete_percent', configs, 'number', '10');
h += '</div>';
h += '<button class="btn btn-pri" style="margin-top:12px" onclick="saveCleanupConfig()">Save Config</button></div>';
document.getElementById('cleanupRoot').innerHTML = h;
}
function cfgRow(label, key, configs, type, placeholder){
var val = configs[key] || '';
if(type==='checkbox'){
var chk = val==='true'?'checked':'';
return '<div><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" class="cleanupCfg" data-key="'+key+'" '+chk+'><span>'+label+'</span></label></div>';
}
return '<div><label style="font-size:12px;color:var(--sub)">'+label+'</label><input class="form-control cleanupCfg" data-key="'+key+'" value="'+val+'" placeholder="'+placeholder+'" style="max-width:120px"></div>';
}
function runVerifyAll(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Verifying all accounts...';
api('/api/admin/verify/run', {method:'POST'}).then(function(r){
el.innerHTML = 'Done: '+(r.ok||0)+'/'+r.total+' OK, '+r.failed+' failed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Verify failed: '+(e.message||'network error');
});
}
function runSingleVerify(id, btn){
btn.disabled = true; btn.textContent = '...';
api('/api/admin/verify/single/'+id, {method:'POST'}).then(function(r){
btn.textContent = r.success ? 'OK' : 'FAIL';
}).catch(function(){ btn.textContent = 'ERR'; })
.finally(function(){ setTimeout(loadCleanupPage, 1500); });
}
function runStorageRefresh(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Refreshing space info...';
api('/api/admin/storage/refresh', {method:'POST'}).then(function(){
el.innerHTML = 'Space refreshed';
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Refresh failed: '+(e.message||'network error');
});
}
function runFullCleanup(){
var el = document.getElementById('actionStatus');
el.innerHTML = 'Running full cleanup...';
api('/api/admin/cleanup/run', {method:'POST'}).then(function(r){
el.innerHTML = (r.message||'Cleanup done');
setTimeout(loadCleanupPage, 2000);
}).catch(function(e){
el.innerHTML = 'Cleanup failed: '+(e.message||'network error');
});
}
function runEmptyTrash(){
var el = document.getElementById('actionStatus');
if(!confirm('Empty all trash? This cannot be undone.')) return;
el.innerHTML = 'Emptying trash...';
api('/api/admin/cleanup/empty-trash', {method:'POST'}).then(function(r){
el.innerHTML = r.emptied ? 'Trash emptied' : (r.message||'Done');
}).catch(function(e){
el.innerHTML = 'Failed: '+(e.message||'network error');
});
}
function saveCleanupConfig(){
var entries = [];
document.querySelectorAll('.cleanupCfg').forEach(function(el){
var key = el.dataset.key;
var val = el.type==='checkbox' ? (el.checked?'true':'false') : el.value.trim();
entries.push({key:key, value:val});
});
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:entries})
}).then(function(){
toast('Config saved');
}).catch(function(e){
toast('Save failed: '+(e.message||''));
});
}
@@ -1,39 +0,0 @@
var TOKEN = localStorage.getItem('admin_token') || '';
var TITLES = {dashboard:'仪表盘','sys-site':'网站设置','sys-services':'外部服务','sys-manage':'服务管理','sys-strategy':'性能配置','sys-password':'修改密码','cloud-config':'网盘设置',cleanup:'存储清理'};
function api(path, opts){
opts = opts || {};
opts.headers = opts.headers || {};
if(TOKEN) opts.headers['Authorization'] = 'Bearer ' + TOKEN;
return fetch(path, opts).then(function(r){
if(r.status === 401){ localStorage.removeItem('admin_token'); location.reload(); throw new Error('unauth'); }
return r.json();
});
}
function toast(msg, type){
type = type || 'success';
var t = document.createElement('div'); t.className = 'toast toast-'+type; t.textContent = msg;
document.body.appendChild(t);
setTimeout(function(){ t.remove(); }, 2500);
}
document.querySelectorAll('.nav-item').forEach(function(el){
el.onclick = function(){ nav(this.dataset.page); };
});
function nav(page){
document.querySelectorAll('.nav-item').forEach(function(e){ e.classList.remove('active'); });
var target = document.querySelector('[data-page="'+page+'"]');
if(target) target.classList.add('active');
document.getElementById('pageTitle').textContent = TITLES[page] || page;
var c = document.getElementById('content');
c.innerHTML = '<div class="loading">加载中...</div>';
try{ window['page_'+page.replace(/-/g,'_')](c); }
catch(e){ c.innerHTML = '<div class="card"><p>加载出错: '+e.message+'</p></div>'; }
}
function logout(){
localStorage.removeItem('admin_token');
location.reload();
}
@@ -1,9 +0,0 @@
function page_dashboard(c){
api('/api/admin/services').then(function(d){
var h = '<div class="card"><div class="card-title">系统概览</div>'+
'<div class="stats-grid">'+
'<div class="stat-card"><div class="stat-value">'+(d.length||0)+'</div><div class="stat-label">服务数</div></div>'+
'</div></div>';
c.innerHTML = h;
}).catch(function(){ c.innerHTML = '<div class="card">加载失败</div>'; });
}
@@ -1 +0,0 @@
// Helper utilities
@@ -1,14 +0,0 @@
function page_login(c){
c.innerHTML = '<div style="max-width:400px;margin:60px auto"><div class="card"><div class="card-title">管理后台登录</div>'+
'<div class="form-group"><label>密码</label><input id="pwd" class="form-control" type="password" onkeydown="if(event.key===\'Enter\')doLogin()"></div>'+
'<button class="btn btn-pri" onclick="doLogin()">登录</button></div></div>';
}
function doLogin(){
var pwd = document.getElementById('pwd').value;
api('/api/admin/login', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})})
.then(function(d){
if(d.token){ localStorage.setItem('admin_token', d.token); TOKEN = d.token; nav('dashboard'); }
else{ toast(d.error||'密码错误','error'); }
});
}
@@ -1,13 +0,0 @@
function page_sys_password(c){
c.innerHTML = '<div class="card"><div class="card-title">修改密码</div>'+
'<div class="form-group"><label>当前密码</label><input id="oldPwd" class="form-control" type="password"></div>'+
'<div class="form-group"><label>新密码</label><input id="newPwd" class="form-control" type="password"></div>'+
'<button class="btn btn-pri" onclick="changePwd()">修改</button></div>';
}
function changePwd(){
api('/api/admin/change-password', {method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({old:document.getElementById('oldPwd').value,new:document.getElementById('newPwd').value})})
.then(function(d){ toast(d.error||'密码修改成功', d.error?'error':'success'); });
}
function page_sys_manage(c){ page_sys_services(c); }
function page_sys_strategy(c){ c.innerHTML = '<div class="card">性能配置页面</div>'; }
@@ -1,10 +0,0 @@
function page_sys_services(c){
api('/api/admin/services').then(function(d){
var h = '<div class="card"><div class="card-title">外部服务</div>';
(d||[]).forEach(function(s){
h += '<div class="service-card"><div class="service-info"><span class="name">'+s.name+'</span><span class="desc">'+s.url+'</span></div>'+
'<div class="service-actions"><span class="status-dot '+(s.enabled?'status-running':'status-stopped')+'"></span>'+(s.enabled?'运行中':'已停止')+'</div></div>';
});
c.innerHTML = h+'</div>';
});
}
@@ -1,14 +0,0 @@
function page_sys_site(c){
api('/api/admin/system-configs').then(function(d){
var h = '<div class="card"><div class="card-title">网站设置</div>';
h += '<div class="form-group"><label>网站名称</label><input id="site_name" class="form-control" value="'+(d.site_name||'')+'"></div>';
h += '<button class="btn btn-pri" onclick="saveSite()">保存</button></div>';
c.innerHTML = h;
});
}
function saveSite(){
var name = document.getElementById('site_name').value;
api('/api/admin/system-configs', {method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'site_name',value:name}]})})
.then(function(d){ toast(d.error||'保存成功', d.error?'error':'success'); });
}
@@ -1,80 +0,0 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by manual toggle
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
toast('⏳ 正在保存...');
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
var savedId = d.id;
toast('⏳ 正在验证 Cookie 并查询空间...');
return api('/api/admin/cloud-configs/'+body.cloud_type+'/test', {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({id: savedId})
});
}).then(function(r){
if(r && r.success){
toast('✅ 验证通过!空间: '+(r.storage_used||'?')+'/'+(r.storage_total||'?'));
} else {
toast('⚠️ 已保存但验证失败: '+(r?r.message:'未知错误'),'error');
}
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}
function doSaveAccount_OLD(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by verification
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}
@@ -1,43 +0,0 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
is_active: true
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}
@@ -1,43 +0,0 @@
function togCloudType(type, cb){
__cloudToggles[type] = cb.checked;
api('/api/admin/system-configs', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({entries:[{key:'cloud_type_'+type+'_enabled', value:cb.checked?'true':'false'}]})
});
}
function togCloudAcc(id, cb){
api('/api/admin/cloud-configs/'+id, {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:cb.checked})})
.then(function(d){ if(d.error){ cb.checked=!cb.checked; toast(d.error,'error'); } });
}
function delCloudAcc(id){ var name = '账号#'+id;
if(!confirm('确定删除 '+name+' 吗?')) return;
api('/api/admin/cloud-configs/'+id, {method:'DELETE'}).then(function(d){
toast(d.error?d.error:'已删除 '+name, d.error?'error':'success');
nav('cloud-config');
});
}
function doSaveAccount(){
var body = {
cloud_type: document.getElementById('dlg_type').value,
// default off, will be set by verification
};
var cookie = document.getElementById('dlg_cookie').value.trim();
if(!cookie){ toast('请先输入或扫码获取 Cookie','error'); return; }
body.cookie = cookie;
console.log("SAVE promo raw:", document.getElementById("dlg_promo").value); body.promotion_account = document.getElementById('dlg_promo').value.trim() || '';
body.nickname = document.getElementById('dlg_nick').value.trim() || '';
body.storage_used = __qrStorageUsed || '';
body.storage_total = __qrStorageTotal || '';
api('/api/admin/cloud-configs', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
}).then(function(d){
if(d && d.error){ toast(d.error,'error'); return; }
toast('✅ 网盘配置更新成功');
closeDialog();
nav('cloud-config');
}).catch(function(e){
toast('保存失败: '+(e.message||'网络错误'),'error');
console.error('doSaveAccount error:', e);
});
}
@@ -1,39 +0,0 @@
// Icons synced with src/config/cloud-icons.ts LEGACY_ICONS
// --- Cloud Config ---
var CLOUD_TYPES = [
{type:'quark',label:'夸克网盘',icon:'/admin/icons/quark.png'},
{type:'baidu',label:'百度网盘',icon:'/admin/icons/baidu.png'},
{type:'aliyun',label:'阿里云盘',icon:'/admin/icons/aliyun.png'},
{type:'115',label:'115网盘',icon:'/admin/icons/115.png'},
{type:'tianyi',label:'天翼云盘',icon:'/admin/icons/tianyi.png'},
{type:'123pan',label:'123云盘',icon:'/admin/icons/123pan.png'},
{type:'uc',label:'UC网盘',icon:'/admin/icons/uc.png'},
{type:'xunlei',label:'迅雷网盘',icon:'/admin/icons/xunlei.png'},
{type:'pikpak',label:'PikPak',icon:'/admin/icons/pikpak.png'},
{type:'magnet',label:'磁力链接',icon:'🧲'},
{type:'ed2k',label:'电驴链接',icon:'🔗'},
{type:'others',label:'其他',icon:'⬜'}
];
var QR_TYPES = ['quark','baidu'];
var __cloudToggles = {};
var __qrType = '', __qrSession = '', __qrCookie = '', __qrTimer = null;
function page_cloud_config(c){
c.innerHTML = '<div class="loading">加载中...</div>';
Promise.all([
api('/api/admin/system-configs').catch(function(e){ console.error(e); return {}; }),
api('/api/admin/cloud-configs').catch(function(e){ console.error(e); return []; })
]).then(function(res){
var d = res[0]||{}, accounts = res[1]||[];
__cloudToggles = {};
CLOUD_TYPES.forEach(function(ct){
var v = d['cloud_type_'+ct.type+'_enabled'];
__cloudToggles[ct.type] = v===undefined ? (ct.type!=='others') : (v==='true'||v==='1');
});
renderCloudPage(c, accounts);
}).catch(function(e){
c.innerHTML = '<div class="card"><p style="color:var(--danger)">加载失败: '+e.message+'</p></div>';
});
}
@@ -1,107 +0,0 @@
var __qrSession = '';
var __qrCookie = '';
var __qrType = '';
var __qrTimer = null;
var __qrNickname = '';
var __qrStorageUsed = '';
var __qrStorageTotal = '';
var QR_TYPES = ['quark','baidu'];
function openAddDialog(){
document.getElementById('dlg_type').value = 'quark';
document.getElementById('dlg_cookie').value = '';
document.getElementById('dlg_promo').value = '';
document.getElementById('dlg_nick').value = '';
document.getElementById('dlg_storage_info').textContent = '扫码后自动获取';
resetQR();
onDlgTypeChange();
document.getElementById('modalBg').style.display = 'flex';
}
function closeDialog(){
cancelQRPoll();
document.getElementById('modalBg').style.display = 'none';
}
function onDlgTypeChange(){
var t = document.getElementById('dlg_type').value;
document.getElementById('dlg_qr_section').style.display = (QR_TYPES.indexOf(t)!==-1) ? 'block' : 'none';
}
function resetQR(){
cancelQRPoll();
__qrSession = ''; __qrCookie = ''; __qrNickname = ''; __qrStorageUsed = ''; __qrStorageTotal = '';
document.getElementById('dlg_qr_status').textContent = '点击下方按钮生成扫码链接';
document.getElementById('dlg_qr_btn').disabled = false;
document.getElementById('dlg_qr_btn').textContent = '生成扫码链接';
document.getElementById('dlg_qr_btn').style.display = '';
document.getElementById('dlg_qr_hint').textContent = '';
}
function doStartQR(){
__qrType = document.getElementById('dlg_type').value;
document.getElementById('dlg_qr_btn').disabled = true;
document.getElementById('dlg_qr_btn').textContent = '生成中...';
document.getElementById('dlg_qr_status').textContent = '正在生成二维码...';
api('/api/admin/'+__qrType+'/qr-login/start', {method:'POST'}).then(function(d){
if(d.error){
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--danger)">❌ '+d.error+'</span>';
document.getElementById('dlg_qr_btn').disabled=false;
document.getElementById('dlg_qr_btn').textContent='重试';
return;
}
__qrSession = d.sessionId;
var url = d.qrUrl||d.url||'';
if(url){
var qrSrc = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(url);
document.getElementById('dlg_qr_status').innerHTML = '<img src="'+qrSrc+'" style="max-width:200px;max-height:200px;border:1px solid var(--border);border-radius:4px;display:block;margin:0 auto"><p style="font-size:11px;color:var(--sub);margin-top:6px;text-align:center">用对应 App 扫码并在手机上确认登录</p>';
}
document.getElementById('dlg_qr_hint').textContent = '等待扫码确认...';
document.getElementById('dlg_qr_btn').textContent = '重新生成';
document.getElementById('dlg_qr_btn').disabled = false;
pollQR();
});
}
function pollQR(){
if(!__qrSession) return;
__qrTimer = setTimeout(function(){
api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/status').then(function(d){
if(d.status==='logged_in'){
__qrCookie = d.cookie||'';
__qrNickname = d.nickname||'';
__qrStorageUsed = d.storage_used||'';
__qrStorageTotal = d.storage_total||'';
if(__qrNickname){ var n = document.getElementById('dlg_nick'); if(!n.value.trim()) n.value = __qrNickname; }
if(d.promotion_account){ var p = document.getElementById('dlg_promo'); if(!p.value.trim()) p.value = d.promotion_account; }
var si = document.getElementById('dlg_storage_info');
if(__qrStorageTotal){
si.textContent = '💾 ' + (__qrStorageUsed||'?') + ' / ' + __qrStorageTotal;
si.style.color = 'var(--success)';
} else { si.textContent = '空间信息未获取'; si.style.color = 'var(--sub)'; }
if(d.autoUpdated){
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动更新现有账号 #'+d.updatedConfigId+'</span>';
cancelQRPoll();
toast('✅ 已自动更新账号','success');
setTimeout(function(){ closeDialog(); nav('cloud-config'); }, 1000);
return;
}
document.getElementById('dlg_qr_hint').textContent = '✅ 登录成功!';
document.getElementById('dlg_qr_status').innerHTML = '<span style="color:var(--success);font-size:14px">✅ 扫码成功!已自动填入</span>';
document.getElementById('dlg_cookie').value = __qrCookie;
document.getElementById('dlg_qr_btn').style.display = 'none';
cancelQRPoll();
}else if(d.status==='expired'){
cancelQRPoll();
setTimeout(function(){ doStartQR(); }, 500);
}else{
pollQR();
}
}).catch(function(){ pollQR(); });
}, 3000);
}
function cancelQRPoll(){
if(__qrTimer){ clearTimeout(__qrTimer); __qrTimer = null; }
if(__qrSession){ api('/api/admin/'+__qrType+'/qr-login/'+__qrSession+'/cancel', {method:'POST'}).catch(function(){}); __qrSession=''; }
}
@@ -1,92 +0,0 @@
function renderCloudPage(c, accounts){
accounts = accounts || [];
var h = '';
// Render icon: URL → <img>, emoji/text → plain text
function iconHtml(icon, size){
if(!icon) return '';
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
}
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
}
// Toggle grid
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
CLOUD_TYPES.forEach(function(ct){
var on = __cloudToggles[ct.type];
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
'</div>';
});
h += '</div></div>';
// Account list — table style
if(accounts.length > 0){
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px"> 新增</button></div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
'</tr></thead><tbody>';
accounts.forEach(function(cfg){
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
var icon = (label||{}).icon||'⬜';
var active = cfg.is_active===1||cfg.is_active===true;
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'⏳');
h += '<tr style="border-bottom:1px solid var(--border)">'+
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px">'+ck+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
'</td>'+
'</tr>';
});
h += '</tbody></table></div></div>';
}
// No accounts yet — show add button
if(accounts.length === 0){
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()"> 新增网盘</button></div>';
}
// Modal dialog
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
h+='</select></div>'+
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie(仅夸克/百度)</p>'+
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
''+
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
'<div style="margin-top:16px;display:flex;gap:8px">'+
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
c.innerHTML = h;
}
@@ -1,92 +0,0 @@
function renderCloudPage(c, accounts){
accounts = accounts || [];
var h = '';
// Render icon: URL → <img>, emoji/text → plain text
function iconHtml(icon, size){
if(!icon) return '';
if(icon.indexOf('/')===0 || icon.indexOf('http')===0){
return '<img src="'+icon+'" style="width:'+size+'px;height:'+size+'px;vertical-align:middle;margin-right:4px" onerror="this.remove()">';
}
return '<span style="font-size:'+(size-2)+'px;vertical-align:middle;margin-right:4px">'+icon+'</span>';
}
// Toggle grid
h += '<div class="card"><div class="card-title">⚡ 搜索网盘类型控制</div>'+
'<p style="color:var(--sub);font-size:12px;margin-bottom:14px">控制搜索引擎检索哪些网盘类型的资源</p>'+
'<div class="stats-grid" style="grid-template-columns:repeat(auto-fill,minmax(170px,1fr))">';
CLOUD_TYPES.forEach(function(ct){
var on = __cloudToggles[ct.type];
h += '<div class="stat-card" style="padding:8px 12px;display:flex;align-items:center;justify-content:space-between">'+
'<span>'+iconHtml(ct.icon,20)+' <span style="font-size:12px">'+ct.label+'</span></span>'+
'<label class="toggle"><input type="checkbox" '+(on?'checked':'')+' onchange="togCloudType(\''+ct.type+'\',this)"><span class="toggle-slider"></span></label>'+
'</div>';
});
h += '</div></div>';
// Account list — table style
if(accounts.length > 0){
h += '<div class="card"><div class="card-title">📋 已有账号 ('+accounts.length+') <button class="btn btn-pri btn-sm" onclick="openAddDialog()" style="margin-left:12px"> 新增</button></div>';
h += '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">';
h += '<thead><tr style="background:var(--bg);border-bottom:2px solid var(--border);text-align:left">'+
'<th style="padding:10px 12px;white-space:nowrap">📱推广平台</th>'+
'<th style="padding:10px 12px;white-space:nowrap">推广平台账号</th>'+
'<th style="padding:10px 12px;white-space:nowrap">网盘昵称</th>'+'<th style="padding:10px 12px;white-space:nowrap">网盘UID</th>'+
'<th style="padding:10px 12px;white-space:nowrap">验证</th>'+
'<th style="padding:10px 12px;white-space:nowrap">空间</th>'+
'<th style="padding:10px 12px;white-space:nowrap">转存</th>'+
'<th style="padding:10px 12px;white-space:nowrap">操作</th>'+
'</tr></thead><tbody>';
accounts.forEach(function(cfg){
var label = CLOUD_TYPES.find(function(ct){ return ct.type===cfg.cloud_type; });
var icon = (label||{}).icon||'⬜';
var active = cfg.is_active===1||cfg.is_active===true;
var ck = cfg.verification_status==='valid'?'✅':(cfg.verification_status==='invalid'?'❌':'—');
h += '<tr style="border-bottom:1px solid var(--border)">'+
'<td style="padding:10px 12px;white-space:nowrap">'+iconHtml(icon,18)+(label||{}).label+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.promotion_account||'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+(cfg.nickname||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.cookie_uid||cfg.cloud_type)+'</td>'+
'<td style="padding:10px 12px">'+ck+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.storage_used||cfg.storage_total?'💾 '+(cfg.storage_used||'?')+'/'+(cfg.storage_total||'?'):'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap;font-size:12px;color:var(--sub)">'+(cfg.total_saves?'转存'+cfg.total_saves+'次':'—')+'</td>'+
'<td style="padding:10px 12px;white-space:nowrap">'+
'<label class="toggle"><input type="checkbox" '+(active?'checked':'')+' onchange="togCloudAcc('+cfg.id+',this)"><span class="toggle-slider"></span></label>'+
'<button class="btn btn-danger btn-sm" onclick="delCloudAcc('+cfg.id+')" style="margin-left:8px">🗑</button>'+
'</td>'+
'</tr>';
});
h += '</tbody></table></div></div>';
}
// No accounts yet — show add button
if(accounts.length === 0){
h += '<div class="card"><div class="card-title">📋 已有账号 (0)</div><p style="color:var(--sub);font-size:13px;margin-bottom:12px">还没有添加任何网盘账号</p><button class="btn btn-pri" onclick="openAddDialog()"> 新增网盘</button></div>';
}
// Modal dialog
h += '<div id="modalBg" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)closeDialog()">'+
'<div class="card" style="width:500px;max-width:95vw;max-height:90vh;overflow-y:auto" onclick="event.stopPropagation()">'+
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">'+
'<span style="font-weight:600;font-size:15px">新增网盘账号</span>'+
'<button class="btn btn-outline btn-sm" onclick="closeDialog()">✕</button></div>'+
'<div class="form-group"><label>网盘类型</label><select id="dlg_type" class="form-control" style="max-width:200px" onchange="onDlgTypeChange()">';
CLOUD_TYPES.forEach(function(ct){ h+='<option value="'+ct.type+'">'+ct.label+'</option>'; });
h+='</select></div>'+
'<div class="form-group"><label>推广平台账号</label><input id="dlg_promo" class="form-control" placeholder="平台注册所用手机号" style="max-width:250px"></div>'+
'<div class="form-group"><label>Cookie</label><textarea id="dlg_cookie" class="form-control wide" rows="5" placeholder="粘贴 Cookie 或通过下方扫码获取"></textarea></div>'+
'<div class="form-group"><label>网盘昵称</label><input id="dlg_nick" class="form-control" placeholder="扫码后自动获取" style="max-width:250px;background:#f5f6fa" readonly></div>'+
'<div class="form-group"><label>空间信息</label><span id="dlg_storage_info" style="font-size:13px;color:var(--sub);padding-top:6px">扫码后自动获取</span></div>'+
'<div id="dlg_qr_section" style="margin-top:12px;padding:12px;background:#f9fafb;border-radius:8px">'+
'<p style="font-size:12px;font-weight:600;margin-bottom:8px">📱 扫码获取 Cookie(仅夸克/百度)</p>'+
'<div id="dlg_qr_status" style="font-size:12px;color:var(--sub);margin-bottom:8px">点击下方按钮生成扫码链接</div>'+
''+
'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'+
'<button class="btn btn-pri btn-sm" id="dlg_qr_btn" onclick="doStartQR()">生成扫码链接</button>'+
'<span id="dlg_qr_hint" style="font-size:11px;color:var(--sub)"></span></div></div>'+
'<div style="margin-top:16px;display:flex;gap:8px">'+
'<button class="btn btn-pri" onclick="doSaveAccount()">💾 保存账号</button>'+
'<button class="btn btn-outline" onclick="closeDialog()">取消</button></div></div></div>';
c.innerHTML = h;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as I,o as L,c as _,a as o,t as m,e,w as t,g as p,q as h,h as u,i as g,j as r,C as M,k as l,D as T,G as j,H as A,I as D,J as E,u as H,y as R,_ as z}from"./index-WUL9v6H_.js";import{a as G}from"./index-BC_nIdvI.js";import"./index-DZ_Kha3d.js";const J={class:"admin-layout"},W={class:"admin-sidebar"},F={class:"sidebar-brand"},K={class:"sidebar-logo"},O=["src","alt"],P={key:1,class:"sidebar-logo-fallback"},Q={class:"sidebar-brand-text"},U={class:"sidebar-version"},X={class:"admin-content"},Y={class:"content-header"},Z={class:"content-breadcrumb"},$={class:"breadcrumb-current"},ss={class:"content-actions"},es={class:"content-body"},ts=I({__name:"AdminLayout",setup(os){const d=H(),v=R(),f=p(""),c=p(""),y=p(""),w={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","sys-notify":"消息推送","sys-daily-report":"每日汇报","save-records":"转存日志"},b=h(()=>{const n=v.name;return n==="admin-cloud-configs"?"cloud-configs-toggle":n==="admin-cleanup"?"cloud-configs-cleanup":n==="admin-system"?v.query.section||"sys-site":n==="admin-save-records"?"save-records":"dashboard"}),k=h(()=>w[b.value]||"仪表盘");function C(n){n==="dashboard"?d.push("/admin/dashboard"):n==="cloud-configs-toggle"?d.push("/admin/cloud-configs"):n==="cloud-configs-cleanup"?d.push("/admin/cleanup"):n.startsWith("sys-")?d.push({path:"/admin/system",query:{section:n}}):n==="save-records"?d.push("/admin/save-records"):n==="logout"&&(localStorage.removeItem("admin_token"),d.push("/admin/login"))}function S(){d.push("/")}return L(async()=>{try{const n=await G();f.value=n.site_name||"",c.value=n.site_logo||""}catch{}try{const s=await(await fetch("/health")).json();y.value=s.version}catch{}}),(n,s)=>{const i=u("el-icon"),a=u("el-menu-item"),x=u("el-sub-menu"),B=u("el-menu"),N=u("el-button"),V=u("router-view");return g(),_("div",J,[o("aside",W,[o("div",F,[o("div",K,[c.value?(g(),_("img",{key:0,src:c.value,alt:f.value||"CloudSearch",class:"sidebar-logo-img",onError:s[0]||(s[0]=q=>{q.target.style.display="none",c.value=""})},null,40,O)):(g(),_("span",P,"☁️"))]),o("div",Q,[o("h2",null,m(f.value||"CloudSearch"),1),s[1]||(s[1]=o("p",null,"管理控制台",-1))])]),e(B,{"default-active":b.value,class:"sidebar-menu",onSelect:C},{default:t(()=>[e(a,{index:"dashboard"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(M))]),_:1}),s[2]||(s[2]=o("span",null,"仪表盘",-1))]),_:1}),e(x,{index:"cloud-configs"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(T))]),_:1}),s[3]||(s[3]=o("span",null,"网盘管理",-1))]),default:t(()=>[e(a,{index:"cloud-configs-toggle"},{default:t(()=>[...s[4]||(s[4]=[l("📋 设置及授权",-1)])]),_:1}),e(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...s[5]||(s[5]=[l("🧹 存储清理",-1)])]),_:1})]),_:1}),e(x,{index:"system"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(j))]),_:1}),s[6]||(s[6]=o("span",null,"系统设置",-1))]),default:t(()=>[e(a,{index:"sys-site"},{default:t(()=>[...s[7]||(s[7]=[l("🌐 网站设置",-1)])]),_:1}),e(a,{index:"sys-services"},{default:t(()=>[...s[8]||(s[8]=[l("🔗 外部服务 & 缓存",-1)])]),_:1}),e(a,{index:"sys-strategy"},{default:t(()=>[...s[9]||(s[9]=[l("⚡ 性能配置",-1)])]),_:1}),e(a,{index:"sys-password"},{default:t(()=>[...s[10]||(s[10]=[l("🔑 修改密码",-1)])]),_:1}),e(a,{index:"sys-notify"},{default:t(()=>[...s[11]||(s[11]=[l("📬 消息推送",-1)])]),_:1}),e(a,{index:"sys-daily-report"},{default:t(()=>[...s[12]||(s[12]=[l("📊 每日汇报",-1)])]),_:1})]),_:1}),e(a,{index:"save-records"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(A))]),_:1}),s[13]||(s[13]=o("span",null,"转存日志",-1))]),_:1}),s[15]||(s[15]=o("div",{class:"sidebar-spacer"},null,-1)),o("div",U,"v"+m(y.value),1),e(a,{index:"logout"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(D))]),_:1}),s[14]||(s[14]=o("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"])]),o("div",X,[o("header",Y,[o("div",Z,[o("span",$,m(k.value),1)]),o("div",ss,[e(N,{text:"",size:"small",onClick:S},{default:t(()=>[e(i,null,{default:t(()=>[e(r(E))]),_:1}),s[16]||(s[16]=l(" 返回前台 ",-1))]),_:1})])]),o("main",es,[e(V)])])])}}}),ds=z(ts,[["__scopeId","data-v-66b4feae"]]);export{ds as default};
@@ -0,0 +1 @@
.admin-layout[data-v-66b4feae]{display:flex;height:100vh;background:var(--bg-page)}.admin-sidebar[data-v-66b4feae]{width:var(--sidebar-w);flex-shrink:0;display:flex;flex-direction:column;background:linear-gradient(180deg,#111827,#1e293b);position:relative;z-index:10}.sidebar-brand[data-v-66b4feae]{display:flex;align-items:center;gap:10px;padding:20px 20px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.sidebar-logo[data-v-66b4feae]{font-size:28px;line-height:1;flex-shrink:0;width:36px;height:36px;display:flex;align-items:center;justify-content:center}.sidebar-logo-img[data-v-66b4feae]{width:36px;height:36px;object-fit:contain;border-radius:6px}.sidebar-logo-fallback[data-v-66b4feae]{font-size:28px}.sidebar-brand-text h2[data-v-66b4feae]{font-size:16px;font-weight:700;margin:0;color:#fff;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-brand-text p[data-v-66b4feae]{font-size:11px;margin:2px 0 0;color:#ffffff73;letter-spacing:1px}.sidebar-menu[data-v-66b4feae]{flex:1;display:flex;flex-direction:column;background:transparent!important;border-right:none!important;padding:4px 0}.sidebar-menu[data-v-66b4feae] .el-menu-item,.sidebar-menu[data-v-66b4feae] .el-sub-menu__title{color:#ffffffa6;height:44px;line-height:44px;transition:all .2s ease;margin:0 6px;border-radius:var(--radius-sm)}.sidebar-menu[data-v-66b4feae] .el-menu-item:hover,.sidebar-menu[data-v-66b4feae] .el-sub-menu__title:hover{background:#ffffff14;color:#ffffffe6}.sidebar-menu[data-v-66b4feae] .el-menu-item.is-active{color:#fff;background:linear-gradient(90deg,#409eff40,#6366f126);font-weight:500}.sidebar-menu[data-v-66b4feae] .el-menu-item:after{display:none}.sidebar-menu[data-v-66b4feae] .el-sub-menu .el-menu{background:#0003!important}.sidebar-menu[data-v-66b4feae] .el-sub-menu .el-menu .el-menu-item{padding-left:52px!important;font-size:13px;height:38px;line-height:38px}.sidebar-menu[data-v-66b4feae] .el-icon{font-size:16px}.sidebar-spacer[data-v-66b4feae]{flex:1}.sidebar-version[data-v-66b4feae]{text-align:center;font-size:11px;color:#ffffff40;padding:8px 0;letter-spacing:.5px}.admin-content[data-v-66b4feae]{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}.content-header[data-v-66b4feae]{display:flex;align-items:center;justify-content:space-between;padding:16px 28px;background:var(--bg-card);border-bottom:1px solid var(--border-light);flex-shrink:0}.content-breadcrumb[data-v-66b4feae]{display:flex;align-items:center;gap:8px}.breadcrumb-current[data-v-66b4feae]{font-size:18px;font-weight:700;color:var(--text)}.content-actions[data-v-66b4feae] .el-button{color:var(--text-secondary);gap:4px}.content-body[data-v-66b4feae]{flex:1;overflow-y:auto;padding:24px 28px}.content-body[data-v-66b4feae] .save-bar{position:sticky;bottom:0;z-index:100;background:var(--bg-card);padding:12px 16px;border-radius:var(--radius-lg) var(--radius-lg) 0 0;border:1px solid var(--border);border-bottom:none;box-shadow:0 -2px 12px #00000014;display:flex;justify-content:flex-end;gap:10px;margin-top:24px}
@@ -0,0 +1 @@
import{d as I,o as L,c as m,a as o,t as _,e,w as t,g as p,q as w,h as u,i as g,j as r,C as M,k as l,D as T,G as j,H as A,I as D,J as E,u as H,y as R,_ as z}from"./index-Cez7RKLo.js";import{a as G}from"./index-iQrvdwZD.js";import"./index-DZ_Kha3d.js";const J={class:"admin-layout"},W={class:"admin-sidebar"},F={class:"sidebar-brand"},K={class:"sidebar-logo"},O=["src","alt"],P={key:1,class:"sidebar-logo-fallback"},Q={class:"sidebar-brand-text"},U={class:"sidebar-version"},X={class:"admin-content"},Y={class:"content-header"},Z={class:"content-breadcrumb"},$={class:"breadcrumb-current"},ss={class:"content-actions"},es={class:"content-body"},ts=I({__name:"AdminLayout",setup(os){const d=H(),v=R(),f=p(""),c=p(""),y=p(""),h={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","sys-notify":"消息推送","sys-daily-report":"每日汇报","sys-platforms":"推广平台管理","save-records":"转存日志"},b=w(()=>{const n=v.name;return n==="admin-cloud-configs"?"cloud-configs-toggle":n==="admin-cleanup"?"cloud-configs-cleanup":n==="admin-system"?v.query.section||"sys-site":n==="admin-save-records"?"save-records":"dashboard"}),k=w(()=>h[b.value]||"仪表盘");function C(n){n==="dashboard"?d.push("/admin/dashboard"):n==="cloud-configs-toggle"?d.push("/admin/cloud-configs"):n==="cloud-configs-cleanup"?d.push("/admin/cleanup"):n.startsWith("sys-")?d.push({path:"/admin/system",query:{section:n}}):n==="save-records"?d.push("/admin/save-records"):n==="logout"&&(localStorage.removeItem("admin_token"),d.push("/admin/login"))}function S(){d.push("/")}return L(async()=>{try{const n=await G();f.value=n.site_name||"",c.value=n.site_logo||""}catch{}try{const s=await(await fetch("/health")).json();y.value=s.version}catch{}}),(n,s)=>{const i=u("el-icon"),a=u("el-menu-item"),x=u("el-sub-menu"),B=u("el-menu"),N=u("el-button"),V=u("router-view");return g(),m("div",J,[o("aside",W,[o("div",F,[o("div",K,[c.value?(g(),m("img",{key:0,src:c.value,alt:f.value||"CloudSearch",class:"sidebar-logo-img",onError:s[0]||(s[0]=q=>{q.target.style.display="none",c.value=""})},null,40,O)):(g(),m("span",P,"☁️"))]),o("div",Q,[o("h2",null,_(f.value||"CloudSearch"),1),s[1]||(s[1]=o("p",null,"管理控制台",-1))])]),e(B,{"default-active":b.value,class:"sidebar-menu",onSelect:C},{default:t(()=>[e(a,{index:"dashboard"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(M))]),_:1}),s[2]||(s[2]=o("span",null,"仪表盘",-1))]),_:1}),e(x,{index:"cloud-configs"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(T))]),_:1}),s[3]||(s[3]=o("span",null,"网盘管理",-1))]),default:t(()=>[e(a,{index:"cloud-configs-toggle"},{default:t(()=>[...s[4]||(s[4]=[l("📋 设置及授权",-1)])]),_:1}),e(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...s[5]||(s[5]=[l("🧹 存储清理",-1)])]),_:1})]),_:1}),e(x,{index:"system"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(j))]),_:1}),s[6]||(s[6]=o("span",null,"系统设置",-1))]),default:t(()=>[e(a,{index:"sys-site"},{default:t(()=>[...s[7]||(s[7]=[l("🌐 网站设置",-1)])]),_:1}),e(a,{index:"sys-services"},{default:t(()=>[...s[8]||(s[8]=[l("🔗 外部服务 & 缓存",-1)])]),_:1}),e(a,{index:"sys-strategy"},{default:t(()=>[...s[9]||(s[9]=[l("⚡ 性能配置",-1)])]),_:1}),e(a,{index:"sys-password"},{default:t(()=>[...s[10]||(s[10]=[l("🔑 修改密码",-1)])]),_:1}),e(a,{index:"sys-notify"},{default:t(()=>[...s[11]||(s[11]=[l("📬 消息推送",-1)])]),_:1}),e(a,{index:"sys-daily-report"},{default:t(()=>[...s[12]||(s[12]=[l("📊 每日汇报",-1)])]),_:1}),e(a,{index:"sys-platforms"},{default:t(()=>[...s[13]||(s[13]=[l("👥 推广平台管理",-1)])]),_:1})]),_:1}),e(a,{index:"save-records"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(A))]),_:1}),s[14]||(s[14]=o("span",null,"转存日志",-1))]),_:1}),s[16]||(s[16]=o("div",{class:"sidebar-spacer"},null,-1)),o("div",U,"v"+_(y.value),1),e(a,{index:"logout"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(D))]),_:1}),s[15]||(s[15]=o("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"])]),o("div",X,[o("header",Y,[o("div",Z,[o("span",$,_(k.value),1)]),o("div",ss,[e(N,{text:"",size:"small",onClick:S},{default:t(()=>[e(i,null,{default:t(()=>[e(r(E))]),_:1}),s[17]||(s[17]=l(" 返回前台 ",-1))]),_:1})])]),o("main",es,[e(V)])])])}}}),ds=z(ts,[["__scopeId","data-v-6f75751a"]]);export{ds as default};
@@ -0,0 +1 @@
import{d as I,o as L,c as m,a as o,t as _,e,w as t,g as p,q as w,h as u,i as g,j as r,C as M,k as l,D as T,G as j,H as A,I as D,J as E,u as H,y as R,_ as z}from"./index-BEhBl6RD.js";import{a as G}from"./index-iQrvdwZD.js";import"./index-DZ_Kha3d.js";const J={class:"admin-layout"},W={class:"admin-sidebar"},F={class:"sidebar-brand"},K={class:"sidebar-logo"},O=["src","alt"],P={key:1,class:"sidebar-logo-fallback"},Q={class:"sidebar-brand-text"},U={class:"sidebar-version"},X={class:"admin-content"},Y={class:"content-header"},Z={class:"content-breadcrumb"},$={class:"breadcrumb-current"},ss={class:"content-actions"},es={class:"content-body"},ts=I({__name:"AdminLayout",setup(os){const d=H(),v=R(),f=p(""),c=p(""),y=p(""),h={dashboard:"仪表盘","cloud-configs-toggle":"网盘设置及授权","cloud-configs-cleanup":"存储清理","sys-site":"网站设置","sys-services":"外部服务 & 缓存","sys-strategy":"性能配置","sys-password":"修改管理员密码","sys-notify":"消息推送","sys-daily-report":"每日汇报","sys-platforms":"推广平台管理","save-records":"转存日志"},b=w(()=>{const n=v.name;return n==="admin-cloud-configs"?"cloud-configs-toggle":n==="admin-cleanup"?"cloud-configs-cleanup":n==="admin-system"?v.query.section||"sys-site":n==="admin-save-records"?"save-records":"dashboard"}),k=w(()=>h[b.value]||"仪表盘");function C(n){n==="dashboard"?d.push("/admin/dashboard"):n==="cloud-configs-toggle"?d.push("/admin/cloud-configs"):n==="cloud-configs-cleanup"?d.push("/admin/cleanup"):n.startsWith("sys-")?d.push({path:"/admin/system",query:{section:n}}):n==="save-records"?d.push("/admin/save-records"):n==="logout"&&(localStorage.removeItem("admin_token"),d.push("/admin/login"))}function S(){d.push("/")}return L(async()=>{try{const n=await G();f.value=n.site_name||"",c.value=n.site_logo||""}catch{}try{const s=await(await fetch("/health")).json();y.value=s.version}catch{}}),(n,s)=>{const i=u("el-icon"),a=u("el-menu-item"),x=u("el-sub-menu"),B=u("el-menu"),N=u("el-button"),V=u("router-view");return g(),m("div",J,[o("aside",W,[o("div",F,[o("div",K,[c.value?(g(),m("img",{key:0,src:c.value,alt:f.value||"CloudSearch",class:"sidebar-logo-img",onError:s[0]||(s[0]=q=>{q.target.style.display="none",c.value=""})},null,40,O)):(g(),m("span",P,"☁️"))]),o("div",Q,[o("h2",null,_(f.value||"CloudSearch"),1),s[1]||(s[1]=o("p",null,"管理控制台",-1))])]),e(B,{"default-active":b.value,class:"sidebar-menu",onSelect:C},{default:t(()=>[e(a,{index:"dashboard"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(M))]),_:1}),s[2]||(s[2]=o("span",null,"仪表盘",-1))]),_:1}),e(x,{index:"cloud-configs"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(T))]),_:1}),s[3]||(s[3]=o("span",null,"网盘管理",-1))]),default:t(()=>[e(a,{index:"cloud-configs-toggle"},{default:t(()=>[...s[4]||(s[4]=[l("📋 设置及授权",-1)])]),_:1}),e(a,{index:"cloud-configs-cleanup"},{default:t(()=>[...s[5]||(s[5]=[l("🧹 存储清理",-1)])]),_:1})]),_:1}),e(x,{index:"system"},{title:t(()=>[e(i,null,{default:t(()=>[e(r(j))]),_:1}),s[6]||(s[6]=o("span",null,"系统设置",-1))]),default:t(()=>[e(a,{index:"sys-site"},{default:t(()=>[...s[7]||(s[7]=[l("🌐 网站设置",-1)])]),_:1}),e(a,{index:"sys-services"},{default:t(()=>[...s[8]||(s[8]=[l("🔗 外部服务 & 缓存",-1)])]),_:1}),e(a,{index:"sys-strategy"},{default:t(()=>[...s[9]||(s[9]=[l("⚡ 性能配置",-1)])]),_:1}),e(a,{index:"sys-password"},{default:t(()=>[...s[10]||(s[10]=[l("🔑 修改密码",-1)])]),_:1}),e(a,{index:"sys-notify"},{default:t(()=>[...s[11]||(s[11]=[l("📬 消息推送",-1)])]),_:1}),e(a,{index:"sys-daily-report"},{default:t(()=>[...s[12]||(s[12]=[l("📊 每日汇报",-1)])]),_:1}),e(a,{index:"sys-platforms"},{default:t(()=>[...s[13]||(s[13]=[l("👥 推广平台管理",-1)])]),_:1})]),_:1}),e(a,{index:"save-records"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(A))]),_:1}),s[14]||(s[14]=o("span",null,"转存日志",-1))]),_:1}),s[16]||(s[16]=o("div",{class:"sidebar-spacer"},null,-1)),o("div",U,"v"+_(y.value),1),e(a,{index:"logout"},{default:t(()=>[e(i,null,{default:t(()=>[e(r(D))]),_:1}),s[15]||(s[15]=o("span",null,"退出登录",-1))]),_:1})]),_:1},8,["default-active"])]),o("div",X,[o("header",Y,[o("div",Z,[o("span",$,_(k.value),1)]),o("div",ss,[e(N,{text:"",size:"small",onClick:S},{default:t(()=>[e(i,null,{default:t(()=>[e(r(E))]),_:1}),s[17]||(s[17]=l(" 返回前台 ",-1))]),_:1})])]),o("main",es,[e(V)])])])}}}),ds=z(ts,[["__scopeId","data-v-6f75751a"]]);export{ds as default};

Some files were not shown because too many files have changed in this diff Show More