Compare commits
31 Commits
v0.3.53
...
ce3f95b8b9
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3f95b8b9 | |||
| 75f5d26964 | |||
| 4c161b63c6 | |||
| d79c11fb15 | |||
| 3b67df19e7 | |||
| c4ca8edd58 | |||
| d7b055f88b | |||
| 39724e6e73 | |||
| 288d30698a | |||
| a9dc056506 | |||
| 9ef58b5724 | |||
| c9067179ff | |||
| 5e3b5e83a4 | |||
| d4e62b1fb1 | |||
| 7471d9aece | |||
| f0c0d0f487 | |||
| e4e1890da3 | |||
| 470ebac20e | |||
| f284e14630 | |||
| bff955e45b | |||
| 6f7ab6dbc6 | |||
| 498b593b28 | |||
| c57af012b1 | |||
| 86d79e550b | |||
| 07d66ac666 | |||
| 42d2b5bf1c | |||
| d7c2a9cfad | |||
| 8da99d6861 | |||
| 6f20c662eb | |||
| 48d6b642e0 | |||
| da5bd01535 |
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
清理文件(移入回收站),返回详细结果。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
# ─── 公开入口 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
0.3.53
|
||||
0.5.6
|
||||
|
||||
Executable
+11
@@ -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"
|
||||
Executable
+128
@@ -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
|
||||
@@ -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,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||
html+=renderRankItems(items,key,false)
|
||||
html+='</div>'
|
||||
// 数据来源
|
||||
html+='<div class="rank-block-ftr">'+
|
||||
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||
'</div></div>'
|
||||
}
|
||||
el.innerHTML=html
|
||||
}
|
||||
|
||||
function renderRankItems(items,key,expanded){
|
||||
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||
const limit=3
|
||||
const show=expanded?items.length:Math.min(limit,items.length)
|
||||
let html=items.slice(0,show).map((item,i)=>{
|
||||
const c=i<3?' rank-idx top3':' rank-idx'
|
||||
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
if(items.length>limit&&!expanded){
|
||||
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function expandRank(key){
|
||||
const container=document.getElementById('ritems-'+key)
|
||||
if(!container)return
|
||||
const tab=window.__activeTab[key]||'hot'
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,key,true)
|
||||
}
|
||||
|
||||
function switchRankTab(category,tab){
|
||||
window.__activeTab[category]=tab
|
||||
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||
if(tabsContainer){
|
||||
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||
}
|
||||
const container=document.getElementById('ritems-'+category)
|
||||
if(container){
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,category,false)
|
||||
}
|
||||
}
|
||||
let userInfo = null
|
||||
let allResults = []
|
||||
let allChannels = []
|
||||
let activeTab = ''
|
||||
let currentSaveItem = null
|
||||
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||
|
||||
// ===== Fetch helpers =====
|
||||
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||
|
||||
// ===== Toast =====
|
||||
let toastTimer
|
||||
function showToast(msg,isError){
|
||||
const el=document.getElementById('toast')
|
||||
el.textContent=msg
|
||||
el.className='toast show'+(isError?' error':'')
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||
}
|
||||
|
||||
// ===== User =====
|
||||
async function checkLogin(){
|
||||
try{
|
||||
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
if(data.loggedIn){
|
||||
userInfo=data
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function logout(){
|
||||
localStorage.removeItem('h5_admin_token')
|
||||
userInfo=null
|
||||
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||
showToast('已退出')
|
||||
}
|
||||
|
||||
function showLogin(){
|
||||
document.getElementById('loginErr').textContent=''
|
||||
document.getElementById('loginUser').value=''
|
||||
document.getElementById('loginPass').value=''
|
||||
document.getElementById('loginModal').style.display='block'
|
||||
document.getElementById('overlay').style.display='block'
|
||||
}
|
||||
|
||||
function closeLogin(){
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
document.getElementById('overlay').style.display='none'
|
||||
}
|
||||
|
||||
async function handleLogin(){
|
||||
const user=document.getElementById('loginUser').value.trim()
|
||||
const pass=document.getElementById('loginPass').value
|
||||
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||
const btn=document.getElementById('loginBtn')
|
||||
btn.disabled=true;btn.textContent='登录中...'
|
||||
try{
|
||||
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
localStorage.setItem('h5_admin_token',data.token)
|
||||
userInfo={username:user}
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
closeLogin()
|
||||
showToast('登录成功')
|
||||
}else{
|
||||
const err=await res.json().catch(()=>({}))
|
||||
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('loginErr').textContent='网络错误'
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='登录'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Search =====
|
||||
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||
|
||||
let searchTimer
|
||||
function doSearch(){
|
||||
const q=document.getElementById('searchInput').value.trim()
|
||||
if(!q)return
|
||||
// Update URL
|
||||
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||
// Show loading
|
||||
document.getElementById('results').innerHTML=''
|
||||
document.getElementById('tabs').style.display='none'
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
document.getElementById('loading').style.display='block'
|
||||
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||
document.getElementById('searchBtn').disabled=true
|
||||
|
||||
let progress=0
|
||||
const bar=document.getElementById('loadingBar')
|
||||
const progressTimer=setInterval(()=>{
|
||||
if(progress<60)progress+=1+Math.random()*3
|
||||
else if(progress<85)progress+=0.5+Math.random()
|
||||
bar.style.width=progress+'%'
|
||||
},200)
|
||||
|
||||
// Use streaming search for live updates
|
||||
streamSearch(q,progressTimer,bar)
|
||||
}
|
||||
|
||||
async function streamSearch(q,progressTimer,bar){
|
||||
const startTime=Date.now()
|
||||
try{
|
||||
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||
|
||||
const reader=response.body.getReader()
|
||||
const decoder=new TextDecoder()
|
||||
let buffer=''
|
||||
let allItems=[]
|
||||
let channels=[]
|
||||
let totalCount=0
|
||||
let filteredCount=0
|
||||
|
||||
while(true){
|
||||
const {done,value}=await reader.read()
|
||||
if(done)break
|
||||
|
||||
buffer+=decoder.decode(value,{stream:true})
|
||||
const lines=buffer.split('\n')
|
||||
buffer=lines.pop()||''
|
||||
|
||||
for(const line of lines){
|
||||
if(!line.trim())continue
|
||||
try{
|
||||
const msg=JSON.parse(line)
|
||||
if(msg.type==='stats'){
|
||||
totalCount=msg.total||0
|
||||
filteredCount=msg.filtered||0
|
||||
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||
}else if(msg.type==='result'){
|
||||
if(msg.valid&&msg.id){
|
||||
allItems.push(msg.id)
|
||||
}
|
||||
}else if(msg.type==='complete'){
|
||||
const results=msg.results||[]
|
||||
channels=msg.channels||[]
|
||||
clearInterval(progressTimer)
|
||||
bar.style.width='100%'
|
||||
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||
return
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
clearInterval(progressTimer)
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
allResults=results
|
||||
allChannels=channels||[]
|
||||
|
||||
// Info bar
|
||||
if(totalCount>0){
|
||||
document.getElementById('infoBar').style.display='flex'
|
||||
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||
else document.getElementById('infoFiltered').textContent=''
|
||||
}else{
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
}
|
||||
|
||||
// Build tabs
|
||||
const tabsEl=document.getElementById('tabs')
|
||||
tabsEl.innerHTML=''
|
||||
const typeCounts={}
|
||||
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||
// "全部" tab
|
||||
const allTab=document.createElement('div')
|
||||
allTab.className='tab active'
|
||||
allTab.textContent='📋 全部 ('+results.length+')'
|
||||
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||
tabsEl.appendChild(allTab)
|
||||
for(const ct of sorted){
|
||||
const tab=document.createElement('div')
|
||||
tab.className='tab'
|
||||
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||
tabsEl.appendChild(tab)
|
||||
}
|
||||
tabsEl.style.display=results.length>0?'flex':'none'
|
||||
activeTab=''
|
||||
|
||||
// Render cards
|
||||
renderCardList(results)
|
||||
}
|
||||
|
||||
function setActiveTab(ct){
|
||||
activeTab=ct
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const isAll=i===0&&!ct
|
||||
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||
t.className='tab'+(active||isAll?' active':'')
|
||||
})
|
||||
}
|
||||
|
||||
function renderCardList(items){
|
||||
const el=document.getElementById('results')
|
||||
if(items.length===0){
|
||||
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML=items.map((item,idx)=>{
|
||||
const coverHtml=item.cover
|
||||
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||
const tags=extractTags(item.title||'')
|
||||
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||
'<div class="card-body">'+
|
||||
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||
'<div class="card-actions">'+
|
||||
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
|
||||
// Store items for save reference
|
||||
window.__h5Results=items
|
||||
}
|
||||
|
||||
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function extractTags(title){
|
||||
const tags=[]
|
||||
// Quality tags
|
||||
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||
return tags.slice(0,6)
|
||||
}
|
||||
|
||||
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||
|
||||
function formatTime(s){
|
||||
if(!s)return ''
|
||||
const d=new Date(s)
|
||||
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||
const diff=Date.now()-d.getTime()
|
||||
if(diff<0)return s.slice(0,10)
|
||||
const mins=Math.floor(diff/60000)
|
||||
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||
const hours=Math.floor(mins/60)
|
||||
if(hours<24)return hours+' 小时前'
|
||||
const days=Math.floor(hours/24)
|
||||
if(days<30)return days+' 天前'
|
||||
return Math.floor(days/30)+' 个月前'
|
||||
}
|
||||
|
||||
// ===== Save / Share =====
|
||||
function saveItem(idx){
|
||||
const items=window.__h5Results||[]
|
||||
currentSaveItem=items[idx]
|
||||
if(!currentSaveItem)return
|
||||
|
||||
document.getElementById('progressSteps').style.display='block'
|
||||
document.getElementById('shareContent').style.display='none'
|
||||
document.getElementById('saveError').style.display='none'
|
||||
document.getElementById('copyBtn2').style.display='none'
|
||||
|
||||
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||
document.getElementById('shareTitle').textContent=title
|
||||
|
||||
// Show modal
|
||||
document.getElementById('overlay').style.display='block'
|
||||
document.getElementById('shareModal').style.display='block'
|
||||
|
||||
// Reset steps
|
||||
resetSteps()
|
||||
advanceStep(1)
|
||||
|
||||
// Call save API
|
||||
doSave()
|
||||
}
|
||||
|
||||
async function doSave(){
|
||||
try{
|
||||
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||
const data=await res.json()
|
||||
|
||||
if(!data.success){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2
|
||||
advanceStep(2)
|
||||
await sleep(500)
|
||||
|
||||
// Step 3
|
||||
advanceStep(3)
|
||||
await sleep(300)
|
||||
|
||||
if(data.share_url){
|
||||
advanceStep(4)
|
||||
await sleep(200)
|
||||
showShareResult(data)
|
||||
}else{
|
||||
advanceStep(4)
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
function showShareResult(data){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('shareContent').style.display='block'
|
||||
|
||||
const link=data.share_url
|
||||
document.getElementById('shareLinkInput').value=link
|
||||
|
||||
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||
|
||||
// Generate QR
|
||||
const qrContainer=document.getElementById('qrContainer')
|
||||
qrContainer.innerHTML=''
|
||||
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||
|
||||
// Password
|
||||
const pwd=data.share_pwd||data.sharePwd||''
|
||||
if(pwd){
|
||||
document.getElementById('sharePwdRow').style.display='flex'
|
||||
document.getElementById('sharePwdTag').textContent=pwd
|
||||
}else{
|
||||
document.getElementById('sharePwdRow').style.display='none'
|
||||
}
|
||||
|
||||
document.getElementById('copyBtn2').style.display='inline-block'
|
||||
}
|
||||
|
||||
function resetSteps(){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
el.className='step'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
el.querySelector('.step-status').textContent='等待中'
|
||||
el.querySelector('.step-status').className='step-status wait'
|
||||
}
|
||||
}
|
||||
|
||||
function advanceStep(n){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
if(i<n){
|
||||
el.className='step done'
|
||||
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||
el.querySelector('.step-status').textContent='已完成'
|
||||
el.querySelector('.step-status').className='step-status done'
|
||||
}else if(i===n){
|
||||
el.className='step active'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||
el.querySelector('.step-status').textContent='进行中'
|
||||
el.querySelector('.step-status').className='step-status doing'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||
|
||||
function copyShareLink(){
|
||||
const input=document.getElementById('shareLinkInput')
|
||||
if(!input.value)return
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||
}else{
|
||||
fallbackCopy(input.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea')
|
||||
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
function openDisclaimer(){
|
||||
window.open('/disclaimer/','_blank')
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
document.getElementById('overlay').style.display='none'
|
||||
document.getElementById('shareModal').style.display='none'
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
checkLogin()
|
||||
|
||||
// Add Enter key handler for home search
|
||||
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||
// Also add for search view input
|
||||
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||
|
||||
// Fetch home page data
|
||||
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||
renderHomePage(data)
|
||||
}).catch(()=>{
|
||||
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||
})
|
||||
|
||||
// Check URL for query
|
||||
const params=new URLSearchParams(window.location.search)
|
||||
const q=params.get('q')
|
||||
if(q){
|
||||
document.getElementById('homePage').style.display='none'
|
||||
document.getElementById('searchView').style.display='block'
|
||||
document.getElementById('searchInput').value=q
|
||||
doSearch()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<!-- Footer -->
|
||||
<div id="siteFooter" class="site-footer" style="display:none">
|
||||
<div id="footerContent" class="footer-inner"></div>
|
||||
<div class="footer-actions" id="footerActions">
|
||||
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
|
||||
</div>
|
||||
</div>
|
||||
</html>
|
||||
@@ -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,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||
html+=renderRankItems(items,key,false)
|
||||
html+='</div>'
|
||||
// 数据来源
|
||||
html+='<div class="rank-block-ftr">'+
|
||||
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||
'</div></div>'
|
||||
}
|
||||
el.innerHTML=html
|
||||
}
|
||||
|
||||
function renderRankItems(items,key,expanded){
|
||||
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||
const limit=3
|
||||
const show=expanded?items.length:Math.min(limit,items.length)
|
||||
let html=items.slice(0,show).map((item,i)=>{
|
||||
const c=i<3?' rank-idx top3':' rank-idx'
|
||||
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
if(items.length>limit&&!expanded){
|
||||
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function expandRank(key){
|
||||
const container=document.getElementById('ritems-'+key)
|
||||
if(!container)return
|
||||
const tab=window.__activeTab[key]||'hot'
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,key,true)
|
||||
}
|
||||
|
||||
function switchRankTab(category,tab){
|
||||
window.__activeTab[category]=tab
|
||||
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||
if(tabsContainer){
|
||||
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||
}
|
||||
const container=document.getElementById('ritems-'+category)
|
||||
if(container){
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,category,false)
|
||||
}
|
||||
}
|
||||
let userInfo = null
|
||||
let allResults = []
|
||||
let allChannels = []
|
||||
let activeTab = ''
|
||||
let currentSaveItem = null
|
||||
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||
|
||||
// ===== Fetch helpers =====
|
||||
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||
|
||||
// ===== Toast =====
|
||||
let toastTimer
|
||||
function showToast(msg,isError){
|
||||
const el=document.getElementById('toast')
|
||||
el.textContent=msg
|
||||
el.className='toast show'+(isError?' error':'')
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||
}
|
||||
|
||||
// ===== User =====
|
||||
async function checkLogin(){
|
||||
try{
|
||||
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
if(data.loggedIn){
|
||||
userInfo=data
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function logout(){
|
||||
localStorage.removeItem('h5_admin_token')
|
||||
userInfo=null
|
||||
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||
showToast('已退出')
|
||||
}
|
||||
|
||||
function showLogin(){
|
||||
document.getElementById('loginErr').textContent=''
|
||||
document.getElementById('loginUser').value=''
|
||||
document.getElementById('loginPass').value=''
|
||||
document.getElementById('loginModal').style.display='block'
|
||||
document.getElementById('overlay').style.display='block'
|
||||
}
|
||||
|
||||
function closeLogin(){
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
document.getElementById('overlay').style.display='none'
|
||||
}
|
||||
|
||||
async function handleLogin(){
|
||||
const user=document.getElementById('loginUser').value.trim()
|
||||
const pass=document.getElementById('loginPass').value
|
||||
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||
const btn=document.getElementById('loginBtn')
|
||||
btn.disabled=true;btn.textContent='登录中...'
|
||||
try{
|
||||
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
localStorage.setItem('h5_admin_token',data.token)
|
||||
userInfo={username:user}
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
closeLogin()
|
||||
showToast('登录成功')
|
||||
}else{
|
||||
const err=await res.json().catch(()=>({}))
|
||||
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('loginErr').textContent='网络错误'
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='登录'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Search =====
|
||||
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||
|
||||
let searchTimer
|
||||
function doSearch(){
|
||||
const q=document.getElementById('searchInput').value.trim()
|
||||
if(!q)return
|
||||
// Update URL
|
||||
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||
// Show loading
|
||||
document.getElementById('results').innerHTML=''
|
||||
document.getElementById('tabs').style.display='none'
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
document.getElementById('loading').style.display='block'
|
||||
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||
document.getElementById('searchBtn').disabled=true
|
||||
|
||||
let progress=0
|
||||
const bar=document.getElementById('loadingBar')
|
||||
const progressTimer=setInterval(()=>{
|
||||
if(progress<60)progress+=1+Math.random()*3
|
||||
else if(progress<85)progress+=0.5+Math.random()
|
||||
bar.style.width=progress+'%'
|
||||
},200)
|
||||
|
||||
// Use streaming search for live updates
|
||||
streamSearch(q,progressTimer,bar)
|
||||
}
|
||||
|
||||
async function streamSearch(q,progressTimer,bar){
|
||||
const startTime=Date.now()
|
||||
try{
|
||||
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||
|
||||
const reader=response.body.getReader()
|
||||
const decoder=new TextDecoder()
|
||||
let buffer=''
|
||||
let allItems=[]
|
||||
let channels=[]
|
||||
let totalCount=0
|
||||
let filteredCount=0
|
||||
|
||||
while(true){
|
||||
const {done,value}=await reader.read()
|
||||
if(done)break
|
||||
|
||||
buffer+=decoder.decode(value,{stream:true})
|
||||
const lines=buffer.split('\n')
|
||||
buffer=lines.pop()||''
|
||||
|
||||
for(const line of lines){
|
||||
if(!line.trim())continue
|
||||
try{
|
||||
const msg=JSON.parse(line)
|
||||
if(msg.type==='stats'){
|
||||
totalCount=msg.total||0
|
||||
filteredCount=msg.filtered||0
|
||||
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||
}else if(msg.type==='result'){
|
||||
if(msg.valid&&msg.id){
|
||||
allItems.push(msg.id)
|
||||
}
|
||||
}else if(msg.type==='complete'){
|
||||
const results=msg.results||[]
|
||||
channels=msg.channels||[]
|
||||
clearInterval(progressTimer)
|
||||
bar.style.width='100%'
|
||||
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||
return
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
clearInterval(progressTimer)
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
allResults=results
|
||||
allChannels=channels||[]
|
||||
|
||||
// Info bar
|
||||
if(totalCount>0){
|
||||
document.getElementById('infoBar').style.display='flex'
|
||||
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||
else document.getElementById('infoFiltered').textContent=''
|
||||
}else{
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
}
|
||||
|
||||
// Build tabs
|
||||
const tabsEl=document.getElementById('tabs')
|
||||
tabsEl.innerHTML=''
|
||||
const typeCounts={}
|
||||
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||
// "全部" tab
|
||||
const allTab=document.createElement('div')
|
||||
allTab.className='tab active'
|
||||
allTab.textContent='📋 全部 ('+results.length+')'
|
||||
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||
tabsEl.appendChild(allTab)
|
||||
for(const ct of sorted){
|
||||
const tab=document.createElement('div')
|
||||
tab.className='tab'
|
||||
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||
tabsEl.appendChild(tab)
|
||||
}
|
||||
tabsEl.style.display=results.length>0?'flex':'none'
|
||||
activeTab=''
|
||||
|
||||
// Render cards
|
||||
renderCardList(results)
|
||||
}
|
||||
|
||||
function setActiveTab(ct){
|
||||
activeTab=ct
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const isAll=i===0&&!ct
|
||||
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||
t.className='tab'+(active||isAll?' active':'')
|
||||
})
|
||||
}
|
||||
|
||||
function renderCardList(items){
|
||||
const el=document.getElementById('results')
|
||||
if(items.length===0){
|
||||
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML=items.map((item,idx)=>{
|
||||
const coverHtml=item.cover
|
||||
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||
const tags=extractTags(item.title||'')
|
||||
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||
'<div class="card-body">'+
|
||||
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||
'<div class="card-actions">'+
|
||||
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
|
||||
// Store items for save reference
|
||||
window.__h5Results=items
|
||||
}
|
||||
|
||||
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function extractTags(title){
|
||||
const tags=[]
|
||||
// Quality tags
|
||||
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||
return tags.slice(0,6)
|
||||
}
|
||||
|
||||
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||
|
||||
function formatTime(s){
|
||||
if(!s)return ''
|
||||
const d=new Date(s)
|
||||
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||
const diff=Date.now()-d.getTime()
|
||||
if(diff<0)return s.slice(0,10)
|
||||
const mins=Math.floor(diff/60000)
|
||||
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||
const hours=Math.floor(mins/60)
|
||||
if(hours<24)return hours+' 小时前'
|
||||
const days=Math.floor(hours/24)
|
||||
if(days<30)return days+' 天前'
|
||||
return Math.floor(days/30)+' 个月前'
|
||||
}
|
||||
|
||||
// ===== Save / Share =====
|
||||
function saveItem(idx){
|
||||
const items=window.__h5Results||[]
|
||||
currentSaveItem=items[idx]
|
||||
if(!currentSaveItem)return
|
||||
|
||||
document.getElementById('progressSteps').style.display='block'
|
||||
document.getElementById('shareContent').style.display='none'
|
||||
document.getElementById('saveError').style.display='none'
|
||||
document.getElementById('copyBtn2').style.display='none'
|
||||
|
||||
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||
document.getElementById('shareTitle').textContent=title
|
||||
|
||||
// Show modal
|
||||
document.getElementById('overlay').style.display='block'
|
||||
document.getElementById('shareModal').style.display='block'
|
||||
|
||||
// Reset steps
|
||||
resetSteps()
|
||||
advanceStep(1)
|
||||
|
||||
// Call save API
|
||||
doSave()
|
||||
}
|
||||
|
||||
async function doSave(){
|
||||
try{
|
||||
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||
const data=await res.json()
|
||||
|
||||
if(!data.success){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2
|
||||
advanceStep(2)
|
||||
await sleep(500)
|
||||
|
||||
// Step 3
|
||||
advanceStep(3)
|
||||
await sleep(300)
|
||||
|
||||
if(data.share_url){
|
||||
advanceStep(4)
|
||||
await sleep(200)
|
||||
showShareResult(data)
|
||||
}else{
|
||||
advanceStep(4)
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
function showShareResult(data){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('shareContent').style.display='block'
|
||||
|
||||
const link=data.share_url
|
||||
document.getElementById('shareLinkInput').value=link
|
||||
|
||||
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||
|
||||
// Generate QR
|
||||
const qrContainer=document.getElementById('qrContainer')
|
||||
qrContainer.innerHTML=''
|
||||
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||
|
||||
// Password
|
||||
const pwd=data.share_pwd||data.sharePwd||''
|
||||
if(pwd){
|
||||
document.getElementById('sharePwdRow').style.display='flex'
|
||||
document.getElementById('sharePwdTag').textContent=pwd
|
||||
}else{
|
||||
document.getElementById('sharePwdRow').style.display='none'
|
||||
}
|
||||
|
||||
document.getElementById('copyBtn2').style.display='inline-block'
|
||||
}
|
||||
|
||||
function resetSteps(){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
el.className='step'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
el.querySelector('.step-status').textContent='等待中'
|
||||
el.querySelector('.step-status').className='step-status wait'
|
||||
}
|
||||
}
|
||||
|
||||
function advanceStep(n){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
if(i<n){
|
||||
el.className='step done'
|
||||
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||
el.querySelector('.step-status').textContent='已完成'
|
||||
el.querySelector('.step-status').className='step-status done'
|
||||
}else if(i===n){
|
||||
el.className='step active'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||
el.querySelector('.step-status').textContent='进行中'
|
||||
el.querySelector('.step-status').className='step-status doing'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||
|
||||
function copyShareLink(){
|
||||
const input=document.getElementById('shareLinkInput')
|
||||
if(!input.value)return
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||
}else{
|
||||
fallbackCopy(input.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea')
|
||||
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
function openDisclaimer(){
|
||||
window.open('/disclaimer/','_blank')
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
document.getElementById('overlay').style.display='none'
|
||||
document.getElementById('shareModal').style.display='none'
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
checkLogin()
|
||||
|
||||
// Add Enter key handler for home search
|
||||
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||
// Also add for search view input
|
||||
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||
|
||||
// Fetch home page data
|
||||
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||
renderHomePage(data)
|
||||
}).catch(()=>{
|
||||
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||
})
|
||||
|
||||
// Check URL for query
|
||||
const params=new URLSearchParams(window.location.search)
|
||||
const q=params.get('q')
|
||||
if(q){
|
||||
document.getElementById('homePage').style.display='none'
|
||||
document.getElementById('searchView').style.display='block'
|
||||
document.getElementById('searchInput').value=q
|
||||
doSearch()
|
||||
}
|
||||
|
||||
// ===== Dark Mode Toggle =====
|
||||
(function() {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'theme-btn';
|
||||
btn.title = '切换暗色模式';
|
||||
var isDark = localStorage.getItem('h5_theme') === 'dark';
|
||||
if (!isDark && window.matchMedia('(prefers-color-scheme: dark)').matches) isDark = true;
|
||||
btn.textContent = isDark ? '☀️' : '🌙';
|
||||
if (isDark) document.documentElement.setAttribute('data-theme', 'dark');
|
||||
btn.onclick = function() {
|
||||
var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : '');
|
||||
localStorage.setItem('h5_theme', dark ? 'dark' : 'light');
|
||||
btn.textContent = dark ? '☀️' : '🌙';
|
||||
};
|
||||
document.body.appendChild(btn);
|
||||
})();
|
||||
@@ -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>
|
||||
-1
File diff suppressed because one or more lines are too long
@@ -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}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,"'")+'\' data-newest=\''+JSON.stringify({items:cat.newest||[]}).replace(/'/g,"'")+'\'>'
|
||||
const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[])
|
||||
html+=renderRankItems(items,key,false)
|
||||
html+='</div>'
|
||||
// 数据来源
|
||||
html+='<div class="rank-block-ftr">'+
|
||||
'<span>'+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+'</span>'+
|
||||
'<span class="ftr-time">'+fetchedAt+'</span>'+
|
||||
'</div></div>'
|
||||
}
|
||||
el.innerHTML=html
|
||||
}
|
||||
|
||||
function renderRankItems(items,key,expanded){
|
||||
if(!items||items.length===0)return'<div style="padding:10px;text-align:center;color:#c0c4cc;font-size:12px">暂无数据</div>'
|
||||
const limit=3
|
||||
const show=expanded?items.length:Math.min(limit,items.length)
|
||||
let html=items.slice(0,show).map((item,i)=>{
|
||||
const c=i<3?' rank-idx top3':' rank-idx'
|
||||
return '<div class="rank-item" onclick="doSearchFromHome(\''+item.keyword.replace(/'/g,"\\'")+'\')">'+
|
||||
'<span class="'+c+'">'+(i+1)+'</span>'+
|
||||
'<span class="rank-name">'+item.keyword+'</span>'+
|
||||
'<span class="rank-cnt">'+(item.rating?'⭐'+item.rating:item.searchCount)+'</span>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
if(items.length>limit&&!expanded){
|
||||
html+='<div class="rank-expand" onclick="expandRank(\''+key+'\')">展开全部 ▼</div>'
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function expandRank(key){
|
||||
const container=document.getElementById('ritems-'+key)
|
||||
if(!container)return
|
||||
const tab=window.__activeTab[key]||'hot'
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,key,true)
|
||||
}
|
||||
|
||||
function switchRankTab(category,tab){
|
||||
window.__activeTab[category]=tab
|
||||
const tabsContainer=document.getElementById('rtabs-'+category)
|
||||
if(tabsContainer){
|
||||
tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab')
|
||||
tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active'
|
||||
}
|
||||
const container=document.getElementById('ritems-'+category)
|
||||
if(container){
|
||||
const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest)
|
||||
container.innerHTML=renderRankItems(data.items,category,false)
|
||||
}
|
||||
}
|
||||
let userInfo = null
|
||||
let allResults = []
|
||||
let allChannels = []
|
||||
let activeTab = ''
|
||||
let currentSaveItem = null
|
||||
const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'}
|
||||
const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'}
|
||||
const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'}
|
||||
const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12}
|
||||
|
||||
// ===== Fetch helpers =====
|
||||
function getToken(){return localStorage.getItem('h5_admin_token')}
|
||||
function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h}
|
||||
|
||||
// ===== Toast =====
|
||||
let toastTimer
|
||||
function showToast(msg,isError){
|
||||
const el=document.getElementById('toast')
|
||||
el.textContent=msg
|
||||
el.className='toast show'+(isError?' error':'')
|
||||
clearTimeout(toastTimer)
|
||||
toastTimer=setTimeout(()=>el.className='toast',2000)
|
||||
}
|
||||
|
||||
// ===== User =====
|
||||
async function checkLogin(){
|
||||
try{
|
||||
const res=await fetch('/api/me',{headers:apiHeaders()})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
if(data.loggedIn){
|
||||
userInfo=data
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+data.username+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function logout(){
|
||||
localStorage.removeItem('h5_admin_token')
|
||||
userInfo=null
|
||||
document.getElementById('userArea').innerHTML='<button class="login-btn-small" onclick="showLogin()">登录</button>'
|
||||
showToast('已退出')
|
||||
}
|
||||
|
||||
function showLogin(){
|
||||
document.getElementById('loginErr').textContent=''
|
||||
document.getElementById('loginUser').value=''
|
||||
document.getElementById('loginPass').value=''
|
||||
document.getElementById('loginModal').style.display='block'
|
||||
document.getElementById('overlay').style.display='block'
|
||||
}
|
||||
|
||||
function closeLogin(){
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
document.getElementById('overlay').style.display='none'
|
||||
}
|
||||
|
||||
async function handleLogin(){
|
||||
const user=document.getElementById('loginUser').value.trim()
|
||||
const pass=document.getElementById('loginPass').value
|
||||
if(!user||!pass){showToast('请输入用户名和密码',true);return}
|
||||
const btn=document.getElementById('loginBtn')
|
||||
btn.disabled=true;btn.textContent='登录中...'
|
||||
try{
|
||||
const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})})
|
||||
if(res.ok){
|
||||
const data=await res.json()
|
||||
localStorage.setItem('h5_admin_token',data.token)
|
||||
userInfo={username:user}
|
||||
document.getElementById('userArea').innerHTML='<span class="user-badge">'+user+'</span><button class="logout-btn-small" onclick="logout()">退出</button>'
|
||||
closeLogin()
|
||||
showToast('登录成功')
|
||||
}else{
|
||||
const err=await res.json().catch(()=>({}))
|
||||
document.getElementById('loginErr').textContent=err.error||'登录失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('loginErr').textContent='网络错误'
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='登录'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Search =====
|
||||
function handleKeydown(e){if(e.key==='Enter')doSearch()}
|
||||
|
||||
let searchTimer
|
||||
function doSearch(){
|
||||
const q=document.getElementById('searchInput').value.trim()
|
||||
if(!q)return
|
||||
// Update URL
|
||||
window.history.replaceState({},'','/h5?q='+encodeURIComponent(q))
|
||||
// Show loading
|
||||
document.getElementById('results').innerHTML=''
|
||||
document.getElementById('tabs').style.display='none'
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
document.getElementById('loading').style.display='block'
|
||||
document.getElementById('loadingText').textContent='🔍 正在搜索中...'
|
||||
document.getElementById('searchBtn').disabled=true
|
||||
|
||||
let progress=0
|
||||
const bar=document.getElementById('loadingBar')
|
||||
const progressTimer=setInterval(()=>{
|
||||
if(progress<60)progress+=1+Math.random()*3
|
||||
else if(progress<85)progress+=0.5+Math.random()
|
||||
bar.style.width=progress+'%'
|
||||
},200)
|
||||
|
||||
// Use streaming search for live updates
|
||||
streamSearch(q,progressTimer,bar)
|
||||
}
|
||||
|
||||
async function streamSearch(q,progressTimer,bar){
|
||||
const startTime=Date.now()
|
||||
try{
|
||||
const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})})
|
||||
if(!response.ok)throw new Error('搜索失败 ('+response.status+')')
|
||||
|
||||
const reader=response.body.getReader()
|
||||
const decoder=new TextDecoder()
|
||||
let buffer=''
|
||||
let allItems=[]
|
||||
let channels=[]
|
||||
let totalCount=0
|
||||
let filteredCount=0
|
||||
|
||||
while(true){
|
||||
const {done,value}=await reader.read()
|
||||
if(done)break
|
||||
|
||||
buffer+=decoder.decode(value,{stream:true})
|
||||
const lines=buffer.split('\n')
|
||||
buffer=lines.pop()||''
|
||||
|
||||
for(const line of lines){
|
||||
if(!line.trim())continue
|
||||
try{
|
||||
const msg=JSON.parse(line)
|
||||
if(msg.type==='stats'){
|
||||
totalCount=msg.total||0
|
||||
filteredCount=msg.filtered||0
|
||||
document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...'
|
||||
}else if(msg.type==='result'){
|
||||
if(msg.valid&&msg.id){
|
||||
allItems.push(msg.id)
|
||||
}
|
||||
}else if(msg.type==='complete'){
|
||||
const results=msg.results||[]
|
||||
channels=msg.channels||[]
|
||||
clearInterval(progressTimer)
|
||||
bar.style.width='100%'
|
||||
setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300)
|
||||
return
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
}
|
||||
}catch(e){
|
||||
clearInterval(progressTimer)
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
document.getElementById('results').innerHTML='<div class="empty">搜索失败:'+e.message+'</div>'
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Render =====
|
||||
function renderResults(results,channels,totalCount,filteredCount,time){
|
||||
document.getElementById('loading').style.display='none'
|
||||
document.getElementById('searchBtn').disabled=false
|
||||
allResults=results
|
||||
allChannels=channels||[]
|
||||
|
||||
// Info bar
|
||||
if(totalCount>0){
|
||||
document.getElementById('infoBar').style.display='flex'
|
||||
document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果'
|
||||
document.getElementById('infoTime').textContent='⏱ '+time+'ms'
|
||||
if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount
|
||||
else document.getElementById('infoFiltered').textContent=''
|
||||
}else{
|
||||
document.getElementById('infoBar').style.display='none'
|
||||
}
|
||||
|
||||
// Build tabs
|
||||
const tabsEl=document.getElementById('tabs')
|
||||
tabsEl.innerHTML=''
|
||||
const typeCounts={}
|
||||
for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1}
|
||||
const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99))
|
||||
// "全部" tab
|
||||
const allTab=document.createElement('div')
|
||||
allTab.className='tab active'
|
||||
allTab.textContent='📋 全部 ('+results.length+')'
|
||||
allTab.onclick=()=>{setActiveTab('');renderCardList(results)}
|
||||
tabsEl.appendChild(allTab)
|
||||
for(const ct of sorted){
|
||||
const tab=document.createElement('div')
|
||||
tab.className='tab'
|
||||
tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')'
|
||||
tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))}
|
||||
tabsEl.appendChild(tab)
|
||||
}
|
||||
tabsEl.style.display=results.length>0?'flex':'none'
|
||||
activeTab=''
|
||||
|
||||
// Render cards
|
||||
renderCardList(results)
|
||||
}
|
||||
|
||||
function setActiveTab(ct){
|
||||
activeTab=ct
|
||||
document.querySelectorAll('.tab').forEach((t,i)=>{
|
||||
const isAll=i===0&&!ct
|
||||
const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct])
|
||||
t.className='tab'+(active||isAll?' active':'')
|
||||
})
|
||||
}
|
||||
|
||||
function renderCardList(items){
|
||||
const el=document.getElementById('results')
|
||||
if(items.length===0){
|
||||
el.innerHTML='<div class="empty">暂无结果</div>'
|
||||
return
|
||||
}
|
||||
el.innerHTML=items.map((item,idx)=>{
|
||||
const coverHtml=item.cover
|
||||
? '<img src="'+escapeHtml(item.cover)+'" alt="" onerror="this.parentElement.innerHTML=\'<div class=placeholder>'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>\'" loading="lazy" />'
|
||||
: '<div class="placeholder">'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'</div>'
|
||||
const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||''
|
||||
const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6'
|
||||
const tags=extractTags(item.title||'')
|
||||
const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim()
|
||||
const relativeTime=formatTime(item.update_time||item.datetime||'')
|
||||
return '<div class="card" onclick="saveItem('+idx+')">'+
|
||||
'<div class="card-cover">'+coverHtml+'<span class="tag" style="background:'+cloudColor+'">'+cloudLabel+'</span></div>'+
|
||||
'<div class="card-body">'+
|
||||
'<div class="card-title">'+escapeHtml(cleanTitle)+'</div>'+
|
||||
'<div class="card-meta"><span>🕐 '+relativeTime+'</span>'+(item.file_size?'<span class="size">📦 '+escapeHtml(item.file_size)+'</span>':'')+'</div>'+
|
||||
(tags.length>0?'<div class="card-tags">'+tags.map(t=>'<span'+(isQualityTag(t)?' class="quality"':'')+'>'+escapeHtml(t)+'</span>').join('')+'</div>':'')+
|
||||
'<div class="card-actions">'+
|
||||
'<span class="card-source">'+(item.source?escapeHtml(item.source):'网盘')+'</span>'+
|
||||
'<button class="card-btn" onclick="event.stopPropagation();saveItem('+idx+')">🔗 获取分享链接</button>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>'
|
||||
}).join('')
|
||||
|
||||
// Store items for save reference
|
||||
window.__h5Results=items
|
||||
}
|
||||
|
||||
function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function extractTags(title){
|
||||
const tags=[]
|
||||
// Quality tags
|
||||
const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip']
|
||||
for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)}
|
||||
const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕']
|
||||
for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)}
|
||||
return tags.slice(0,6)
|
||||
}
|
||||
|
||||
function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)}
|
||||
|
||||
function formatTime(s){
|
||||
if(!s)return ''
|
||||
const d=new Date(s)
|
||||
if(isNaN(d.getTime()))return s.slice(0,10)
|
||||
const diff=Date.now()-d.getTime()
|
||||
if(diff<0)return s.slice(0,10)
|
||||
const mins=Math.floor(diff/60000)
|
||||
if(mins<60)return mins<=1?'刚刚':mins+' 分钟前'
|
||||
const hours=Math.floor(mins/60)
|
||||
if(hours<24)return hours+' 小时前'
|
||||
const days=Math.floor(hours/24)
|
||||
if(days<30)return days+' 天前'
|
||||
return Math.floor(days/30)+' 个月前'
|
||||
}
|
||||
|
||||
// ===== Save / Share =====
|
||||
function saveItem(idx){
|
||||
const items=window.__h5Results||[]
|
||||
currentSaveItem=items[idx]
|
||||
if(!currentSaveItem)return
|
||||
|
||||
document.getElementById('progressSteps').style.display='block'
|
||||
document.getElementById('shareContent').style.display='none'
|
||||
document.getElementById('saveError').style.display='none'
|
||||
document.getElementById('copyBtn2').style.display='none'
|
||||
|
||||
const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源'
|
||||
document.getElementById('shareTitle').textContent=title
|
||||
|
||||
// Show modal
|
||||
document.getElementById('overlay').style.display='block'
|
||||
document.getElementById('shareModal').style.display='block'
|
||||
|
||||
// Reset steps
|
||||
resetSteps()
|
||||
advanceStep(1)
|
||||
|
||||
// Call save API
|
||||
doSave()
|
||||
}
|
||||
|
||||
async function doSave(){
|
||||
try{
|
||||
const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})})
|
||||
const data=await res.json()
|
||||
|
||||
if(!data.success){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=data.message||data.error||'保存失败'
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2
|
||||
advanceStep(2)
|
||||
await sleep(500)
|
||||
|
||||
// Step 3
|
||||
advanceStep(3)
|
||||
await sleep(300)
|
||||
|
||||
if(data.share_url){
|
||||
advanceStep(4)
|
||||
await sleep(200)
|
||||
showShareResult(data)
|
||||
}else{
|
||||
advanceStep(4)
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent='生成分享链接失败'
|
||||
}
|
||||
}catch(e){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('saveError').style.display='flex'
|
||||
document.getElementById('saveError').textContent=e.message||'保存请求失败'
|
||||
}
|
||||
}
|
||||
|
||||
function showShareResult(data){
|
||||
document.getElementById('progressSteps').style.display='none'
|
||||
document.getElementById('shareContent').style.display='block'
|
||||
|
||||
const link=data.share_url
|
||||
document.getElementById('shareLinkInput').value=link
|
||||
|
||||
const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘'
|
||||
document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存'
|
||||
|
||||
// Generate QR
|
||||
const qrContainer=document.getElementById('qrContainer')
|
||||
qrContainer.innerHTML=''
|
||||
new QRCode(qrContainer,{text:link,width:140,height:140})
|
||||
|
||||
// Password
|
||||
const pwd=data.share_pwd||data.sharePwd||''
|
||||
if(pwd){
|
||||
document.getElementById('sharePwdRow').style.display='flex'
|
||||
document.getElementById('sharePwdTag').textContent=pwd
|
||||
}else{
|
||||
document.getElementById('sharePwdRow').style.display='none'
|
||||
}
|
||||
|
||||
document.getElementById('copyBtn2').style.display='inline-block'
|
||||
}
|
||||
|
||||
function resetSteps(){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
el.className='step'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
el.querySelector('.step-status').textContent='等待中'
|
||||
el.querySelector('.step-status').className='step-status wait'
|
||||
}
|
||||
}
|
||||
|
||||
function advanceStep(n){
|
||||
for(let i=1;i<=3;i++){
|
||||
const el=document.getElementById('step'+i)
|
||||
if(i<n){
|
||||
el.className='step done'
|
||||
el.querySelector('.step-dot').innerHTML='<span class="step-check">✓</span>'
|
||||
el.querySelector('.step-status').textContent='已完成'
|
||||
el.querySelector('.step-status').className='step-status done'
|
||||
}else if(i===n){
|
||||
el.className='step active'
|
||||
el.querySelector('.step-dot').innerHTML='<span>'+i+'</span>'
|
||||
const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接']
|
||||
el.querySelector('.step-title').textContent=titles[i-1]+'...'
|
||||
el.querySelector('.step-status').textContent='进行中'
|
||||
el.querySelector('.step-status').className='step-status doing'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms){return new Promise(r=>setTimeout(r,ms))}
|
||||
|
||||
function copyShareLink(){
|
||||
const input=document.getElementById('shareLinkInput')
|
||||
if(!input.value)return
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value))
|
||||
}else{
|
||||
fallbackCopy(input.value)
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea')
|
||||
ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
function openDisclaimer(){
|
||||
window.open('/disclaimer/','_blank')
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
document.getElementById('overlay').style.display='none'
|
||||
document.getElementById('shareModal').style.display='none'
|
||||
document.getElementById('loginModal').style.display='none'
|
||||
}
|
||||
|
||||
// ===== Init =====
|
||||
checkLogin()
|
||||
|
||||
// Add Enter key handler for home search
|
||||
document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()})
|
||||
// Also add for search view input
|
||||
document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})
|
||||
|
||||
// Fetch home page data
|
||||
fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{
|
||||
renderHomePage(data)
|
||||
}).catch(()=>{
|
||||
document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」'
|
||||
document.getElementById('homeQuoteAuthor').textContent='---孔子'
|
||||
})
|
||||
|
||||
// Check URL for query
|
||||
const params=new URLSearchParams(window.location.search)
|
||||
const q=params.get('q')
|
||||
if(q){
|
||||
document.getElementById('homePage').style.display='none'
|
||||
document.getElementById('searchView').style.display='block'
|
||||
document.getElementById('searchInput').value=q
|
||||
doSearch()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<!-- Footer -->
|
||||
<div id="siteFooter" class="site-footer" style="display:none">
|
||||
<div id="footerContent" class="footer-inner"></div>
|
||||
<div class="footer-actions" id="footerActions">
|
||||
<button class="footer-btn" onclick="openDisclaimer()">📜 免责声明</button>
|
||||
</div>
|
||||
</div>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
Executable
+193
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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('管理后台')
|
||||
})
|
||||
})
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
+2
-2
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
Reference in New Issue
Block a user