Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| 在线文件转存到网盘 - 基于 FastAPI 的现代化界面 | |
| 通过云端高速通道实现文件转存到网盘中 | |
| """ | |
| import json | |
| import uuid | |
| import os | |
| import io | |
| import zipfile | |
| import requests | |
| from datetime import datetime | |
| from urllib.parse import urlparse, unquote | |
| from fastapi import FastAPI, Request, Query | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from pydantic import BaseModel | |
| import uvicorn | |
| DEFAULT_TOKEN_Q = "" | |
| ACCEPT_VALUE = os.environ.get("ACCEPT_VALUE", "") | |
| API_VERSION_KEY = os.environ.get("API_VERSION_KEY", "") | |
| ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN_G", "") | |
| API_BASE_URL = os.environ.get("API_BASE_URL", f"") | |
| QUARK_COOKIE = os.environ.get("ACCESS_TOKEN_Q", DEFAULT_TOKEN_Q) | |
| ENABLE_TEST_DATA = False | |
| def get_remote_processor(): | |
| token = ACCESS_TOKEN | |
| base_url = API_BASE_URL | |
| if not all([token, base_url]): | |
| return None | |
| return RemoteTaskProcessor(token, base_url) | |
| class RemoteTaskProcessor: | |
| def __init__(self, token, base_url): | |
| self.token = token | |
| self.base_url = base_url | |
| self.headers = { | |
| "Authorization": f"Bearer {token}", | |
| "Accept": ACCEPT_VALUE, | |
| API_VERSION_KEY: "2022-11-28" | |
| } | |
| def generate_task_id(self): | |
| date_str = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| short_uuid = uuid.uuid4().hex[:12] | |
| return f"task_{date_str}_{short_uuid}" | |
| def exec_task(self, task_file, inputs, trace_id): | |
| url = f"{self.base_url}/actions/workflows/{task_file}/dispatches" | |
| dispatch_inputs = {"trace_id": trace_id, **inputs} | |
| data = {"ref": "main", "inputs": dispatch_inputs} | |
| response = requests.post(url, headers=self.headers, json=data) | |
| return response.status_code == 204, response | |
| def find_task_by_task_id(self, task_file, trace_id): | |
| url = f"{self.base_url}/actions/workflows/{task_file}/runs" | |
| params = {"event": "workflow_dispatch", "branch": "main", "per_page": 20} | |
| resp = requests.get(url, headers=self.headers, params=params) | |
| resp.raise_for_status() | |
| runs = resp.json().get("workflow_runs", []) | |
| for run in runs: | |
| run_name = run.get("name", "") | |
| display_title = run.get("display_title", "") | |
| if trace_id in run_name or trace_id in display_title: | |
| return run | |
| return None | |
| def get_task_status(self, task_id): | |
| url = f"{self.base_url}/actions/runs/{task_id}" | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code != 200: | |
| return None | |
| return response.json() | |
| def get_result(self, run_id, artifact_name="result"): | |
| url = f"{self.base_url}/actions/runs/{run_id}/artifacts" | |
| response = requests.get(url, headers=self.headers) | |
| if response.status_code != 200: | |
| return None | |
| artifacts = response.json().get("artifacts", []) | |
| target = None | |
| for a in artifacts: | |
| if a["name"] == artifact_name: | |
| target = a | |
| break | |
| if not target: | |
| return None | |
| download_url = f"{self.base_url}/actions/artifacts/{target['id']}/zip" | |
| response = requests.get(download_url, headers=self.headers) | |
| if response.status_code != 200: | |
| return None | |
| try: | |
| with zipfile.ZipFile(io.BytesIO(response.content)) as zf: | |
| if "result.json" in zf.namelist(): | |
| return json.loads(zf.read("result.json").decode("utf-8")) | |
| except Exception: | |
| pass | |
| return None | |
| def is_valid_url(url: str) -> bool: | |
| """初步判断是否是有效的 http/https 下载链接""" | |
| if not url: | |
| return False | |
| try: | |
| result = urlparse(url.strip()) | |
| return all([result.scheme, result.netloc]) and result.scheme in ["http", "https"] | |
| except Exception: | |
| return False | |
| def extract_filename_from_url(url): | |
| if not url or not url.strip(): | |
| return "" | |
| try: | |
| parsed = urlparse(url.strip()) | |
| path = unquote(parsed.path) | |
| path = path.rstrip("/") | |
| filename = os.path.basename(path) | |
| if filename and "." in filename: | |
| return filename | |
| if filename: | |
| return filename | |
| except Exception: | |
| pass | |
| return "" | |
| # ============================================================ | |
| # 核心刷新逻辑 | |
| # ============================================================ | |
| def _do_refresh(task_list, trigger): | |
| updated_count = 0 | |
| for task in task_list: | |
| if task["status"] not in ["正在转存", "未提交"]: | |
| continue | |
| run_id_str = task.get("run_id", "") | |
| if not run_id_str: | |
| run = trigger.find_task_by_task_id("upload.yml", task["trace_id"]) | |
| if run: | |
| task["run_id"] = str(run["id"]) | |
| run_id_str = str(run["id"]) | |
| else: | |
| continue | |
| try: | |
| run_data = trigger.get_task_status(int(run_id_str)) | |
| except (ValueError, TypeError): | |
| continue | |
| if not run_data: | |
| continue | |
| status = run_data.get("status") | |
| conclusion = run_data.get("conclusion") | |
| if status == "completed": | |
| if conclusion == "success": | |
| result = trigger.get_result(int(run_id_str)) | |
| if result and isinstance(result, dict): | |
| task["status"] = "已转存" | |
| task["share_url"] = result.get("share_url", "") | |
| else: | |
| task["status"] = "已转存" | |
| else: | |
| task["status"] = "失败" | |
| updated_count += 1 | |
| return updated_count | |
| def _has_active_tasks(task_list): | |
| return any(t["status"] in ["正在转存", "未提交"] for t in task_list) | |
| # ============================================================ | |
| # FastAPI 应用 | |
| # ============================================================ | |
| app = FastAPI(title="CloudFileRelay|Online Files to Cloud Drive") | |
| # 会话存储 | |
| SESSION_FILE = "sessions_db.json" | |
| sessions: dict = {} | |
| def load_sessions(): | |
| global sessions | |
| if os.path.exists(SESSION_FILE): | |
| try: | |
| with open(SESSION_FILE, "r", encoding="utf-8") as f: | |
| sessions = json.load(f) | |
| except Exception: | |
| sessions = {} | |
| def save_sessions(): | |
| try: | |
| with open(SESSION_FILE, "w", encoding="utf-8") as f: | |
| json.dump(sessions, f, ensure_ascii=False, indent=2) | |
| except Exception: | |
| pass | |
| load_sessions() | |
| def get_or_create_session(request: Request) -> tuple: | |
| # 优先从 header 获取 sid,作为 fallback 方案,解决 iframe 跨域 Cookie 丢失问题 | |
| sid = request.headers.get("X-Session-ID", "") | |
| if not sid: | |
| sid = request.cookies.get("sid", "") | |
| if sid and sid in sessions: | |
| return sid, sessions[sid] | |
| # 如果 sid 已经存在但不在 sessions 中(可能服务器重启),仍保留原 sid 以维持客户端一致性 | |
| if not sid: | |
| sid = uuid.uuid4().hex | |
| if sid not in sessions: | |
| sessions[sid] = [] | |
| save_sessions() | |
| return sid, sessions[sid] | |
| def json_resp(data: dict, sid: str) -> JSONResponse: | |
| # 每次有变更时保存会话(这里简单处理,实际高并发下建议异步或定时保存) | |
| save_sessions() | |
| # 在响应体中携带 sid,方便前端存入 localStorage | |
| data["sid"] = sid | |
| resp = JSONResponse(content=data) | |
| # 使用 samesite="none" 和 secure=True 以支持在 Hugging Face Spaces 等 iframe 环境中正常传递 Cookie | |
| resp.set_cookie("sid", sid, max_age=86400 * 7, httponly=True, samesite="none", secure=True) | |
| return resp | |
| # ---- 请求模型 ---- | |
| class SubmitRequest(BaseModel): | |
| url: str | |
| filename: str = "" | |
| class QueryRequest(BaseModel): | |
| trace_id: str | |
| # ---- 页面 ---- | |
| async def index(request: Request): | |
| sid, _ = get_or_create_session(request) | |
| resp = HTMLResponse(content=HTML_TEMPLATE) | |
| resp.set_cookie("sid", sid, max_age=86400 * 7, httponly=True, samesite="none", secure=True) | |
| return resp | |
| # ---- API ---- | |
| async def api_extract_filename(url: str = Query("")): | |
| return {"filename": extract_filename_from_url(url)} | |
| async def api_submit(req: SubmitRequest, request: Request): | |
| sid, task_list = get_or_create_session(request) | |
| if not req.url or not req.url.strip(): | |
| return json_resp({"success": False, "message": "请输入下载链接", "tasks": task_list}, sid) | |
| url_to_submit = req.url.strip() | |
| if not is_valid_url(url_to_submit): | |
| return json_resp({ | |
| "success": False, | |
| "message": "转存失败,请输入有效的下载链接地址", | |
| "tasks": task_list | |
| }, sid) | |
| # 检查是否已存在相同链接且处于活跃状态的任务 | |
| for task in task_list: | |
| if task.get("url") == url_to_submit and task.get("status") in ["正在转存", "未提交"]: | |
| return json_resp({ | |
| "success": False, | |
| "message": "该任务已在转存中,请耐心等待,无需重复提交。", | |
| "tasks": task_list | |
| }, sid) | |
| trigger = get_remote_processor() | |
| if not trigger: | |
| return json_resp({ | |
| "success": False, | |
| "message": "配置缺失!", | |
| "tasks": task_list | |
| }, sid) | |
| local_file = req.filename.strip() if req.filename and req.filename.strip() else extract_filename_from_url(req.url) | |
| trace_id = trigger.generate_task_id() | |
| cookie = QUARK_COOKIE | |
| inputs = { | |
| "url": req.url.strip(), | |
| "local_file": local_file, | |
| "cookie": cookie | |
| } | |
| success, resp = trigger.exec_task("upload.yml", inputs, trace_id) | |
| if not success: | |
| error_detail = "" | |
| try: | |
| error_detail = resp.text[:300] | |
| except Exception: | |
| pass | |
| return json_resp({ | |
| "success": False, | |
| "message": f"任务触发失败 (HTTP {resp.status_code})\n{error_detail}", | |
| "tasks": task_list | |
| }, sid) | |
| task = { | |
| "trace_id": trace_id, | |
| "run_id": "", | |
| "filename": local_file, | |
| "url": req.url.strip(), | |
| "status": "正在转存", | |
| "share_url": "", | |
| "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| } | |
| task_list.append(task) | |
| return json_resp({ | |
| "success": True, | |
| "message": f"转存任务已提交!\n任务 ID: {trace_id}\n文件名: {local_file}", | |
| "task_id": trace_id, | |
| "tasks": task_list | |
| }, sid) | |
| async def api_tasks(request: Request): | |
| sid, task_list = get_or_create_session(request) | |
| return json_resp({"tasks": task_list}, sid) | |
| async def api_refresh(request: Request): | |
| sid, task_list = get_or_create_session(request) | |
| trigger = get_remote_processor() | |
| if not trigger: | |
| return json_resp({"tasks": task_list, "message": "配置缺失", "all_done": True}, sid) | |
| if not task_list: | |
| return json_resp({"tasks": task_list, "message": "暂无任务", "all_done": True}, sid) | |
| count = _do_refresh(task_list, trigger) | |
| now = datetime.now().strftime("%H:%M:%S") | |
| all_done = not _has_active_tasks(task_list) | |
| if count > 0: | |
| msg = f"[{now}] 已更新 {count} 个任务的状态" | |
| else: | |
| msg = f"[{now}] 正在转存" | |
| if all_done and task_list: | |
| msg += " · 所有任务已完成" | |
| return json_resp({"tasks": task_list, "message": msg, "all_done": all_done}, sid) | |
| async def api_clear(request: Request): | |
| sid, _ = get_or_create_session(request) | |
| sessions[sid] = [] | |
| return json_resp({"success": True, "tasks": [], "message": "任务列表已清空"}, sid) | |
| async def api_query(req: QueryRequest, request: Request): | |
| sid, task_list = get_or_create_session(request) | |
| if not req.trace_id or not req.trace_id.strip(): | |
| return json_resp({"success": False, "message": "请输入任务 ID", "tasks": task_list}, sid) | |
| trigger = get_remote_processor() | |
| if not trigger: | |
| return json_resp({"success": False, "message": "配置缺失", "tasks": task_list}, sid) | |
| trace_id = req.trace_id.strip() | |
| # 在本地任务列表中查找 run_id | |
| run_id = None | |
| for task in task_list: | |
| if task.get("trace_id") == trace_id and task.get("run_id"): | |
| try: | |
| run_id = int(task["run_id"]) | |
| except (ValueError, TypeError): | |
| pass | |
| break | |
| if not run_id: | |
| run = trigger.find_task_by_task_id("upload.yml", trace_id) | |
| if not run: | |
| return json_resp({ | |
| "success": False, | |
| "message": f"未找到任务 ID: {trace_id}\n可能任务尚未被创建,请稍后再试", | |
| "tasks": task_list | |
| }, sid) | |
| run_id = run["id"] | |
| for task in task_list: | |
| if task.get("trace_id") == trace_id: | |
| task["run_id"] = str(run_id) | |
| run_data = trigger.get_task_status(run_id) | |
| if not run_data: | |
| return json_resp({ | |
| "success": False, | |
| "message": f"无法获取任务状态\n任务 ID: {trace_id}", | |
| "tasks": task_list | |
| }, sid) | |
| status = run_data.get("status") | |
| conclusion = run_data.get("conclusion") | |
| html_url = run_data.get("html_url", "") | |
| if status != "completed": | |
| status_map = { | |
| "queued": "排队中", "in_progress": "执行中", | |
| "waiting": "等待中", "requested": "已请求", "pending": "等待中" | |
| } | |
| status_cn = status_map.get(status, status) | |
| return json_resp({ | |
| "success": True, | |
| "message": f"任务正在执行中,请稍后再查询\n\n任务 ID: {trace_id}\n当前状态: {status_cn}", | |
| "tasks": task_list | |
| }, sid) | |
| if conclusion == "success": | |
| result = trigger.get_result(run_id) | |
| for task in task_list: | |
| if task.get("trace_id") == trace_id: | |
| if result and isinstance(result, dict): | |
| task["status"] = "已转存" | |
| task["share_url"] = result.get("share_url", "") | |
| else: | |
| task["status"] = "已转存" | |
| if result and isinstance(result, dict): | |
| share_url = result.get("share_url", "无") | |
| local_file = result.get("local_file", "无") | |
| result_status = result.get("status", "unknown") | |
| return json_resp({ | |
| "success": True, | |
| "message": ( | |
| f"任务已完成!\n\n" | |
| f"任务 ID: {trace_id}\n" | |
| f"状态: {result_status}\n" | |
| f"文件名: {local_file}\n" | |
| f"网盘地址: {share_url}" | |
| ), | |
| "tasks": task_list | |
| }, sid) | |
| else: | |
| return json_resp({ | |
| "success": True, | |
| "message": f"任务已完成 (结论: {conclusion})\n但未找到结果文件 (artifact)", | |
| "tasks": task_list | |
| }, sid) | |
| else: | |
| for task in task_list: | |
| if task.get("trace_id") == trace_id: | |
| task["status"] = "失败" | |
| result = trigger.get_result(run_id) | |
| error_info = "" | |
| if result and isinstance(result, dict) and "error" in result: | |
| error_info = f"\n错误信息: {result['error']}" | |
| return json_resp({ | |
| "success": False, | |
| "message": f"任务失败\n\n任务 ID: {trace_id}\n结论: {conclusion}{error_info}", | |
| "tasks": task_list | |
| }, sid) | |
| # ============================================================ | |
| # HTML 模板 | |
| # ============================================================ | |
| HTML_TEMPLATE = r"""<!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>CloudFileRelay|Online Files to Cloud Drive</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| fontFamily: { sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'] } | |
| } | |
| } | |
| } | |
| </script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background-color: #f8fafc; | |
| background-image: | |
| radial-gradient(at 0% 0%, rgba(139,92,246,0.06) 0px, transparent 50%), | |
| radial-gradient(at 100% 100%, rgba(99,102,241,0.06) 0px, transparent 50%); | |
| min-height: 100vh; | |
| } | |
| /* 自定义滚动条 */ | |
| ::-webkit-scrollbar { width: 5px; height: 5px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } | |
| /* 开关 */ | |
| .toggle-track { | |
| width: 38px; height: 20px; | |
| background: #e2e8f0; | |
| border-radius: 10px; | |
| position: relative; | |
| cursor: pointer; | |
| transition: background 0.25s ease; | |
| flex-shrink: 0; | |
| } | |
| .toggle-track.active { background: #8b5cf6; } | |
| .toggle-track::after { | |
| content: ''; | |
| position: absolute; | |
| width: 16px; height: 16px; | |
| background: #fff; | |
| border-radius: 50%; | |
| top: 2px; left: 2px; | |
| transition: transform 0.25s ease; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.12); | |
| } | |
| .toggle-track.active::after { transform: translateX(18px); } | |
| /* 渐入动画 */ | |
| @keyframes fadeInUp { | |
| from { opacity: 0; transform: translateY(12px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .anim-in { animation: fadeInUp 0.45s ease-out both; } | |
| .anim-d1 { animation-delay: .06s; } | |
| .anim-d2 { animation-delay: .12s; } | |
| .anim-d3 { animation-delay: .18s; } | |
| /* toast 滑入 */ | |
| @keyframes toastIn { | |
| from { opacity: 0; transform: translateX(30px); } | |
| to { opacity: 1; transform: translateX(0); } | |
| } | |
| .toast-in { animation: toastIn 0.3s ease-out both; } | |
| /* 按钮加载态 */ | |
| .btn-spin { | |
| pointer-events: none; opacity: .75; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .icon-spin { animation: spin .7s linear infinite; } | |
| @keyframes pulse { | |
| 0% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.4; transform: scale(0.8); } | |
| 100% { opacity: 1; transform: scale(1); } | |
| } | |
| @keyframes dots { | |
| 0%, 20% { content: '.'; } | |
| 40% { content: '..'; } | |
| 60% { content: '...'; } | |
| 80%, 100% { content: '....'; } | |
| } | |
| .dots::after { | |
| content: '.'; | |
| display: inline-block; | |
| width: 24px; | |
| text-align: left; | |
| animation: dots 2s infinite; | |
| } | |
| /* 卡片 */ | |
| .card { | |
| background: #fff; | |
| border-radius: 16px; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 24px rgba(0,0,0,0.04); | |
| border: 1px solid rgba(226,232,240,0.8); | |
| overflow: hidden; | |
| transition: box-shadow 0.25s; | |
| } | |
| .card:hover { | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.06); | |
| } | |
| /* 输入框 */ | |
| .input-field { | |
| width: 100%; padding: 10px 14px; | |
| border-radius: 10px; | |
| border: 1px solid #e2e8f0; | |
| font-size: 14px; color: #334155; | |
| transition: all .2s; | |
| outline: none; | |
| background: #fff; | |
| } | |
| .input-field::placeholder { color: #94a3b8; } | |
| .input-field:focus { | |
| border-color: #a78bfa; | |
| box-shadow: 0 0 0 3px rgba(139,92,246,0.1); | |
| } | |
| /* 主按钮 */ | |
| .btn-primary { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| padding: 10px 22px; | |
| background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); | |
| color: #fff; font-weight: 500; font-size: 14px; | |
| border: none; border-radius: 10px; | |
| cursor: pointer; | |
| box-shadow: 0 2px 8px rgba(99,102,241,0.25); | |
| transition: all .2s; | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 14px rgba(99,102,241,0.35); | |
| } | |
| .btn-primary:active { transform: translateY(0); } | |
| /* 次按钮 */ | |
| .btn-secondary { | |
| display: inline-flex; align-items: center; gap: 5px; | |
| padding: 7px 14px; | |
| background: #f1f5f9; color: #475569; | |
| font-weight: 500; font-size: 13px; | |
| border: 1px solid #e2e8f0; border-radius: 8px; | |
| cursor: pointer; transition: all .2s; | |
| } | |
| .btn-secondary:hover { background: #e2e8f0; } | |
| /* 状态徽标 */ | |
| .badge { | |
| display: inline-flex; align-items: center; gap: 5px; | |
| padding: 3px 10px; | |
| border-radius: 20px; | |
| font-size: 12px; font-weight: 500; | |
| white-space: nowrap; | |
| } | |
| .badge-dot { | |
| width: 6px; height: 6px; border-radius: 50%; | |
| flex-shrink: 0; | |
| } | |
| /* 表格 */ | |
| .task-table { width: 100%; border-collapse: collapse; } | |
| .task-table thead th { | |
| padding: 10px 14px; | |
| font-size: 11px; font-weight: 600; | |
| color: #64748b; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| text-align: left; | |
| background: #f8fafc; | |
| border-bottom: 1px solid #e2e8f0; | |
| } | |
| .task-table tbody td { | |
| padding: 12px 14px; | |
| font-size: 13px; | |
| color: #334155; | |
| border-bottom: 1px solid #f1f5f9; | |
| vertical-align: middle; | |
| } | |
| .task-table tbody tr { transition: background .15s; } | |
| .task-table tbody tr:hover { background: #f8fafc; } | |
| .task-table tbody tr:last-child td { border-bottom: none; } | |
| /* 结果框 */ | |
| .result-box { | |
| border-radius: 10px; padding: 12px 16px; | |
| font-size: 13px; line-height: 1.6; | |
| white-space: pre-wrap; word-break: break-all; | |
| } | |
| .result-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } | |
| .result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } | |
| .result-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; } | |
| /* 链接 */ | |
| a.share-link { | |
| color: #7c3aed; text-decoration: none; | |
| transition: color .15s; | |
| } | |
| a.share-link:hover { color: #6d28d9; text-decoration: underline; } | |
| /* 分页按钮 */ | |
| .btn-page { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| min-width: 32px; height: 32px; padding: 0 6px; | |
| border-radius: 8px; border: 1px solid #e2e8f0; | |
| background: #fff; color: #64748b; | |
| font-size: 13px; font-weight: 500; | |
| cursor: pointer; transition: all .2s; | |
| } | |
| .btn-page:hover:not(:disabled) { background: #f1f5f9; border-color: #cbd5e1; color: #334155; } | |
| .btn-page.active { background: #8b5cf6; border-color: #8b5cf6; color: #fff; } | |
| .btn-page:disabled { opacity: 0.4; cursor: not-allowed; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Toast 容器 --> | |
| <div id="toast-box" style="position:fixed;top:16px;right:16px;z-index:100;display:flex;flex-direction:column;gap:8px;max-width:340px;"></div> | |
| <div style="max-width:1200px;margin:0 auto;padding:32px 16px 48px;"> | |
| <!-- ====== 标题 ====== --> | |
| <div class="anim-in" style="text-align:center;margin-bottom:36px;"> | |
| <div style="display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:16px;background:linear-gradient(135deg,#8b5cf6,#6366f1);box-shadow:0 4px 16px rgba(99,102,241,0.25);margin-bottom:14px;"> | |
| <span style="font-size:24px;">📦</span> | |
| </div> | |
| <h1 style="font-size:22px;font-weight:700;color:#1e293b;margin:0 0 4px;">在线文件云端中转到你的网盘</h1> | |
| <p style="font-size:14px;color:#94a3b8;margin:0;">输入文件下载链接,自动转存到你的夸克网盘</p> | |
| </div> | |
| <!-- ====== 新建任务 ====== --> | |
| <div class="card anim-in anim-d1" style="margin-bottom:20px;"> | |
| <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;gap:8px;"> | |
| <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#ede9fe;border-radius:8px;font-size:13px;">📥</span> | |
| <span style="font-size:14px;font-weight:600;color:#334155;">新建转存任务</span> | |
| </div> | |
| <div style="padding:20px;"> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;"> | |
| <div> | |
| <label style="display:block;font-size:12px;font-weight:500;color:#64748b;margin-bottom:6px;">下载链接</label> | |
| <input id="url-input" class="input-field" placeholder="https://example.com/file.zip" /> | |
| </div> | |
| <div> | |
| <label style="display:block;font-size:12px;font-weight:500;color:#64748b;margin-bottom:6px;">文件名 <span style="color:#94a3b8;font-weight:400;">(自动提取,可修改)</span></label> | |
| <input id="filename-input" class="input-field" placeholder="自动提取" /> | |
| </div> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;"> | |
| <button id="submit-btn" class="btn-primary" onclick="submitTask()"> | |
| <svg id="submit-icon" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7"/></svg> | |
| <span id="submit-text">开始转存</span> | |
| </button> | |
| <span id="task-id-tag" style="font-size:12px;font-family:monospace;color:#a5b4c6;"></span> | |
| </div> | |
| <div id="submit-result" class="result-box" style="display:none;margin-top:14px;"></div> | |
| </div> | |
| </div> | |
| <!-- ====== 任务列表 ====== --> | |
| <div class="card anim-in anim-d2" style="margin-bottom:20px;"> | |
| <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;"> | |
| <div style="display:flex;align-items:center;gap:8px;"> | |
| <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#dbeafe;border-radius:8px;font-size:13px;">📋</span> | |
| <span style="font-size:14px;font-weight:600;color:#334155;">任务列表</span> | |
| <span id="task-count" style="font-size:11px;color:#94a3b8;font-weight:500;"></span> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:12px;"> | |
| <div style="display:flex;align-items:center;gap:6px;"> | |
| <span style="font-size:12px;color:#94a3b8;">自动刷新</span> | |
| <div id="auto-toggle" class="toggle-track" onclick="toggleAutoRefresh()"></div> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:4px;"> | |
| <input id="interval-input" type="number" value="5" min="1" max="120" | |
| class="input-field" style="width:52px;padding:5px 8px;font-size:12px;text-align:center;" | |
| onchange="onIntervalChange()"> | |
| <span style="font-size:12px;color:#94a3b8;">秒</span> | |
| </div> | |
| <button id="refresh-btn" class="btn-secondary" onclick="refreshTasks()"> | |
| <svg id="refresh-icon" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> | |
| </svg> | |
| <span>刷新</span> | |
| </button> | |
| <button id="clear-btn" class="btn-secondary" onclick="clearTasks()" style="color:#ef4444; border-color:rgba(239,68,68,0.2);"> | |
| <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> | |
| </svg> | |
| <span>清空</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div style="padding:16px 20px;"> | |
| <div id="refresh-msg" style="display:none;font-size:12px;color:#94a3b8;margin-bottom:10px;"></div> | |
| <!-- 空状态 --> | |
| <div id="empty-state" style="padding:40px 0;text-align:center;"> | |
| <div style="font-size:36px;margin-bottom:8px;opacity:.7;">📭</div> | |
| <p style="font-size:13px;color:#94a3b8;margin:0;">暂无转存任务</p> | |
| <p style="font-size:12px;color:#cbd5e1;margin:4px 0 0;">提交转存任务后,将在此处显示</p> | |
| </div> | |
| <!-- 任务表格 --> | |
| <div id="table-wrap" style="display:none;overflow-x:auto;border-radius:10px;border:1px solid #e2e8f0;"> | |
| <table class="task-table"> | |
| <thead><tr> | |
| <th style="min-width:180px;">任务 ID</th> | |
| <th style="min-width:150px;">文件名</th> | |
| <th>状态</th> | |
| <th>原始地址</th> | |
| <th style="min-width:250px;">网盘地址</th> | |
| <th style="min-width:120px;">创建时间</th> | |
| </tr></thead> | |
| <tbody id="task-tbody"></tbody> | |
| </table> | |
| </div> | |
| <!-- 分页控制 --> | |
| <div id="pagination-wrap" style="display:none;margin-top:16px;display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;"></div> | |
| </div> | |
| </div> | |
| <!-- ====== 查询任务 ====== --> | |
| <div class="card anim-in anim-d3"> | |
| <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;gap:8px;"> | |
| <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#fef3c7;border-radius:8px;font-size:13px;">🔍</span> | |
| <span style="font-size:14px;font-weight:600;color:#334155;">查询指定任务</span> | |
| </div> | |
| <div style="padding:20px;"> | |
| <div style="display:flex;gap:10px;margin-bottom:4px;"> | |
| <input id="query-input" class="input-field" style="flex:1;font-family:monospace;" placeholder="输入任务 ID,如 task_20260208_xxxxxx" /> | |
| <button id="query-btn" class="btn-secondary" onclick="queryTask()" style="white-space:nowrap;"> | |
| <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path stroke-linecap="round" d="M21 21l-4.35-4.35"/></svg> | |
| <span id="query-text">查询</span> | |
| </button> | |
| </div> | |
| <div id="query-result" class="result-box" style="display:none;margin-top:14px;"></div> | |
| </div> | |
| </div> | |
| <!-- 页脚 --> | |
| <div style="text-align:center;padding:28px 0 0;font-size:12px;color:#cbd5e1;"> | |
| Powered by 小豹 | |
| </div> | |
| </div> | |
| <script> | |
| /* ============================================================ | |
| State | |
| ============================================================ */ | |
| let autoTimer = null; | |
| let isSubmitting = false; | |
| let isRefreshing = false; | |
| let isQuerying = false; | |
| let allTasks = []; | |
| let currentPage = 1; | |
| const pageSize = 10; | |
| /* ============================================================ | |
| Utility | |
| ============================================================ */ | |
| function esc(s) { | |
| if (!s) return ''; | |
| const d = document.createElement('div'); | |
| d.textContent = s; | |
| return d.innerHTML; | |
| } | |
| function showToast(msg, type) { | |
| type = type || 'info'; | |
| const box = document.getElementById('toast-box'); | |
| const el = document.createElement('div'); | |
| const colors = { | |
| success: 'background:#f0fdf4;color:#166534;border:1px solid #bbf7d0;', | |
| error: 'background:#fef2f2;color:#991b1b;border:1px solid #fecaca;', | |
| info: 'background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;', | |
| warning: 'background:#fffbeb;color:#92400e;border:1px solid #fde68a;', | |
| }; | |
| el.className = 'toast-in'; | |
| el.style.cssText = 'padding:10px 16px;border-radius:10px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,0.08);' + (colors[type] || colors.info); | |
| el.textContent = msg; | |
| box.appendChild(el); | |
| setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(30px)'; el.style.transition = 'all .3s'; setTimeout(() => el.remove(), 300); }, 3500); | |
| } | |
| function copyToClipboard(text) { | |
| if (!text) return; | |
| navigator.clipboard.writeText(text).then(() => { | |
| showToast('地址已复制到剪贴板', 'success'); | |
| }).catch(() => { | |
| // Fallback | |
| const input = document.createElement('input'); | |
| input.value = text; | |
| document.body.appendChild(input); | |
| input.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(input); | |
| showToast('地址已复制到剪贴板', 'success'); | |
| }); | |
| } | |
| /* ============================================================ | |
| Status Badges | |
| ============================================================ */ | |
| function statusBadge(s) { | |
| const m = { | |
| '未提交': { bg:'#f1f5f9', fg:'#64748b', dot:'#94a3b8', t:'未提交' }, | |
| '正在转存': { bg:'#eff6ff', fg:'#1d4ed8', dot:'#3b82f6', t:'正在转存', pulse:true, dots:true }, | |
| '已转存': { bg:'#f0fdf4', fg:'#15803d', dot:'#22c55e', t:'已完成' }, | |
| '失败': { bg:'#fef2f2', fg:'#b91c1c', dot:'#ef4444', t:'失败' }, | |
| }; | |
| const c = m[s] || m['未提交']; | |
| const pulseStyle = c.pulse ? 'animation:pulse 1.5s ease-in-out infinite;' : ''; | |
| const dotsHtml = c.dots ? '<span class="dots"></span>' : ''; | |
| return '<span class="badge" style="background:'+c.bg+';color:'+c.fg+';">' | |
| + '<span class="badge-dot" style="background:'+c.dot+';'+pulseStyle+'"></span>' | |
| + c.t + dotsHtml + '</span>'; | |
| } | |
| /* ============================================================ | |
| Render Task Table | |
| ============================================================ */ | |
| function renderTasks(tasks, resetPage = false) { | |
| allTasks = tasks || []; | |
| if (resetPage) currentPage = 1; | |
| displayPage(currentPage); | |
| } | |
| function displayPage(page) { | |
| const tbody = document.getElementById('task-tbody'); | |
| const empty = document.getElementById('empty-state'); | |
| const wrap = document.getElementById('table-wrap'); | |
| const count = document.getElementById('task-count'); | |
| const pagin = document.getElementById('pagination-wrap'); | |
| if (!allTasks || !allTasks.length) { | |
| tbody.innerHTML = ''; | |
| empty.style.display = ''; | |
| wrap.style.display = 'none'; | |
| pagin.style.display = 'none'; | |
| count.textContent = ''; | |
| return; | |
| } | |
| const totalPages = Math.ceil(allTasks.length / pageSize); | |
| if (page < 1) page = 1; | |
| if (page > totalPages) page = totalPages; | |
| currentPage = page; | |
| empty.style.display = 'none'; | |
| wrap.style.display = ''; | |
| count.textContent = `共 ${allTasks.length} 条,第 ${currentPage}/${totalPages} 页`; | |
| const rows = [...allTasks].reverse(); | |
| const start = (currentPage - 1) * pageSize; | |
| const end = start + pageSize; | |
| const pageRows = rows.slice(start, end); | |
| tbody.innerHTML = pageRows.map(function(t) { | |
| const link = t.share_url | |
| ? '<a class="share-link whitespace-nowrap" href="'+esc(t.share_url)+'" target="_blank" rel="noopener">'+esc(t.share_url)+'</a>' | |
| : '<span style="color:#cbd5e1;">—</span>'; | |
| const originalUrlBtn = t.url | |
| ? '<button class="btn-secondary" style="padding:4px 10px;font-size:11px;" onclick="copyToClipboard(\''+esc(t.url)+'\')">复制地址</button>' | |
| : '<span style="color:#cbd5e1;">—</span>'; | |
| return '<tr>' | |
| + '<td style="font-family:monospace;font-size:12px;color:#64748b;white-space:nowrap;">'+esc(t.trace_id)+'</td>' | |
| + '<td style="font-weight:500;white-space:nowrap;">'+esc(t.filename)+'</td>' | |
| + '<td>'+statusBadge(t.status)+'</td>' | |
| + '<td>'+originalUrlBtn+'</td>' | |
| + '<td style="font-size:12px;white-space:nowrap;">'+link+'</td>' | |
| + '<td style="font-size:12px;color:#94a3b8;white-space:nowrap;">'+esc(t.created_at)+'</td>' | |
| + '</tr>'; | |
| }).join(''); | |
| renderPaginationControls(totalPages); | |
| } | |
| function renderPaginationControls(totalPages) { | |
| const pagin = document.getElementById('pagination-wrap'); | |
| if (totalPages <= 1) { | |
| pagin.style.display = 'none'; | |
| return; | |
| } | |
| pagin.style.display = 'flex'; | |
| let html = ''; | |
| html += `<button class="btn-page" ${currentPage === 1 ? 'disabled' : ''} onclick="displayPage(${currentPage - 1})">上一页</button>`; | |
| const maxButtons = 5; | |
| let startPage = Math.max(1, currentPage - 2); | |
| let endPage = Math.min(totalPages, startPage + maxButtons - 1); | |
| if (endPage - startPage < maxButtons - 1) { | |
| startPage = Math.max(1, endPage - maxButtons + 1); | |
| } | |
| if (startPage > 1) { | |
| html += `<button class="btn-page" onclick="displayPage(1)">1</button>`; | |
| if (startPage > 2) html += `<span style="color:#94a3b8;padding:0 4px;">...</span>`; | |
| } | |
| for (let i = startPage; i <= endPage; i++) { | |
| html += `<button class="btn-page ${i === currentPage ? 'active' : ''}" onclick="displayPage(${i})">${i}</button>`; | |
| } | |
| if (endPage < totalPages) { | |
| if (endPage < totalPages - 1) html += `<span style="color:#94a3b8;padding:0 4px;">...</span>`; | |
| html += `<button class="btn-page" onclick="displayPage(${totalPages})">${totalPages}</button>`; | |
| } | |
| html += `<button class="btn-page" ${currentPage === totalPages ? 'disabled' : ''} onclick="displayPage(${currentPage + 1})">下一页</button>`; | |
| pagin.innerHTML = html; | |
| } | |
| /* ============================================================ | |
| API Helper | |
| ============================================================ */ | |
| async function api(endpoint, method, body) { | |
| const sid = localStorage.getItem('sid') || ''; | |
| const opt = { | |
| method: method || 'GET', | |
| headers: { | |
| 'Content-Type':'application/json', | |
| 'X-Session-ID': sid | |
| } | |
| }; | |
| if (body) opt.body = JSON.stringify(body); | |
| const r = await fetch(endpoint, opt); | |
| const d = await r.json(); | |
| if (d.sid) localStorage.setItem('sid', d.sid); | |
| return d; | |
| } | |
| /* ============================================================ | |
| URL → 文件名自动提取 | |
| ============================================================ */ | |
| let urlTimer = null; | |
| document.getElementById('url-input').addEventListener('input', function() { | |
| clearTimeout(urlTimer); | |
| const v = this.value.trim(); | |
| if (!v) return; | |
| urlTimer = setTimeout(async function() { | |
| try { | |
| const d = await api('/api/extract-filename?url=' + encodeURIComponent(v)); | |
| if (d.filename) document.getElementById('filename-input').value = d.filename; | |
| } catch(e) {} | |
| }, 350); | |
| }); | |
| /* ============================================================ | |
| 提交任务 | |
| ============================================================ */ | |
| async function submitTask() { | |
| if (isSubmitting) return; | |
| const url = document.getElementById('url-input').value.trim(); | |
| const filename = document.getElementById('filename-input').value.trim(); | |
| if (!url) { | |
| showToast('请输入下载链接', 'warning'); | |
| return; | |
| } | |
| // 初步判断是否是有效的 URL | |
| try { | |
| const u = new URL(url); | |
| if (u.protocol !== 'http:' && u.protocol !== 'https:') { | |
| throw new Error(); | |
| } | |
| } catch (e) { | |
| const box = document.getElementById('submit-result'); | |
| box.style.display = ''; | |
| box.className = 'result-box result-error'; | |
| box.textContent = '转存失败,请输入有效的下载链接地址'; | |
| showToast('无效的下载链接', 'error'); | |
| return; | |
| } | |
| isSubmitting = true; | |
| const btn = document.getElementById('submit-btn'); | |
| const txt = document.getElementById('submit-text'); | |
| const ico = document.getElementById('submit-icon'); | |
| btn.classList.add('btn-spin'); | |
| ico.classList.add('icon-spin'); | |
| txt.textContent = '提交中…'; | |
| try { | |
| const d = await api('/api/submit', 'POST', { url: url, filename: filename }); | |
| const box = document.getElementById('submit-result'); | |
| box.style.display = ''; | |
| if (d.success) { | |
| box.className = 'result-box result-success'; | |
| box.textContent = d.message; | |
| document.getElementById('task-id-tag').textContent = d.task_id || ''; | |
| renderTasks(d.tasks, true); | |
| showToast('转存任务已提交', 'success'); | |
| // 自动开启刷新 | |
| const tog = document.getElementById('auto-toggle'); | |
| if (!tog.classList.contains('active')) { tog.classList.add('active'); startAutoRefresh(); } | |
| document.getElementById('url-input').value = ''; | |
| document.getElementById('filename-input').value = ''; | |
| } else { | |
| box.className = 'result-box result-error'; | |
| box.textContent = d.message; | |
| showToast('提交失败', 'error'); | |
| } | |
| } catch(e) { | |
| showToast('网络错误,请重试', 'error'); | |
| } finally { | |
| isSubmitting = false; | |
| btn.classList.remove('btn-spin'); | |
| ico.classList.remove('icon-spin'); | |
| txt.textContent = '开始转存'; | |
| } | |
| } | |
| /* ============================================================ | |
| 刷新任务列表 | |
| ============================================================ */ | |
| async function refreshTasks() { | |
| if (isRefreshing) return; | |
| isRefreshing = true; | |
| const ico = document.getElementById('refresh-icon'); | |
| ico.classList.add('icon-spin'); | |
| try { | |
| const d = await api('/api/refresh', 'POST'); | |
| renderTasks(d.tasks); | |
| const msg = document.getElementById('refresh-msg'); | |
| msg.style.display = ''; | |
| // 使用正则将时间部分包装在绿色 span 中 | |
| const formattedMsg = d.message.replace(/\[(\d{2}:\d{2}:\d{2})\]/, '<span class="text-green-600 font-medium">[$1]</span>'); | |
| msg.innerHTML = formattedMsg; | |
| if (d.all_done && d.tasks && d.tasks.length) { | |
| const tog = document.getElementById('auto-toggle'); | |
| tog.classList.remove('active'); | |
| stopAutoRefresh(); | |
| showToast('所有任务已完成', 'success'); | |
| } | |
| } catch(e) { | |
| showToast('刷新失败', 'error'); | |
| } finally { | |
| isRefreshing = false; | |
| ico.classList.remove('icon-spin'); | |
| } | |
| } | |
| /* ============================================================ | |
| 清空任务列表 | |
| ============================================================ */ | |
| async function clearTasks() { | |
| if (!confirm('确定要清空任务列表吗?此操作不可撤销。')) return; | |
| try { | |
| const d = await api('/api/clear', 'POST'); | |
| if (d.success) { | |
| renderTasks([], true); | |
| showToast('任务列表已清空', 'success'); | |
| document.getElementById('refresh-msg').style.display = 'none'; | |
| stopAutoRefresh(); | |
| document.getElementById('auto-toggle').classList.remove('active'); | |
| } | |
| } catch(e) { | |
| showToast('操作失败', 'error'); | |
| } | |
| } | |
| /* ============================================================ | |
| 查询指定任务 | |
| ============================================================ */ | |
| async function queryTask() { | |
| if (isQuerying) return; | |
| const tid = document.getElementById('query-input').value.trim(); | |
| if (!tid) { showToast('请输入任务 ID', 'warning'); return; } | |
| isQuerying = true; | |
| const btn = document.getElementById('query-btn'); | |
| const txt = document.getElementById('query-text'); | |
| btn.disabled = true; | |
| txt.textContent = '查询中…'; | |
| try { | |
| const d = await api('/api/query', 'POST', { trace_id: tid }); | |
| const box = document.getElementById('query-result'); | |
| box.style.display = ''; | |
| box.className = 'result-box ' + (d.success !== false ? 'result-info' : 'result-error'); | |
| box.textContent = d.message; | |
| if (d.tasks) renderTasks(d.tasks, true); | |
| } catch(e) { | |
| showToast('查询失败', 'error'); | |
| } finally { | |
| isQuerying = false; | |
| btn.disabled = false; | |
| txt.textContent = '查询'; | |
| } | |
| } | |
| /* ============================================================ | |
| 自动刷新 | |
| ============================================================ */ | |
| function startAutoRefresh() { | |
| stopAutoRefresh(); | |
| const sec = Math.max(1, parseInt(document.getElementById('interval-input').value) || 5); | |
| autoTimer = setInterval(refreshTasks, sec * 1000); | |
| } | |
| function stopAutoRefresh() { | |
| if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } | |
| } | |
| function toggleAutoRefresh() { | |
| const tog = document.getElementById('auto-toggle'); | |
| tog.classList.toggle('active'); | |
| if (tog.classList.contains('active')) { startAutoRefresh(); } else { stopAutoRefresh(); } | |
| } | |
| function onIntervalChange() { | |
| const tog = document.getElementById('auto-toggle'); | |
| if (tog.classList.contains('active')) startAutoRefresh(); | |
| } | |
| /* ============================================================ | |
| 快捷键 | |
| ============================================================ */ | |
| document.getElementById('url-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') submitTask(); }); | |
| document.getElementById('query-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') queryTask(); }); | |
| /* ============================================================ | |
| 初始化 | |
| ============================================================ */ | |
| (async function() { | |
| try { const d = await api('/api/tasks'); renderTasks(d.tasks); } catch(e) {} | |
| })(); | |
| </script> | |
| </body> | |
| </html>""" | |
| # ============================================================ | |
| # 启动 | |
| # ============================================================ | |
| if __name__ == "__main__": | |
| uvicorn.run("app:app", host="0.0.0.0", port=7860) | |