xuminglong commited on
Commit
bf48544
·
1 Parent(s): 88f57ce
Files changed (6) hide show
  1. README.md +0 -10
  2. app.py +1212 -162
  3. requirements.txt +4 -6
  4. shared.py +0 -6
  5. styles.css +0 -12
  6. tips.csv +0 -245
README.md CHANGED
@@ -9,13 +9,3 @@ license: mit
9
  short_description: 在线文件云端中转到你的网盘
10
  ---
11
 
12
- This is a templated Space for [Shiny for Python](https://shiny.rstudio.com/py/).
13
-
14
-
15
- To get started with a new app do the following:
16
-
17
- 1) Install Shiny with `pip install shiny`
18
- 2) Create a new app with `shiny create`
19
- 3) Then run the app with `shiny run --reload`
20
-
21
- To learn more about this framework please see the [Documentation](https://shiny.rstudio.com/py/docs/overview.html).
 
9
  short_description: 在线文件云端中转到你的网盘
10
  ---
11
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,162 +1,1212 @@
1
- import faicons as fa
2
- import plotly.express as px
3
-
4
- # Load data and compute static values
5
- from shared import app_dir, tips
6
- from shinywidgets import render_plotly
7
-
8
- from shiny import reactive, render
9
- from shiny.express import input, ui
10
-
11
- bill_rng = (min(tips.total_bill), max(tips.total_bill))
12
-
13
- # Add page title and sidebar
14
- ui.page_opts(title="Restaurant tipping", fillable=True)
15
-
16
- with ui.sidebar(open="desktop"):
17
- ui.input_slider(
18
- "total_bill",
19
- "Bill amount",
20
- min=bill_rng[0],
21
- max=bill_rng[1],
22
- value=bill_rng,
23
- pre="$",
24
- )
25
- ui.input_checkbox_group(
26
- "time",
27
- "Food service",
28
- ["Lunch", "Dinner"],
29
- selected=["Lunch", "Dinner"],
30
- inline=True,
31
- )
32
- ui.input_action_button("reset", "Reset filter")
33
-
34
- # Add main content
35
- ICONS = {
36
- "user": fa.icon_svg("user", "regular"),
37
- "wallet": fa.icon_svg("wallet"),
38
- "currency-dollar": fa.icon_svg("dollar-sign"),
39
- "ellipsis": fa.icon_svg("ellipsis"),
40
- }
41
-
42
- with ui.layout_columns(fill=False):
43
- with ui.value_box(showcase=ICONS["user"]):
44
- "Total tippers"
45
-
46
- @render.express
47
- def total_tippers():
48
- tips_data().shape[0]
49
-
50
- with ui.value_box(showcase=ICONS["wallet"]):
51
- "Average tip"
52
-
53
- @render.express
54
- def average_tip():
55
- d = tips_data()
56
- if d.shape[0] > 0:
57
- perc = d.tip / d.total_bill
58
- f"{perc.mean():.1%}"
59
-
60
- with ui.value_box(showcase=ICONS["currency-dollar"]):
61
- "Average bill"
62
-
63
- @render.express
64
- def average_bill():
65
- d = tips_data()
66
- if d.shape[0] > 0:
67
- bill = d.total_bill.mean()
68
- f"${bill:.2f}"
69
-
70
-
71
- with ui.layout_columns(col_widths=[6, 6, 12]):
72
- with ui.card(full_screen=True):
73
- ui.card_header("Tips data")
74
-
75
- @render.data_frame
76
- def table():
77
- return render.DataGrid(tips_data())
78
-
79
- with ui.card(full_screen=True):
80
- with ui.card_header(class_="d-flex justify-content-between align-items-center"):
81
- "Total bill vs tip"
82
- with ui.popover(title="Add a color variable", placement="top"):
83
- ICONS["ellipsis"]
84
- ui.input_radio_buttons(
85
- "scatter_color",
86
- None,
87
- ["none", "sex", "smoker", "day", "time"],
88
- inline=True,
89
- )
90
-
91
- @render_plotly
92
- def scatterplot():
93
- color = input.scatter_color()
94
- return px.scatter(
95
- tips_data(),
96
- x="total_bill",
97
- y="tip",
98
- color=None if color == "none" else color,
99
- trendline="lowess",
100
- )
101
-
102
- with ui.card(full_screen=True):
103
- with ui.card_header(class_="d-flex justify-content-between align-items-center"):
104
- "Tip percentages"
105
- with ui.popover(title="Add a color variable"):
106
- ICONS["ellipsis"]
107
- ui.input_radio_buttons(
108
- "tip_perc_y",
109
- "Split by:",
110
- ["sex", "smoker", "day", "time"],
111
- selected="day",
112
- inline=True,
113
- )
114
-
115
- @render_plotly
116
- def tip_perc():
117
- from ridgeplot import ridgeplot
118
-
119
- dat = tips_data()
120
- dat["percent"] = dat.tip / dat.total_bill
121
- yvar = input.tip_perc_y()
122
- uvals = dat[yvar].unique()
123
-
124
- samples = [[dat.percent[dat[yvar] == val]] for val in uvals]
125
-
126
- plt = ridgeplot(
127
- samples=samples,
128
- labels=uvals,
129
- bandwidth=0.01,
130
- colorscale="viridis",
131
- colormode="row-index",
132
- )
133
-
134
- plt.update_layout(
135
- legend=dict(
136
- orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5
137
- )
138
- )
139
-
140
- return plt
141
-
142
-
143
- ui.include_css(app_dir / "styles.css")
144
-
145
- # --------------------------------------------------------
146
- # Reactive calculations and effects
147
- # --------------------------------------------------------
148
-
149
-
150
- @reactive.calc
151
- def tips_data():
152
- bill = input.total_bill()
153
- idx1 = tips.total_bill.between(bill[0], bill[1])
154
- idx2 = tips.time.isin(input.time())
155
- return tips[idx1 & idx2]
156
-
157
-
158
- @reactive.effect
159
- @reactive.event(input.reset)
160
- def _():
161
- ui.update_slider("total_bill", value=bill_rng)
162
- ui.update_checkbox_group("time", selected=["Lunch", "Dinner"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 在线文件转存到网盘 - 基于 FastAPI 的现代化界面
4
+ 通过云端高速通道实现文件转存到网盘中
5
+ """
6
+
7
+ import json
8
+ import uuid
9
+ import os
10
+ import io
11
+ import zipfile
12
+ import requests
13
+ from datetime import datetime
14
+ from urllib.parse import urlparse, unquote
15
+
16
+ from fastapi import FastAPI, Request, Query
17
+ from fastapi.responses import HTMLResponse, JSONResponse
18
+ from pydantic import BaseModel
19
+ import uvicorn
20
+
21
+ DEFAULT_TOKEN_Q = ""
22
+
23
+ ACCEPT_VALUE = os.environ.get("ACCEPT_VALUE", "")
24
+ API_VERSION_KEY = os.environ.get("API_VERSION_KEY", "")
25
+ ACCESS_TOKEN = os.environ.get("ACCESS_TOKEN_G", "")
26
+ API_BASE_URL = os.environ.get("API_BASE_URL", f"")
27
+ QUARK_COOKIE = os.environ.get("ACCESS_TOKEN_Q", DEFAULT_TOKEN_Q)
28
+
29
+ ENABLE_TEST_DATA = False
30
+
31
+ def get_remote_processor():
32
+
33
+ token = ACCESS_TOKEN
34
+ base_url = API_BASE_URL
35
+
36
+ if not all([token, base_url]):
37
+ return None
38
+ return RemoteTaskProcessor(token, base_url)
39
+
40
+
41
+ class RemoteTaskProcessor:
42
+ def __init__(self, token, base_url):
43
+ self.token = token
44
+ self.base_url = base_url
45
+ self.headers = {
46
+ "Authorization": f"Bearer {token}",
47
+ "Accept": ACCEPT_VALUE,
48
+ API_VERSION_KEY: "2022-11-28"
49
+ }
50
+
51
+ def generate_task_id(self):
52
+ date_str = datetime.now().strftime("%Y%m%d_%H%M%S")
53
+ short_uuid = uuid.uuid4().hex[:12]
54
+ return f"task_{date_str}_{short_uuid}"
55
+
56
+ def exec_task(self, task_file, inputs, trace_id):
57
+ url = f"{self.base_url}/actions/workflows/{task_file}/dispatches"
58
+ dispatch_inputs = {"trace_id": trace_id, **inputs}
59
+ data = {"ref": "main", "inputs": dispatch_inputs}
60
+ response = requests.post(url, headers=self.headers, json=data)
61
+ return response.status_code == 204, response
62
+
63
+ def find_task_by_task_id(self, task_file, trace_id):
64
+ url = f"{self.base_url}/actions/workflows/{task_file}/runs"
65
+ params = {"event": "workflow_dispatch", "branch": "main", "per_page": 20}
66
+ resp = requests.get(url, headers=self.headers, params=params)
67
+ resp.raise_for_status()
68
+ runs = resp.json().get("workflow_runs", [])
69
+ for run in runs:
70
+ run_name = run.get("name", "")
71
+ display_title = run.get("display_title", "")
72
+ if trace_id in run_name or trace_id in display_title:
73
+ return run
74
+ return None
75
+
76
+ def get_task_status(self, task_id):
77
+ url = f"{self.base_url}/actions/runs/{task_id}"
78
+ response = requests.get(url, headers=self.headers)
79
+ if response.status_code != 200:
80
+ return None
81
+ return response.json()
82
+
83
+ def get_result(self, run_id, artifact_name="result"):
84
+ url = f"{self.base_url}/actions/runs/{run_id}/artifacts"
85
+ response = requests.get(url, headers=self.headers)
86
+ if response.status_code != 200:
87
+ return None
88
+ artifacts = response.json().get("artifacts", [])
89
+ target = None
90
+ for a in artifacts:
91
+ if a["name"] == artifact_name:
92
+ target = a
93
+ break
94
+ if not target:
95
+ return None
96
+ download_url = f"{self.base_url}/actions/artifacts/{target['id']}/zip"
97
+ response = requests.get(download_url, headers=self.headers)
98
+ if response.status_code != 200:
99
+ return None
100
+ try:
101
+ with zipfile.ZipFile(io.BytesIO(response.content)) as zf:
102
+ if "result.json" in zf.namelist():
103
+ return json.loads(zf.read("result.json").decode("utf-8"))
104
+ except Exception:
105
+ pass
106
+ return None
107
+
108
+ def is_valid_url(url: str) -> bool:
109
+ """初步判断是否是有效的 http/https 下载链接"""
110
+ if not url:
111
+ return False
112
+ try:
113
+ result = urlparse(url.strip())
114
+ return all([result.scheme, result.netloc]) and result.scheme in ["http", "https"]
115
+ except Exception:
116
+ return False
117
+
118
+
119
+ def extract_filename_from_url(url):
120
+
121
+ if not url or not url.strip():
122
+ return ""
123
+ try:
124
+ parsed = urlparse(url.strip())
125
+ path = unquote(parsed.path)
126
+ path = path.rstrip("/")
127
+ filename = os.path.basename(path)
128
+ if filename and "." in filename:
129
+ return filename
130
+ if filename:
131
+ return filename
132
+ except Exception:
133
+ pass
134
+ return ""
135
+
136
+
137
+
138
+
139
+ # ============================================================
140
+ # 核心刷新逻辑
141
+ # ============================================================
142
+ def _do_refresh(task_list, trigger):
143
+ updated_count = 0
144
+ for task in task_list:
145
+ if task["status"] not in ["正在转存", "未提交"]:
146
+ continue
147
+ run_id_str = task.get("run_id", "")
148
+ if not run_id_str:
149
+ run = trigger.find_task_by_task_id("upload.yml", task["trace_id"])
150
+ if run:
151
+ task["run_id"] = str(run["id"])
152
+ run_id_str = str(run["id"])
153
+ else:
154
+ continue
155
+ try:
156
+ run_data = trigger.get_task_status(int(run_id_str))
157
+ except (ValueError, TypeError):
158
+ continue
159
+ if not run_data:
160
+ continue
161
+ status = run_data.get("status")
162
+ conclusion = run_data.get("conclusion")
163
+ if status == "completed":
164
+ if conclusion == "success":
165
+ result = trigger.get_result(int(run_id_str))
166
+ if result and isinstance(result, dict):
167
+ task["status"] = "已转存"
168
+ task["share_url"] = result.get("share_url", "")
169
+ else:
170
+ task["status"] = "已转存"
171
+ else:
172
+ task["status"] = "失败"
173
+ updated_count += 1
174
+ return updated_count
175
+
176
+
177
+ def _has_active_tasks(task_list):
178
+ return any(t["status"] in ["正在转存", "未提交"] for t in task_list)
179
+
180
+
181
+ # ============================================================
182
+ # FastAPI 应用
183
+ # ============================================================
184
+ app = FastAPI(title="CloudFileRelay|Online Files to Cloud Drive")
185
+
186
+ # 会话存储
187
+ sessions: dict = {}
188
+
189
+
190
+ def get_or_create_session(request: Request) -> tuple:
191
+ sid = request.cookies.get("sid", "")
192
+ if sid and sid in sessions:
193
+ return sid, sessions[sid]
194
+ sid = uuid.uuid4().hex
195
+
196
+ test_tasks = []
197
+ if ENABLE_TEST_DATA:
198
+ # 初始化一些测试数据以展示分页效果
199
+ for i in range(45):
200
+ test_tasks.append({
201
+ "trace_id": f"test_{20240208}_{i:03d}",
202
+ "run_id": str(123456 + i),
203
+ "filename": f"测试文件_{i:03d}.mp4",
204
+ "url": f"https://example.com/file_{i:03d}.mp4",
205
+ "status": "已转存" if i % 3 != 0 else "失败",
206
+ "share_url": f"https://quark.cn/s/{"abcdefghijk"[i%11]*12}" if i % 3 != 0 else "",
207
+ "created_at": f"2026-02-08 10:{i//60:02d}:{i%60:02d}",
208
+ })
209
+
210
+ sessions[sid] = test_tasks
211
+ return sid, sessions[sid]
212
+
213
+
214
+ def json_resp(data: dict, sid: str) -> JSONResponse:
215
+ resp = JSONResponse(content=data)
216
+ resp.set_cookie("sid", sid, max_age=86400 * 7, httponly=True, samesite="lax")
217
+ return resp
218
+
219
+
220
+ # ---- 请求模型 ----
221
+ class SubmitRequest(BaseModel):
222
+ url: str
223
+ filename: str = ""
224
+
225
+
226
+ class QueryRequest(BaseModel):
227
+ trace_id: str
228
+
229
+
230
+ # ---- 页面 ----
231
+ @app.get("/", response_class=HTMLResponse)
232
+ async def index(request: Request):
233
+ sid, _ = get_or_create_session(request)
234
+ resp = HTMLResponse(content=HTML_TEMPLATE)
235
+ resp.set_cookie("sid", sid, max_age=86400 * 7, httponly=True, samesite="lax")
236
+ return resp
237
+
238
+
239
+ # ---- API ----
240
+ @app.get("/api/extract-filename")
241
+ async def api_extract_filename(url: str = Query("")):
242
+ return {"filename": extract_filename_from_url(url)}
243
+
244
+
245
+ @app.post("/api/submit")
246
+ async def api_submit(req: SubmitRequest, request: Request):
247
+ sid, task_list = get_or_create_session(request)
248
+
249
+ if not req.url or not req.url.strip():
250
+ return json_resp({"success": False, "message": "请输入下载链接", "tasks": task_list}, sid)
251
+
252
+ url_to_submit = req.url.strip()
253
+ if not is_valid_url(url_to_submit):
254
+ return json_resp({
255
+ "success": False,
256
+ "message": "转存失败,请输入有效的下载链接地址",
257
+ "tasks": task_list
258
+ }, sid)
259
+
260
+ # 检查是否已存在相同链接且处于活跃状态的任务
261
+
262
+ for task in task_list:
263
+ if task.get("url") == url_to_submit and task.get("status") in ["正在转存", "未提交"]:
264
+ return json_resp({
265
+ "success": False,
266
+ "message": "该任务已在转存中,请耐心等待,无需重复提交。",
267
+ "tasks": task_list
268
+ }, sid)
269
+
270
+ trigger = get_remote_processor()
271
+ if not trigger:
272
+ return json_resp({
273
+ "success": False,
274
+ "message": "配置缺失!",
275
+ "tasks": task_list
276
+ }, sid)
277
+
278
+ local_file = req.filename.strip() if req.filename and req.filename.strip() else extract_filename_from_url(req.url)
279
+ trace_id = trigger.generate_task_id()
280
+
281
+ cookie = QUARK_COOKIE
282
+ inputs = {
283
+ "url": req.url.strip(),
284
+ "local_file": local_file,
285
+ "cookie": cookie
286
+ }
287
+
288
+ success, resp = trigger.exec_task("upload.yml", inputs, trace_id)
289
+ if not success:
290
+ error_detail = ""
291
+ try:
292
+ error_detail = resp.text[:300]
293
+ except Exception:
294
+ pass
295
+ return json_resp({
296
+ "success": False,
297
+ "message": f"任务触发失败 (HTTP {resp.status_code})\n{error_detail}",
298
+ "tasks": task_list
299
+ }, sid)
300
+
301
+ task = {
302
+ "trace_id": trace_id,
303
+ "run_id": "",
304
+ "filename": local_file,
305
+ "url": req.url.strip(),
306
+ "status": "正在转存",
307
+ "share_url": "",
308
+ "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
309
+ }
310
+ task_list.append(task)
311
+
312
+ return json_resp({
313
+ "success": True,
314
+ "message": f"转存任务已提交!\n任务 ID: {trace_id}\n文件名: {local_file}",
315
+ "task_id": trace_id,
316
+ "tasks": task_list
317
+ }, sid)
318
+
319
+
320
+ @app.get("/api/tasks")
321
+ async def api_tasks(request: Request):
322
+ sid, task_list = get_or_create_session(request)
323
+ return json_resp({"tasks": task_list}, sid)
324
+
325
+
326
+ @app.post("/api/refresh")
327
+ async def api_refresh(request: Request):
328
+ sid, task_list = get_or_create_session(request)
329
+
330
+ trigger = get_remote_processor()
331
+ if not trigger:
332
+ return json_resp({"tasks": task_list, "message": "配置缺失", "all_done": True}, sid)
333
+
334
+ if not task_list:
335
+ return json_resp({"tasks": task_list, "message": "暂无任务", "all_done": True}, sid)
336
+
337
+ count = _do_refresh(task_list, trigger)
338
+ now = datetime.now().strftime("%H:%M:%S")
339
+ all_done = not _has_active_tasks(task_list)
340
+
341
+ if count > 0:
342
+ msg = f"[{now}] 已更新 {count} 个任务的状态"
343
+ else:
344
+ msg = f"[{now}] 正在转存"
345
+ if all_done and task_list:
346
+ msg += " · 所有任务已完成"
347
+
348
+ return json_resp({"tasks": task_list, "message": msg, "all_done": all_done}, sid)
349
+
350
+
351
+ @app.post("/api/clear")
352
+ async def api_clear(request: Request):
353
+ sid, _ = get_or_create_session(request)
354
+ sessions[sid] = []
355
+ return json_resp({"success": True, "tasks": [], "message": "任务列表已清空"}, sid)
356
+
357
+
358
+ @app.post("/api/query")
359
+ async def api_query(req: QueryRequest, request: Request):
360
+ sid, task_list = get_or_create_session(request)
361
+
362
+ if not req.trace_id or not req.trace_id.strip():
363
+ return json_resp({"success": False, "message": "请输入任务 ID", "tasks": task_list}, sid)
364
+
365
+ trigger = get_remote_processor()
366
+ if not trigger:
367
+ return json_resp({"success": False, "message": "配置缺失", "tasks": task_list}, sid)
368
+
369
+ trace_id = req.trace_id.strip()
370
+
371
+ # 在本地任务列表中查找 run_id
372
+ run_id = None
373
+ for task in task_list:
374
+ if task.get("trace_id") == trace_id and task.get("run_id"):
375
+ try:
376
+ run_id = int(task["run_id"])
377
+ except (ValueError, TypeError):
378
+ pass
379
+ break
380
+
381
+ if not run_id:
382
+ run = trigger.find_task_by_task_id("upload.yml", trace_id)
383
+ if not run:
384
+ return json_resp({
385
+ "success": False,
386
+ "message": f"未找到任务 ID: {trace_id}\n可能任务尚未被创建,请稍后再试",
387
+ "tasks": task_list
388
+ }, sid)
389
+ run_id = run["id"]
390
+ for task in task_list:
391
+ if task.get("trace_id") == trace_id:
392
+ task["run_id"] = str(run_id)
393
+
394
+ run_data = trigger.get_task_status(run_id)
395
+ if not run_data:
396
+ return json_resp({
397
+ "success": False,
398
+ "message": f"无法获取任务状态\n任务 ID: {trace_id}",
399
+ "tasks": task_list
400
+ }, sid)
401
+
402
+ status = run_data.get("status")
403
+ conclusion = run_data.get("conclusion")
404
+ html_url = run_data.get("html_url", "")
405
+
406
+ if status != "completed":
407
+ status_map = {
408
+ "queued": "排队中", "in_progress": "执行中",
409
+ "waiting": "等待中", "requested": "已请求", "pending": "等待中"
410
+ }
411
+ status_cn = status_map.get(status, status)
412
+ return json_resp({
413
+ "success": True,
414
+ "message": f"任务正在执行中,请稍后再查询\n\n任务 ID: {trace_id}\n当前状态: {status_cn}",
415
+ "tasks": task_list
416
+ }, sid)
417
+
418
+ if conclusion == "success":
419
+ result = trigger.get_result(run_id)
420
+ for task in task_list:
421
+ if task.get("trace_id") == trace_id:
422
+ if result and isinstance(result, dict):
423
+ task["status"] = "已转存"
424
+ task["share_url"] = result.get("share_url", "")
425
+ else:
426
+ task["status"] = "已转存"
427
+
428
+ if result and isinstance(result, dict):
429
+ share_url = result.get("share_url", "无")
430
+ local_file = result.get("local_file", "无")
431
+ result_status = result.get("status", "unknown")
432
+ return json_resp({
433
+ "success": True,
434
+ "message": (
435
+ f"任务已完成!\n\n"
436
+ f"任务 ID: {trace_id}\n"
437
+ f"状态: {result_status}\n"
438
+ f"文件名: {local_file}\n"
439
+ f"网盘地址: {share_url}"
440
+ ),
441
+ "tasks": task_list
442
+ }, sid)
443
+ else:
444
+ return json_resp({
445
+ "success": True,
446
+ "message": f"任务已完成 (结论: {conclusion})\n但未找到结果文件 (artifact)",
447
+ "tasks": task_list
448
+ }, sid)
449
+ else:
450
+ for task in task_list:
451
+ if task.get("trace_id") == trace_id:
452
+ task["status"] = "失败"
453
+
454
+ result = trigger.get_result(run_id)
455
+ error_info = ""
456
+ if result and isinstance(result, dict) and "error" in result:
457
+ error_info = f"\n错误信息: {result['error']}"
458
+
459
+ return json_resp({
460
+ "success": False,
461
+ "message": f"任务失败\n\n任务 ID: {trace_id}\n结论: {conclusion}{error_info}",
462
+ "tasks": task_list
463
+ }, sid)
464
+
465
+
466
+ # ============================================================
467
+ # HTML 模板
468
+ # ============================================================
469
+ HTML_TEMPLATE = r"""<!DOCTYPE html>
470
+ <html lang="zh-CN">
471
+ <head>
472
+ <meta charset="UTF-8">
473
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
474
+ <title>CloudFileRelay|Online Files to Cloud Drive</title>
475
+ <script src="https://cdn.tailwindcss.com"></script>
476
+ <script>
477
+ tailwind.config = {
478
+ theme: {
479
+ extend: {
480
+ fontFamily: { sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'] }
481
+ }
482
+ }
483
+ }
484
+ </script>
485
+ <link rel="preconnect" href="https://fonts.googleapis.com">
486
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
487
+ <style>
488
+ body {
489
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
490
+ background-color: #f8fafc;
491
+ background-image:
492
+ radial-gradient(at 0% 0%, rgba(139,92,246,0.06) 0px, transparent 50%),
493
+ radial-gradient(at 100% 100%, rgba(99,102,241,0.06) 0px, transparent 50%);
494
+ min-height: 100vh;
495
+ }
496
+
497
+ /* 自定义滚动条 */
498
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
499
+ ::-webkit-scrollbar-track { background: transparent; }
500
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
501
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
502
+
503
+ /* 开关 */
504
+ .toggle-track {
505
+ width: 38px; height: 20px;
506
+ background: #e2e8f0;
507
+ border-radius: 10px;
508
+ position: relative;
509
+ cursor: pointer;
510
+ transition: background 0.25s ease;
511
+ flex-shrink: 0;
512
+ }
513
+ .toggle-track.active { background: #8b5cf6; }
514
+ .toggle-track::after {
515
+ content: '';
516
+ position: absolute;
517
+ width: 16px; height: 16px;
518
+ background: #fff;
519
+ border-radius: 50%;
520
+ top: 2px; left: 2px;
521
+ transition: transform 0.25s ease;
522
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12);
523
+ }
524
+ .toggle-track.active::after { transform: translateX(18px); }
525
+
526
+ /* 渐入动画 */
527
+ @keyframes fadeInUp {
528
+ from { opacity: 0; transform: translateY(12px); }
529
+ to { opacity: 1; transform: translateY(0); }
530
+ }
531
+ .anim-in { animation: fadeInUp 0.45s ease-out both; }
532
+ .anim-d1 { animation-delay: .06s; }
533
+ .anim-d2 { animation-delay: .12s; }
534
+ .anim-d3 { animation-delay: .18s; }
535
+
536
+ /* toast 滑入 */
537
+ @keyframes toastIn {
538
+ from { opacity: 0; transform: translateX(30px); }
539
+ to { opacity: 1; transform: translateX(0); }
540
+ }
541
+ .toast-in { animation: toastIn 0.3s ease-out both; }
542
+
543
+ /* 按钮加载态 */
544
+ .btn-spin {
545
+ pointer-events: none; opacity: .75;
546
+ }
547
+ @keyframes spin { to { transform: rotate(360deg); } }
548
+ .icon-spin { animation: spin .7s linear infinite; }
549
+
550
+ @keyframes pulse {
551
+ 0% { opacity: 1; transform: scale(1); }
552
+ 50% { opacity: 0.4; transform: scale(0.8); }
553
+ 100% { opacity: 1; transform: scale(1); }
554
+ }
555
+
556
+ @keyframes dots {
557
+ 0%, 20% { content: '.'; }
558
+ 40% { content: '..'; }
559
+ 60% { content: '...'; }
560
+ 80%, 100% { content: '....'; }
561
+ }
562
+ .dots::after {
563
+ content: '.';
564
+ display: inline-block;
565
+ width: 24px;
566
+ text-align: left;
567
+ animation: dots 2s infinite;
568
+ }
569
+
570
+ /* 卡片 */
571
+ .card {
572
+ background: #fff;
573
+ border-radius: 16px;
574
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 24px rgba(0,0,0,0.04);
575
+ border: 1px solid rgba(226,232,240,0.8);
576
+ overflow: hidden;
577
+ transition: box-shadow 0.25s;
578
+ }
579
+ .card:hover {
580
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.06);
581
+ }
582
+
583
+ /* 输入框 */
584
+ .input-field {
585
+ width: 100%; padding: 10px 14px;
586
+ border-radius: 10px;
587
+ border: 1px solid #e2e8f0;
588
+ font-size: 14px; color: #334155;
589
+ transition: all .2s;
590
+ outline: none;
591
+ background: #fff;
592
+ }
593
+ .input-field::placeholder { color: #94a3b8; }
594
+ .input-field:focus {
595
+ border-color: #a78bfa;
596
+ box-shadow: 0 0 0 3px rgba(139,92,246,0.1);
597
+ }
598
+
599
+ /* 主按钮 */
600
+ .btn-primary {
601
+ display: inline-flex; align-items: center; gap: 6px;
602
+ padding: 10px 22px;
603
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
604
+ color: #fff; font-weight: 500; font-size: 14px;
605
+ border: none; border-radius: 10px;
606
+ cursor: pointer;
607
+ box-shadow: 0 2px 8px rgba(99,102,241,0.25);
608
+ transition: all .2s;
609
+ }
610
+ .btn-primary:hover {
611
+ transform: translateY(-1px);
612
+ box-shadow: 0 4px 14px rgba(99,102,241,0.35);
613
+ }
614
+ .btn-primary:active { transform: translateY(0); }
615
+
616
+ /* 次按钮 */
617
+ .btn-secondary {
618
+ display: inline-flex; align-items: center; gap: 5px;
619
+ padding: 7px 14px;
620
+ background: #f1f5f9; color: #475569;
621
+ font-weight: 500; font-size: 13px;
622
+ border: 1px solid #e2e8f0; border-radius: 8px;
623
+ cursor: pointer; transition: all .2s;
624
+ }
625
+ .btn-secondary:hover { background: #e2e8f0; }
626
+
627
+ /* 状态徽标 */
628
+ .badge {
629
+ display: inline-flex; align-items: center; gap: 5px;
630
+ padding: 3px 10px;
631
+ border-radius: 20px;
632
+ font-size: 12px; font-weight: 500;
633
+ white-space: nowrap;
634
+ }
635
+ .badge-dot {
636
+ width: 6px; height: 6px; border-radius: 50%;
637
+ flex-shrink: 0;
638
+ }
639
+
640
+ /* 表格 */
641
+ .task-table { width: 100%; border-collapse: collapse; }
642
+ .task-table thead th {
643
+ padding: 10px 14px;
644
+ font-size: 11px; font-weight: 600;
645
+ color: #64748b;
646
+ text-transform: uppercase;
647
+ letter-spacing: 0.05em;
648
+ text-align: left;
649
+ background: #f8fafc;
650
+ border-bottom: 1px solid #e2e8f0;
651
+ }
652
+ .task-table tbody td {
653
+ padding: 12px 14px;
654
+ font-size: 13px;
655
+ color: #334155;
656
+ border-bottom: 1px solid #f1f5f9;
657
+ vertical-align: middle;
658
+ }
659
+ .task-table tbody tr { transition: background .15s; }
660
+ .task-table tbody tr:hover { background: #f8fafc; }
661
+ .task-table tbody tr:last-child td { border-bottom: none; }
662
+
663
+ /* 结果框 */
664
+ .result-box {
665
+ border-radius: 10px; padding: 12px 16px;
666
+ font-size: 13px; line-height: 1.6;
667
+ white-space: pre-wrap; word-break: break-all;
668
+ }
669
+ .result-success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
670
+ .result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
671
+ .result-info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
672
+
673
+ /* 链接 */
674
+ a.share-link {
675
+ color: #7c3aed; text-decoration: none;
676
+ transition: color .15s;
677
+ }
678
+ a.share-link:hover { color: #6d28d9; text-decoration: underline; }
679
+
680
+ /* 分页按钮 */
681
+ .btn-page {
682
+ display: inline-flex; align-items: center; justify-content: center;
683
+ min-width: 32px; height: 32px; padding: 0 6px;
684
+ border-radius: 8px; border: 1px solid #e2e8f0;
685
+ background: #fff; color: #64748b;
686
+ font-size: 13px; font-weight: 500;
687
+ cursor: pointer; transition: all .2s;
688
+ }
689
+ .btn-page:hover:not(:disabled) { background: #f1f5f9; border-color: #cbd5e1; color: #334155; }
690
+ .btn-page.active { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
691
+ .btn-page:disabled { opacity: 0.4; cursor: not-allowed; }
692
+ </style>
693
+ </head>
694
+
695
+ <body>
696
+ <!-- Toast 容器 -->
697
+ <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>
698
+
699
+ <div style="max-width:1200px;margin:0 auto;padding:32px 16px 48px;">
700
+
701
+ <!-- ====== 标题 ====== -->
702
+ <div class="anim-in" style="text-align:center;margin-bottom:36px;">
703
+ <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;">
704
+ <span style="font-size:24px;">📦</span>
705
+ </div>
706
+ <h1 style="font-size:22px;font-weight:700;color:#1e293b;margin:0 0 4px;">在线文件云端中转到你的网盘</h1>
707
+ <p style="font-size:14px;color:#94a3b8;margin:0;">输入文件下载链接,自动转存到你的夸克网盘</p>
708
+ </div>
709
+
710
+ <!-- ====== 新建任务 ====== -->
711
+ <div class="card anim-in anim-d1" style="margin-bottom:20px;">
712
+ <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;gap:8px;">
713
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#ede9fe;border-radius:8px;font-size:13px;">📥</span>
714
+ <span style="font-size:14px;font-weight:600;color:#334155;">新建转存任务</span>
715
+ </div>
716
+ <div style="padding:20px;">
717
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
718
+ <div>
719
+ <label style="display:block;font-size:12px;font-weight:500;color:#64748b;margin-bottom:6px;">下载链接</label>
720
+ <input id="url-input" class="input-field" placeholder="https://example.com/file.zip" />
721
+ </div>
722
+ <div>
723
+ <label style="display:block;font-size:12px;font-weight:500;color:#64748b;margin-bottom:6px;">文件名 <span style="color:#94a3b8;font-weight:400;">(自动提取,可修改)</span></label>
724
+ <input id="filename-input" class="input-field" placeholder="自动提取" />
725
+ </div>
726
+ </div>
727
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">
728
+ <button id="submit-btn" class="btn-primary" onclick="submitTask()">
729
+ <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>
730
+ <span id="submit-text">开始转存</span>
731
+ </button>
732
+ <span id="task-id-tag" style="font-size:12px;font-family:monospace;color:#a5b4c6;"></span>
733
+ </div>
734
+ <div id="submit-result" class="result-box" style="display:none;margin-top:14px;"></div>
735
+ </div>
736
+ </div>
737
+
738
+ <!-- ====== 任务列表 ====== -->
739
+ <div class="card anim-in anim-d2" style="margin-bottom:20px;">
740
+ <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
741
+ <div style="display:flex;align-items:center;gap:8px;">
742
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#dbeafe;border-radius:8px;font-size:13px;">📋</span>
743
+ <span style="font-size:14px;font-weight:600;color:#334155;">任务列表</span>
744
+ <span id="task-count" style="font-size:11px;color:#94a3b8;font-weight:500;"></span>
745
+ </div>
746
+ <div style="display:flex;align-items:center;gap:12px;">
747
+ <div style="display:flex;align-items:center;gap:6px;">
748
+ <span style="font-size:12px;color:#94a3b8;">自动刷新</span>
749
+ <div id="auto-toggle" class="toggle-track" onclick="toggleAutoRefresh()"></div>
750
+ </div>
751
+ <div style="display:flex;align-items:center;gap:4px;">
752
+ <input id="interval-input" type="number" value="5" min="1" max="120"
753
+ class="input-field" style="width:52px;padding:5px 8px;font-size:12px;text-align:center;"
754
+ onchange="onIntervalChange()">
755
+ <span style="font-size:12px;color:#94a3b8;">秒</span>
756
+ </div>
757
+ <button id="refresh-btn" class="btn-secondary" onclick="refreshTasks()">
758
+ <svg id="refresh-icon" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
759
+ <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"/>
760
+ </svg>
761
+ <span>刷新</span>
762
+ </button>
763
+ <button id="clear-btn" class="btn-secondary" onclick="clearTasks()" style="color:#ef4444; border-color:rgba(239,68,68,0.2);">
764
+ <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
765
+ <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"/>
766
+ </svg>
767
+ <span>清空</span>
768
+ </button>
769
+ </div>
770
+ </div>
771
+ <div style="padding:16px 20px;">
772
+ <div id="refresh-msg" style="display:none;font-size:12px;color:#94a3b8;margin-bottom:10px;"></div>
773
+
774
+ <!-- 空状态 -->
775
+ <div id="empty-state" style="padding:40px 0;text-align:center;">
776
+ <div style="font-size:36px;margin-bottom:8px;opacity:.7;">📭</div>
777
+ <p style="font-size:13px;color:#94a3b8;margin:0;">暂无转存任务</p>
778
+ <p style="font-size:12px;color:#cbd5e1;margin:4px 0 0;">提交转存任务后,将在此处显示</p>
779
+ </div>
780
+
781
+ <!-- 任务表格 -->
782
+ <div id="table-wrap" style="display:none;overflow-x:auto;border-radius:10px;border:1px solid #e2e8f0;">
783
+ <table class="task-table">
784
+ <thead><tr>
785
+ <th style="min-width:180px;">任务 ID</th>
786
+ <th style="min-width:150px;">文件名</th>
787
+ <th>状态</th>
788
+ <th>原始地址</th>
789
+ <th style="min-width:250px;">网盘地址</th>
790
+ <th style="min-width:120px;">创建时间</th>
791
+ </tr></thead>
792
+ <tbody id="task-tbody"></tbody>
793
+ </table>
794
+ </div>
795
+
796
+ <!-- 分页控制 -->
797
+ <div id="pagination-wrap" style="display:none;margin-top:16px;display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap;"></div>
798
+ </div>
799
+ </div>
800
+
801
+ <!-- ====== 查询任务 ====== -->
802
+ <div class="card anim-in anim-d3">
803
+ <div style="padding:14px 20px;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;gap:8px;">
804
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#fef3c7;border-radius:8px;font-size:13px;">🔍</span>
805
+ <span style="font-size:14px;font-weight:600;color:#334155;">查询指定任务</span>
806
+ </div>
807
+ <div style="padding:20px;">
808
+ <div style="display:flex;gap:10px;margin-bottom:4px;">
809
+ <input id="query-input" class="input-field" style="flex:1;font-family:monospace;" placeholder="输入任务 ID,如 task_20260208_xxxxxx" />
810
+ <button id="query-btn" class="btn-secondary" onclick="queryTask()" style="white-space:nowrap;">
811
+ <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>
812
+ <span id="query-text">查询</span>
813
+ </button>
814
+ </div>
815
+ <div id="query-result" class="result-box" style="display:none;margin-top:14px;"></div>
816
+ </div>
817
+ </div>
818
+
819
+ <!-- 页脚 -->
820
+ <div style="text-align:center;padding:28px 0 0;font-size:12px;color:#cbd5e1;">
821
+ Powered by 小豹
822
+ </div>
823
+ </div>
824
+
825
+ <script>
826
+ /* ============================================================
827
+ State
828
+ ============================================================ */
829
+ let autoTimer = null;
830
+ let isSubmitting = false;
831
+ let isRefreshing = false;
832
+ let isQuerying = false;
833
+ let allTasks = [];
834
+ let currentPage = 1;
835
+ const pageSize = 10;
836
+
837
+ /* ============================================================
838
+ Utility
839
+ ============================================================ */
840
+ function esc(s) {
841
+ if (!s) return '';
842
+ const d = document.createElement('div');
843
+ d.textContent = s;
844
+ return d.innerHTML;
845
+ }
846
+
847
+ function showToast(msg, type) {
848
+ type = type || 'info';
849
+ const box = document.getElementById('toast-box');
850
+ const el = document.createElement('div');
851
+ const colors = {
852
+ success: 'background:#f0fdf4;color:#166534;border:1px solid #bbf7d0;',
853
+ error: 'background:#fef2f2;color:#991b1b;border:1px solid #fecaca;',
854
+ info: 'background:#eff6ff;color:#1e40af;border:1px solid #bfdbfe;',
855
+ warning: 'background:#fffbeb;color:#92400e;border:1px solid #fde68a;',
856
+ };
857
+ el.className = 'toast-in';
858
+ 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);
859
+ el.textContent = msg;
860
+ box.appendChild(el);
861
+ setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateX(30px)'; el.style.transition = 'all .3s'; setTimeout(() => el.remove(), 300); }, 3500);
862
+ }
863
+
864
+ function copyToClipboard(text) {
865
+ if (!text) return;
866
+ navigator.clipboard.writeText(text).then(() => {
867
+ showToast('地址已复制到剪贴板', 'success');
868
+ }).catch(() => {
869
+ // Fallback
870
+ const input = document.createElement('input');
871
+ input.value = text;
872
+ document.body.appendChild(input);
873
+ input.select();
874
+ document.execCommand('copy');
875
+ document.body.removeChild(input);
876
+ showToast('地址已复制到剪贴板', 'success');
877
+ });
878
+ }
879
+
880
+ /* ============================================================
881
+ Status Badges
882
+ ============================================================ */
883
+ function statusBadge(s) {
884
+ const m = {
885
+ '未提交': { bg:'#f1f5f9', fg:'#64748b', dot:'#94a3b8', t:'未提交' },
886
+ '正在转存': { bg:'#eff6ff', fg:'#1d4ed8', dot:'#3b82f6', t:'正在转存', pulse:true, dots:true },
887
+ '已转存': { bg:'#f0fdf4', fg:'#15803d', dot:'#22c55e', t:'已完成' },
888
+ '失败': { bg:'#fef2f2', fg:'#b91c1c', dot:'#ef4444', t:'失败' },
889
+ };
890
+ const c = m[s] || m['未提交'];
891
+ const pulseStyle = c.pulse ? 'animation:pulse 1.5s ease-in-out infinite;' : '';
892
+ const dotsHtml = c.dots ? '<span class="dots"></span>' : '';
893
+ return '<span class="badge" style="background:'+c.bg+';color:'+c.fg+';">'
894
+ + '<span class="badge-dot" style="background:'+c.dot+';'+pulseStyle+'"></span>'
895
+ + c.t + dotsHtml + '</span>';
896
+ }
897
+
898
+ /* ============================================================
899
+ Render Task Table
900
+ ============================================================ */
901
+ function renderTasks(tasks, resetPage = false) {
902
+ allTasks = tasks || [];
903
+ if (resetPage) currentPage = 1;
904
+ displayPage(currentPage);
905
+ }
906
+
907
+ function displayPage(page) {
908
+ const tbody = document.getElementById('task-tbody');
909
+ const empty = document.getElementById('empty-state');
910
+ const wrap = document.getElementById('table-wrap');
911
+ const count = document.getElementById('task-count');
912
+ const pagin = document.getElementById('pagination-wrap');
913
+
914
+ if (!allTasks || !allTasks.length) {
915
+ tbody.innerHTML = '';
916
+ empty.style.display = '';
917
+ wrap.style.display = 'none';
918
+ pagin.style.display = 'none';
919
+ count.textContent = '';
920
+ return;
921
+ }
922
+
923
+ const totalPages = Math.ceil(allTasks.length / pageSize);
924
+ if (page < 1) page = 1;
925
+ if (page > totalPages) page = totalPages;
926
+ currentPage = page;
927
+
928
+ empty.style.display = 'none';
929
+ wrap.style.display = '';
930
+ count.textContent = `共 ${allTasks.length} 条,第 ${currentPage}/${totalPages} 页`;
931
+
932
+ const rows = [...allTasks].reverse();
933
+ const start = (currentPage - 1) * pageSize;
934
+ const end = start + pageSize;
935
+ const pageRows = rows.slice(start, end);
936
+
937
+ tbody.innerHTML = pageRows.map(function(t) {
938
+ const link = t.share_url
939
+ ? '<a class="share-link whitespace-nowrap" href="'+esc(t.share_url)+'" target="_blank" rel="noopener">'+esc(t.share_url)+'</a>'
940
+ : '<span style="color:#cbd5e1;">—</span>';
941
+ const originalUrlBtn = t.url
942
+ ? '<button class="btn-secondary" style="padding:4px 10px;font-size:11px;" onclick="copyToClipboard(\''+esc(t.url)+'\')">复制地址</button>'
943
+ : '<span style="color:#cbd5e1;">—</span>';
944
+ return '<tr>'
945
+ + '<td style="font-family:monospace;font-size:12px;color:#64748b;white-space:nowrap;">'+esc(t.trace_id)+'</td>'
946
+ + '<td style="font-weight:500;white-space:nowrap;">'+esc(t.filename)+'</td>'
947
+ + '<td>'+statusBadge(t.status)+'</td>'
948
+ + '<td>'+originalUrlBtn+'</td>'
949
+ + '<td style="font-size:12px;white-space:nowrap;">'+link+'</td>'
950
+ + '<td style="font-size:12px;color:#94a3b8;white-space:nowrap;">'+esc(t.created_at)+'</td>'
951
+ + '</tr>';
952
+ }).join('');
953
+
954
+ renderPaginationControls(totalPages);
955
+ }
956
+
957
+ function renderPaginationControls(totalPages) {
958
+ const pagin = document.getElementById('pagination-wrap');
959
+ if (totalPages <= 1) {
960
+ pagin.style.display = 'none';
961
+ return;
962
+ }
963
+ pagin.style.display = 'flex';
964
+
965
+ let html = '';
966
+ html += `<button class="btn-page" ${currentPage === 1 ? 'disabled' : ''} onclick="displayPage(${currentPage - 1})">上一页</button>`;
967
+
968
+ const maxButtons = 5;
969
+ let startPage = Math.max(1, currentPage - 2);
970
+ let endPage = Math.min(totalPages, startPage + maxButtons - 1);
971
+ if (endPage - startPage < maxButtons - 1) {
972
+ startPage = Math.max(1, endPage - maxButtons + 1);
973
+ }
974
+
975
+ if (startPage > 1) {
976
+ html += `<button class="btn-page" onclick="displayPage(1)">1</button>`;
977
+ if (startPage > 2) html += `<span style="color:#94a3b8;padding:0 4px;">...</span>`;
978
+ }
979
+
980
+ for (let i = startPage; i <= endPage; i++) {
981
+ html += `<button class="btn-page ${i === currentPage ? 'active' : ''}" onclick="displayPage(${i})">${i}</button>`;
982
+ }
983
+
984
+ if (endPage < totalPages) {
985
+ if (endPage < totalPages - 1) html += `<span style="color:#94a3b8;padding:0 4px;">...</span>`;
986
+ html += `<button class="btn-page" onclick="displayPage(${totalPages})">${totalPages}</button>`;
987
+ }
988
+
989
+ html += `<button class="btn-page" ${currentPage === totalPages ? 'disabled' : ''} onclick="displayPage(${currentPage + 1})">下一页</button>`;
990
+
991
+ pagin.innerHTML = html;
992
+ }
993
+
994
+ /* ============================================================
995
+ API Helper
996
+ ============================================================ */
997
+ async function api(endpoint, method, body) {
998
+ const opt = { method: method || 'GET', headers: {'Content-Type':'application/json'} };
999
+ if (body) opt.body = JSON.stringify(body);
1000
+ const r = await fetch(endpoint, opt);
1001
+ return await r.json();
1002
+ }
1003
+
1004
+ /* ============================================================
1005
+ URL → 文件名自动提取
1006
+ ============================================================ */
1007
+ let urlTimer = null;
1008
+ document.getElementById('url-input').addEventListener('input', function() {
1009
+ clearTimeout(urlTimer);
1010
+ const v = this.value.trim();
1011
+ if (!v) return;
1012
+ urlTimer = setTimeout(async function() {
1013
+ try {
1014
+ const d = await api('/api/extract-filename?url=' + encodeURIComponent(v));
1015
+ if (d.filename) document.getElementById('filename-input').value = d.filename;
1016
+ } catch(e) {}
1017
+ }, 350);
1018
+ });
1019
+
1020
+ /* ============================================================
1021
+ 提交任务
1022
+ ============================================================ */
1023
+ async function submitTask() {
1024
+ if (isSubmitting) return;
1025
+ const url = document.getElementById('url-input').value.trim();
1026
+ const filename = document.getElementById('filename-input').value.trim();
1027
+
1028
+ if (!url) {
1029
+ showToast('请输入下载链接', 'warning');
1030
+ return;
1031
+ }
1032
+
1033
+ // 初步判断是否是有效的 URL
1034
+ try {
1035
+ const u = new URL(url);
1036
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
1037
+ throw new Error();
1038
+ }
1039
+ } catch (e) {
1040
+ const box = document.getElementById('submit-result');
1041
+ box.style.display = '';
1042
+ box.className = 'result-box result-error';
1043
+ box.textContent = '转存失败,请输入有效的下载链接地址';
1044
+ showToast('无效的下载链接', 'error');
1045
+ return;
1046
+ }
1047
+
1048
+ isSubmitting = true;
1049
+
1050
+ const btn = document.getElementById('submit-btn');
1051
+ const txt = document.getElementById('submit-text');
1052
+ const ico = document.getElementById('submit-icon');
1053
+ btn.classList.add('btn-spin');
1054
+ ico.classList.add('icon-spin');
1055
+ txt.textContent = '提交中…';
1056
+
1057
+ try {
1058
+ const d = await api('/api/submit', 'POST', { url: url, filename: filename });
1059
+ const box = document.getElementById('submit-result');
1060
+ box.style.display = '';
1061
+ if (d.success) {
1062
+ box.className = 'result-box result-success';
1063
+ box.textContent = d.message;
1064
+ document.getElementById('task-id-tag').textContent = d.task_id || '';
1065
+ renderTasks(d.tasks, true);
1066
+ showToast('转存任务已提交', 'success');
1067
+ // 自动开启刷新
1068
+ const tog = document.getElementById('auto-toggle');
1069
+ if (!tog.classList.contains('active')) { tog.classList.add('active'); startAutoRefresh(); }
1070
+ document.getElementById('url-input').value = '';
1071
+ document.getElementById('filename-input').value = '';
1072
+ } else {
1073
+ box.className = 'result-box result-error';
1074
+ box.textContent = d.message;
1075
+ showToast('提交失败', 'error');
1076
+ }
1077
+ } catch(e) {
1078
+ showToast('网络错误,请重试', 'error');
1079
+ } finally {
1080
+ isSubmitting = false;
1081
+ btn.classList.remove('btn-spin');
1082
+ ico.classList.remove('icon-spin');
1083
+ txt.textContent = '开始转存';
1084
+ }
1085
+ }
1086
+
1087
+ /* ============================================================
1088
+ 刷新任务列表
1089
+ ============================================================ */
1090
+ async function refreshTasks() {
1091
+ if (isRefreshing) return;
1092
+ isRefreshing = true;
1093
+ const ico = document.getElementById('refresh-icon');
1094
+ ico.classList.add('icon-spin');
1095
+
1096
+ try {
1097
+ const d = await api('/api/refresh', 'POST');
1098
+ renderTasks(d.tasks);
1099
+ const msg = document.getElementById('refresh-msg');
1100
+ msg.style.display = '';
1101
+
1102
+ // 使用正则将时间部分包装在绿色 span 中
1103
+ const formattedMsg = d.message.replace(/\[(\d{2}:\d{2}:\d{2})\]/, '<span class="text-green-600 font-medium">[$1]</span>');
1104
+ msg.innerHTML = formattedMsg;
1105
+
1106
+ if (d.all_done && d.tasks && d.tasks.length) {
1107
+ const tog = document.getElementById('auto-toggle');
1108
+ tog.classList.remove('active');
1109
+ stopAutoRefresh();
1110
+ showToast('所有任务已完成', 'success');
1111
+ }
1112
+ } catch(e) {
1113
+ showToast('刷新失败', 'error');
1114
+ } finally {
1115
+ isRefreshing = false;
1116
+ ico.classList.remove('icon-spin');
1117
+ }
1118
+ }
1119
+
1120
+ /* ============================================================
1121
+ 清空任务列表
1122
+ ============================================================ */
1123
+ async function clearTasks() {
1124
+ if (!confirm('确定要清空任务列表吗?此操作不可撤销。')) return;
1125
+
1126
+ try {
1127
+ const d = await api('/api/clear', 'POST');
1128
+ if (d.success) {
1129
+ renderTasks([], true);
1130
+ showToast('任务列表已清空', 'success');
1131
+ document.getElementById('refresh-msg').style.display = 'none';
1132
+ stopAutoRefresh();
1133
+ document.getElementById('auto-toggle').classList.remove('active');
1134
+ }
1135
+ } catch(e) {
1136
+ showToast('操作失败', 'error');
1137
+ }
1138
+ }
1139
+
1140
+ /* ============================================================
1141
+ 查询指定任务
1142
+ ============================================================ */
1143
+ async function queryTask() {
1144
+ if (isQuerying) return;
1145
+ const tid = document.getElementById('query-input').value.trim();
1146
+ if (!tid) { showToast('请输入任务 ID', 'warning'); return; }
1147
+
1148
+ isQuerying = true;
1149
+ const btn = document.getElementById('query-btn');
1150
+ const txt = document.getElementById('query-text');
1151
+ btn.disabled = true;
1152
+ txt.textContent = '查询中…';
1153
+
1154
+ try {
1155
+ const d = await api('/api/query', 'POST', { trace_id: tid });
1156
+ const box = document.getElementById('query-result');
1157
+ box.style.display = '';
1158
+ box.className = 'result-box ' + (d.success !== false ? 'result-info' : 'result-error');
1159
+ box.textContent = d.message;
1160
+ if (d.tasks) renderTasks(d.tasks, true);
1161
+ } catch(e) {
1162
+ showToast('查询失败', 'error');
1163
+ } finally {
1164
+ isQuerying = false;
1165
+ btn.disabled = false;
1166
+ txt.textContent = '查询';
1167
+ }
1168
+ }
1169
+
1170
+ /* ============================================================
1171
+ 自动刷新
1172
+ ============================================================ */
1173
+ function startAutoRefresh() {
1174
+ stopAutoRefresh();
1175
+ const sec = Math.max(1, parseInt(document.getElementById('interval-input').value) || 5);
1176
+ autoTimer = setInterval(refreshTasks, sec * 1000);
1177
+ }
1178
+ function stopAutoRefresh() {
1179
+ if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
1180
+ }
1181
+ function toggleAutoRefresh() {
1182
+ const tog = document.getElementById('auto-toggle');
1183
+ tog.classList.toggle('active');
1184
+ if (tog.classList.contains('active')) { startAutoRefresh(); } else { stopAutoRefresh(); }
1185
+ }
1186
+ function onIntervalChange() {
1187
+ const tog = document.getElementById('auto-toggle');
1188
+ if (tog.classList.contains('active')) startAutoRefresh();
1189
+ }
1190
+
1191
+ /* ============================================================
1192
+ 快捷键
1193
+ ============================================================ */
1194
+ document.getElementById('url-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') submitTask(); });
1195
+ document.getElementById('query-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') queryTask(); });
1196
+
1197
+ /* ============================================================
1198
+ 初始化
1199
+ ============================================================ */
1200
+ (async function() {
1201
+ try { const d = await api('/api/tasks'); renderTasks(d.tasks); } catch(e) {}
1202
+ })();
1203
+ </script>
1204
+ </body>
1205
+ </html>"""
1206
+
1207
+
1208
+ # ============================================================
1209
+ # 启动
1210
+ # ============================================================
1211
+ if __name__ == "__main__":
1212
+ uvicorn.run("app:app", host="0.0.0.0", port=7860)
requirements.txt CHANGED
@@ -1,6 +1,4 @@
1
- faicons
2
- shiny
3
- shinywidgets
4
- plotly
5
- pandas
6
- ridgeplot
 
1
+ fastapi>=0.110.0
2
+ uvicorn>=0.27.0
3
+ requests
4
+ pydantic>=2.0.0
 
 
shared.py DELETED
@@ -1,6 +0,0 @@
1
- from pathlib import Path
2
-
3
- import pandas as pd
4
-
5
- app_dir = Path(__file__).parent
6
- tips = pd.read_csv(app_dir / "tips.csv")
 
 
 
 
 
 
 
styles.css DELETED
@@ -1,12 +0,0 @@
1
- :root {
2
- --bslib-sidebar-main-bg: #f8f8f8;
3
- }
4
-
5
- .popover {
6
- --bs-popover-header-bg: #222;
7
- --bs-popover-header-color: #fff;
8
- }
9
-
10
- .popover .btn-close {
11
- filter: var(--bs-btn-close-white-filter);
12
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
tips.csv DELETED
@@ -1,245 +0,0 @@
1
- total_bill,tip,sex,smoker,day,time,size
2
- 16.99,1.01,Female,No,Sun,Dinner,2
3
- 10.34,1.66,Male,No,Sun,Dinner,3
4
- 21.01,3.5,Male,No,Sun,Dinner,3
5
- 23.68,3.31,Male,No,Sun,Dinner,2
6
- 24.59,3.61,Female,No,Sun,Dinner,4
7
- 25.29,4.71,Male,No,Sun,Dinner,4
8
- 8.77,2.0,Male,No,Sun,Dinner,2
9
- 26.88,3.12,Male,No,Sun,Dinner,4
10
- 15.04,1.96,Male,No,Sun,Dinner,2
11
- 14.78,3.23,Male,No,Sun,Dinner,2
12
- 10.27,1.71,Male,No,Sun,Dinner,2
13
- 35.26,5.0,Female,No,Sun,Dinner,4
14
- 15.42,1.57,Male,No,Sun,Dinner,2
15
- 18.43,3.0,Male,No,Sun,Dinner,4
16
- 14.83,3.02,Female,No,Sun,Dinner,2
17
- 21.58,3.92,Male,No,Sun,Dinner,2
18
- 10.33,1.67,Female,No,Sun,Dinner,3
19
- 16.29,3.71,Male,No,Sun,Dinner,3
20
- 16.97,3.5,Female,No,Sun,Dinner,3
21
- 20.65,3.35,Male,No,Sat,Dinner,3
22
- 17.92,4.08,Male,No,Sat,Dinner,2
23
- 20.29,2.75,Female,No,Sat,Dinner,2
24
- 15.77,2.23,Female,No,Sat,Dinner,2
25
- 39.42,7.58,Male,No,Sat,Dinner,4
26
- 19.82,3.18,Male,No,Sat,Dinner,2
27
- 17.81,2.34,Male,No,Sat,Dinner,4
28
- 13.37,2.0,Male,No,Sat,Dinner,2
29
- 12.69,2.0,Male,No,Sat,Dinner,2
30
- 21.7,4.3,Male,No,Sat,Dinner,2
31
- 19.65,3.0,Female,No,Sat,Dinner,2
32
- 9.55,1.45,Male,No,Sat,Dinner,2
33
- 18.35,2.5,Male,No,Sat,Dinner,4
34
- 15.06,3.0,Female,No,Sat,Dinner,2
35
- 20.69,2.45,Female,No,Sat,Dinner,4
36
- 17.78,3.27,Male,No,Sat,Dinner,2
37
- 24.06,3.6,Male,No,Sat,Dinner,3
38
- 16.31,2.0,Male,No,Sat,Dinner,3
39
- 16.93,3.07,Female,No,Sat,Dinner,3
40
- 18.69,2.31,Male,No,Sat,Dinner,3
41
- 31.27,5.0,Male,No,Sat,Dinner,3
42
- 16.04,2.24,Male,No,Sat,Dinner,3
43
- 17.46,2.54,Male,No,Sun,Dinner,2
44
- 13.94,3.06,Male,No,Sun,Dinner,2
45
- 9.68,1.32,Male,No,Sun,Dinner,2
46
- 30.4,5.6,Male,No,Sun,Dinner,4
47
- 18.29,3.0,Male,No,Sun,Dinner,2
48
- 22.23,5.0,Male,No,Sun,Dinner,2
49
- 32.4,6.0,Male,No,Sun,Dinner,4
50
- 28.55,2.05,Male,No,Sun,Dinner,3
51
- 18.04,3.0,Male,No,Sun,Dinner,2
52
- 12.54,2.5,Male,No,Sun,Dinner,2
53
- 10.29,2.6,Female,No,Sun,Dinner,2
54
- 34.81,5.2,Female,No,Sun,Dinner,4
55
- 9.94,1.56,Male,No,Sun,Dinner,2
56
- 25.56,4.34,Male,No,Sun,Dinner,4
57
- 19.49,3.51,Male,No,Sun,Dinner,2
58
- 38.01,3.0,Male,Yes,Sat,Dinner,4
59
- 26.41,1.5,Female,No,Sat,Dinner,2
60
- 11.24,1.76,Male,Yes,Sat,Dinner,2
61
- 48.27,6.73,Male,No,Sat,Dinner,4
62
- 20.29,3.21,Male,Yes,Sat,Dinner,2
63
- 13.81,2.0,Male,Yes,Sat,Dinner,2
64
- 11.02,1.98,Male,Yes,Sat,Dinner,2
65
- 18.29,3.76,Male,Yes,Sat,Dinner,4
66
- 17.59,2.64,Male,No,Sat,Dinner,3
67
- 20.08,3.15,Male,No,Sat,Dinner,3
68
- 16.45,2.47,Female,No,Sat,Dinner,2
69
- 3.07,1.0,Female,Yes,Sat,Dinner,1
70
- 20.23,2.01,Male,No,Sat,Dinner,2
71
- 15.01,2.09,Male,Yes,Sat,Dinner,2
72
- 12.02,1.97,Male,No,Sat,Dinner,2
73
- 17.07,3.0,Female,No,Sat,Dinner,3
74
- 26.86,3.14,Female,Yes,Sat,Dinner,2
75
- 25.28,5.0,Female,Yes,Sat,Dinner,2
76
- 14.73,2.2,Female,No,Sat,Dinner,2
77
- 10.51,1.25,Male,No,Sat,Dinner,2
78
- 17.92,3.08,Male,Yes,Sat,Dinner,2
79
- 27.2,4.0,Male,No,Thur,Lunch,4
80
- 22.76,3.0,Male,No,Thur,Lunch,2
81
- 17.29,2.71,Male,No,Thur,Lunch,2
82
- 19.44,3.0,Male,Yes,Thur,Lunch,2
83
- 16.66,3.4,Male,No,Thur,Lunch,2
84
- 10.07,1.83,Female,No,Thur,Lunch,1
85
- 32.68,5.0,Male,Yes,Thur,Lunch,2
86
- 15.98,2.03,Male,No,Thur,Lunch,2
87
- 34.83,5.17,Female,No,Thur,Lunch,4
88
- 13.03,2.0,Male,No,Thur,Lunch,2
89
- 18.28,4.0,Male,No,Thur,Lunch,2
90
- 24.71,5.85,Male,No,Thur,Lunch,2
91
- 21.16,3.0,Male,No,Thur,Lunch,2
92
- 28.97,3.0,Male,Yes,Fri,Dinner,2
93
- 22.49,3.5,Male,No,Fri,Dinner,2
94
- 5.75,1.0,Female,Yes,Fri,Dinner,2
95
- 16.32,4.3,Female,Yes,Fri,Dinner,2
96
- 22.75,3.25,Female,No,Fri,Dinner,2
97
- 40.17,4.73,Male,Yes,Fri,Dinner,4
98
- 27.28,4.0,Male,Yes,Fri,Dinner,2
99
- 12.03,1.5,Male,Yes,Fri,Dinner,2
100
- 21.01,3.0,Male,Yes,Fri,Dinner,2
101
- 12.46,1.5,Male,No,Fri,Dinner,2
102
- 11.35,2.5,Female,Yes,Fri,Dinner,2
103
- 15.38,3.0,Female,Yes,Fri,Dinner,2
104
- 44.3,2.5,Female,Yes,Sat,Dinner,3
105
- 22.42,3.48,Female,Yes,Sat,Dinner,2
106
- 20.92,4.08,Female,No,Sat,Dinner,2
107
- 15.36,1.64,Male,Yes,Sat,Dinner,2
108
- 20.49,4.06,Male,Yes,Sat,Dinner,2
109
- 25.21,4.29,Male,Yes,Sat,Dinner,2
110
- 18.24,3.76,Male,No,Sat,Dinner,2
111
- 14.31,4.0,Female,Yes,Sat,Dinner,2
112
- 14.0,3.0,Male,No,Sat,Dinner,2
113
- 7.25,1.0,Female,No,Sat,Dinner,1
114
- 38.07,4.0,Male,No,Sun,Dinner,3
115
- 23.95,2.55,Male,No,Sun,Dinner,2
116
- 25.71,4.0,Female,No,Sun,Dinner,3
117
- 17.31,3.5,Female,No,Sun,Dinner,2
118
- 29.93,5.07,Male,No,Sun,Dinner,4
119
- 10.65,1.5,Female,No,Thur,Lunch,2
120
- 12.43,1.8,Female,No,Thur,Lunch,2
121
- 24.08,2.92,Female,No,Thur,Lunch,4
122
- 11.69,2.31,Male,No,Thur,Lunch,2
123
- 13.42,1.68,Female,No,Thur,Lunch,2
124
- 14.26,2.5,Male,No,Thur,Lunch,2
125
- 15.95,2.0,Male,No,Thur,Lunch,2
126
- 12.48,2.52,Female,No,Thur,Lunch,2
127
- 29.8,4.2,Female,No,Thur,Lunch,6
128
- 8.52,1.48,Male,No,Thur,Lunch,2
129
- 14.52,2.0,Female,No,Thur,Lunch,2
130
- 11.38,2.0,Female,No,Thur,Lunch,2
131
- 22.82,2.18,Male,No,Thur,Lunch,3
132
- 19.08,1.5,Male,No,Thur,Lunch,2
133
- 20.27,2.83,Female,No,Thur,Lunch,2
134
- 11.17,1.5,Female,No,Thur,Lunch,2
135
- 12.26,2.0,Female,No,Thur,Lunch,2
136
- 18.26,3.25,Female,No,Thur,Lunch,2
137
- 8.51,1.25,Female,No,Thur,Lunch,2
138
- 10.33,2.0,Female,No,Thur,Lunch,2
139
- 14.15,2.0,Female,No,Thur,Lunch,2
140
- 16.0,2.0,Male,Yes,Thur,Lunch,2
141
- 13.16,2.75,Female,No,Thur,Lunch,2
142
- 17.47,3.5,Female,No,Thur,Lunch,2
143
- 34.3,6.7,Male,No,Thur,Lunch,6
144
- 41.19,5.0,Male,No,Thur,Lunch,5
145
- 27.05,5.0,Female,No,Thur,Lunch,6
146
- 16.43,2.3,Female,No,Thur,Lunch,2
147
- 8.35,1.5,Female,No,Thur,Lunch,2
148
- 18.64,1.36,Female,No,Thur,Lunch,3
149
- 11.87,1.63,Female,No,Thur,Lunch,2
150
- 9.78,1.73,Male,No,Thur,Lunch,2
151
- 7.51,2.0,Male,No,Thur,Lunch,2
152
- 14.07,2.5,Male,No,Sun,Dinner,2
153
- 13.13,2.0,Male,No,Sun,Dinner,2
154
- 17.26,2.74,Male,No,Sun,Dinner,3
155
- 24.55,2.0,Male,No,Sun,Dinner,4
156
- 19.77,2.0,Male,No,Sun,Dinner,4
157
- 29.85,5.14,Female,No,Sun,Dinner,5
158
- 48.17,5.0,Male,No,Sun,Dinner,6
159
- 25.0,3.75,Female,No,Sun,Dinner,4
160
- 13.39,2.61,Female,No,Sun,Dinner,2
161
- 16.49,2.0,Male,No,Sun,Dinner,4
162
- 21.5,3.5,Male,No,Sun,Dinner,4
163
- 12.66,2.5,Male,No,Sun,Dinner,2
164
- 16.21,2.0,Female,No,Sun,Dinner,3
165
- 13.81,2.0,Male,No,Sun,Dinner,2
166
- 17.51,3.0,Female,Yes,Sun,Dinner,2
167
- 24.52,3.48,Male,No,Sun,Dinner,3
168
- 20.76,2.24,Male,No,Sun,Dinner,2
169
- 31.71,4.5,Male,No,Sun,Dinner,4
170
- 10.59,1.61,Female,Yes,Sat,Dinner,2
171
- 10.63,2.0,Female,Yes,Sat,Dinner,2
172
- 50.81,10.0,Male,Yes,Sat,Dinner,3
173
- 15.81,3.16,Male,Yes,Sat,Dinner,2
174
- 7.25,5.15,Male,Yes,Sun,Dinner,2
175
- 31.85,3.18,Male,Yes,Sun,Dinner,2
176
- 16.82,4.0,Male,Yes,Sun,Dinner,2
177
- 32.9,3.11,Male,Yes,Sun,Dinner,2
178
- 17.89,2.0,Male,Yes,Sun,Dinner,2
179
- 14.48,2.0,Male,Yes,Sun,Dinner,2
180
- 9.6,4.0,Female,Yes,Sun,Dinner,2
181
- 34.63,3.55,Male,Yes,Sun,Dinner,2
182
- 34.65,3.68,Male,Yes,Sun,Dinner,4
183
- 23.33,5.65,Male,Yes,Sun,Dinner,2
184
- 45.35,3.5,Male,Yes,Sun,Dinner,3
185
- 23.17,6.5,Male,Yes,Sun,Dinner,4
186
- 40.55,3.0,Male,Yes,Sun,Dinner,2
187
- 20.69,5.0,Male,No,Sun,Dinner,5
188
- 20.9,3.5,Female,Yes,Sun,Dinner,3
189
- 30.46,2.0,Male,Yes,Sun,Dinner,5
190
- 18.15,3.5,Female,Yes,Sun,Dinner,3
191
- 23.1,4.0,Male,Yes,Sun,Dinner,3
192
- 15.69,1.5,Male,Yes,Sun,Dinner,2
193
- 19.81,4.19,Female,Yes,Thur,Lunch,2
194
- 28.44,2.56,Male,Yes,Thur,Lunch,2
195
- 15.48,2.02,Male,Yes,Thur,Lunch,2
196
- 16.58,4.0,Male,Yes,Thur,Lunch,2
197
- 7.56,1.44,Male,No,Thur,Lunch,2
198
- 10.34,2.0,Male,Yes,Thur,Lunch,2
199
- 43.11,5.0,Female,Yes,Thur,Lunch,4
200
- 13.0,2.0,Female,Yes,Thur,Lunch,2
201
- 13.51,2.0,Male,Yes,Thur,Lunch,2
202
- 18.71,4.0,Male,Yes,Thur,Lunch,3
203
- 12.74,2.01,Female,Yes,Thur,Lunch,2
204
- 13.0,2.0,Female,Yes,Thur,Lunch,2
205
- 16.4,2.5,Female,Yes,Thur,Lunch,2
206
- 20.53,4.0,Male,Yes,Thur,Lunch,4
207
- 16.47,3.23,Female,Yes,Thur,Lunch,3
208
- 26.59,3.41,Male,Yes,Sat,Dinner,3
209
- 38.73,3.0,Male,Yes,Sat,Dinner,4
210
- 24.27,2.03,Male,Yes,Sat,Dinner,2
211
- 12.76,2.23,Female,Yes,Sat,Dinner,2
212
- 30.06,2.0,Male,Yes,Sat,Dinner,3
213
- 25.89,5.16,Male,Yes,Sat,Dinner,4
214
- 48.33,9.0,Male,No,Sat,Dinner,4
215
- 13.27,2.5,Female,Yes,Sat,Dinner,2
216
- 28.17,6.5,Female,Yes,Sat,Dinner,3
217
- 12.9,1.1,Female,Yes,Sat,Dinner,2
218
- 28.15,3.0,Male,Yes,Sat,Dinner,5
219
- 11.59,1.5,Male,Yes,Sat,Dinner,2
220
- 7.74,1.44,Male,Yes,Sat,Dinner,2
221
- 30.14,3.09,Female,Yes,Sat,Dinner,4
222
- 12.16,2.2,Male,Yes,Fri,Lunch,2
223
- 13.42,3.48,Female,Yes,Fri,Lunch,2
224
- 8.58,1.92,Male,Yes,Fri,Lunch,1
225
- 15.98,3.0,Female,No,Fri,Lunch,3
226
- 13.42,1.58,Male,Yes,Fri,Lunch,2
227
- 16.27,2.5,Female,Yes,Fri,Lunch,2
228
- 10.09,2.0,Female,Yes,Fri,Lunch,2
229
- 20.45,3.0,Male,No,Sat,Dinner,4
230
- 13.28,2.72,Male,No,Sat,Dinner,2
231
- 22.12,2.88,Female,Yes,Sat,Dinner,2
232
- 24.01,2.0,Male,Yes,Sat,Dinner,4
233
- 15.69,3.0,Male,Yes,Sat,Dinner,3
234
- 11.61,3.39,Male,No,Sat,Dinner,2
235
- 10.77,1.47,Male,No,Sat,Dinner,2
236
- 15.53,3.0,Male,Yes,Sat,Dinner,2
237
- 10.07,1.25,Male,No,Sat,Dinner,2
238
- 12.6,1.0,Male,Yes,Sat,Dinner,2
239
- 32.83,1.17,Male,Yes,Sat,Dinner,2
240
- 35.83,4.67,Female,No,Sat,Dinner,3
241
- 29.03,5.92,Male,No,Sat,Dinner,3
242
- 27.18,2.0,Female,Yes,Sat,Dinner,2
243
- 22.67,2.0,Male,Yes,Sat,Dinner,2
244
- 17.82,1.75,Male,No,Sat,Dinner,2
245
- 18.78,3.0,Female,No,Thur,Dinner,2