From a4606f04a3b0421eac69401f8c2ff5bf10613fe8 Mon Sep 17 00:00:00 2001 From: Codebuddy Date: Mon, 14 Sep 2026 10:07:06 +0800 Subject: [PATCH] =?utf8?q?feat:=20=E6=96=B0=E5=A2=9E=20/list=20=E5=BF=AB?= =?utf8?q?=E6=8D=B7=E9=93=BE=E6=8E=A5=E9=85=8D=E7=BD=AE=E9=A1=B5=EF=BC=88U?= =?utf8?q?RL=20=E6=89=8B=E5=B7=A5=E5=A2=9E=E5=88=A0=E6=94=B9=E3=80=81?= =?utf8?q?=E6=96=B0=E6=A0=87=E7=AD=BE=E6=89=93=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 后端: 新增 data/urls.json 读写(load_urls/save_urls),CRUD 路由 GET/POST/PUT/DELETE /api/urls(_auth_ok 鉴权),新增 GET /list 返回 SPA 外壳 - 前端: 顶栏「🔗 链接」Icon(位于重启会话右侧)同标签跳转 /list; /list 渲染链接列表,链接 target=_blank 新标签打开; 新增/编辑 modal(name+url),删除带确认;列表用 textContent 防 XSS - .gitignore: 将 data/urls.json 纳入运行时用户数据忽略项 --- .gitignore | 3 + backend/app.py | 96 +++++++++++++++++++- frontend/index.html | 209 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 307 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 43d86fb..2cc51c4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ logs/*.log # Runtime state: task registry (session IDs, last-used timestamps) data/tasks.json +# Runtime state: user-configured quick links (name + URL entries) +data/urls.json + # Runtime state: pasted screenshots uploaded via web console data/pastes/ diff --git a/backend/app.py b/backend/app.py index efb66ab..db1013d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -247,6 +247,24 @@ def save_tasks(data: dict) -> None: os.replace(tmp, TASKS_FILE) +def load_urls() -> list: + if not os.path.isfile(URLS_FILE): + return [] + try: + with open(URLS_FILE, "r", encoding="utf-8") as f: + d = json.load(f) + return d if isinstance(d, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + +def save_urls(urls: list) -> None: + tmp = str(URLS_FILE) + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(urls, f, ensure_ascii=False, indent=2) + os.replace(tmp, URLS_FILE) + + def seed_tasks() -> dict: """首次运行:预置 words 任务。""" now = now_iso() @@ -541,6 +559,12 @@ async def index(): return HTMLResponse(load_html(), headers={"Cache-Control": "no-store"}) +@app.get("/list") +async def list_page(): + # 快捷链接配置页:同一 SPA 外壳,前端按 pathname 渲染对应页面 + return HTMLResponse(load_html(), headers={"Cache-Control": "no-store"}) + + # 站点图标(黑白灰终端提示符风格 SVG),供浏览器标签页 / 收藏夹使用 FAVICON_FILE = FRONTEND_DIR / "favicon.svg" @app.get("/favicon.svg") @@ -793,7 +817,75 @@ async def api_update_task(task_id: str, request: Request): return JSONResponse({"ok": True, "task": t}) -@app.delete("/api/tasks/{task_id}") +# ==================== 快捷链接(URL 配置) ==================== +@app.get("/api/urls") +async def api_list_urls(request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + return JSONResponse({"ok": True, "urls": load_urls()}) + + +@app.post("/api/urls") +async def api_create_url(request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "无效的请求体"}, status_code=400) + name = (body.get("name") or "").strip() + url = (body.get("url") or "").strip() + if not name: + return JSONResponse({"ok": False, "error": "名称不能为空"}, status_code=400) + if not url: + return JSONResponse({"ok": False, "error": "URL 不能为空"}, status_code=400) + urls = load_urls() + item = {"id": gen_id(), "name": name, "url": url} + urls.append(item) + save_urls(urls) + logger.info(f"新增快捷链接: name={name}, url={url}") + return JSONResponse({"ok": True, "url": item}) + + +@app.put("/api/urls/{url_id}") +async def api_update_url(url_id: str, request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "无效的请求体"}, status_code=400) + name = (body.get("name") or "").strip() + url = (body.get("url") or "").strip() + if not name: + return JSONResponse({"ok": False, "error": "名称不能为空"}, status_code=400) + if not url: + return JSONResponse({"ok": False, "error": "URL 不能为空"}, status_code=400) + urls = load_urls() + item = next((u for u in urls if u.get("id") == url_id), None) + if not item: + return JSONResponse({"ok": False, "error": "链接不存在"}, status_code=404) + item["name"] = name + item["url"] = url + save_urls(urls) + logger.info(f"更新快捷链接: id={url_id}, name={name}") + return JSONResponse({"ok": True, "url": item}) + + +@app.delete("/api/urls/{url_id}") +async def api_delete_url(url_id: str, request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + urls = load_urls() + if not any(u.get("id") == url_id for u in urls): + return JSONResponse({"ok": False, "error": "链接不存在"}, status_code=404) + urls = [u for u in urls if u.get("id") != url_id] + save_urls(urls) + logger.info(f"删除快捷链接: id={url_id}") + return JSONResponse({"ok": True}) + + + async def api_delete_task(task_id: str, request: Request): if not _auth_ok(request): return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) @@ -991,6 +1083,8 @@ _MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", # 上传文件 → 所属会话任务的索引(前端在终端页上传时带上 task_id) UPLOADS_INDEX = Path(__file__).resolve().parent.parent / "data" / "uploads_index.json" +# 快捷链接(URL 配置):名称 + URL,存于 data/urls.json +URLS_FILE = Path(__file__).resolve().parent.parent / "data" / "urls.json" def load_uploads_index() -> dict: diff --git a/frontend/index.html b/frontend/index.html index cadbd49..4b6a2ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -268,6 +268,32 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh .picker-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #aaa; cursor: pointer; user-select: none; } .picker-toggle input { cursor: pointer; } +/* 快捷链接页 */ +.url-body { flex:1; min-height:0; overflow:auto; padding:24px; } +.url-toolbar { max-width:900px; margin:0 auto 18px; display:flex; justify-content:flex-end; } +.url-list { display:flex; flex-direction:column; gap:12px; max-width:900px; margin:0 auto; } +.url-row { + display:flex; align-items:center; gap:14px; + background:#1a1a2e; border:1px solid rgba(255,255,255,0.12); border-radius:12px; padding:14px 16px; +} +.url-row:hover { border-color:rgba(78,204,163,0.4); } +.url-row .ur-name { font-size:15px; font-weight:600; color:#e0e0e0; min-width:120px; } +.url-row .ur-link { flex:1; min-width:0; color:#4ecca3; text-decoration:none; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14px; } +.url-row .ur-link:hover { color:#6fd9b4; text-decoration:underline; } +.url-row .ur-actions { display:flex; gap:8px; flex-shrink:0; } +.url-row .ur-btn { + background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.15); border-radius:6px; + color:#ccc; font-size:13px; padding:6px 12px; cursor:pointer; +} +.url-row .ur-btn:hover { background:rgba(78,204,163,0.14); color:#fff; } +.url-row .ur-del:hover { background:rgba(255,107,107,0.22); color:#fff; border-color:rgba(255,107,107,0.4); } +.up-dialog .tc-input { + width:100%; box-sizing:border-box; background:#0f0f1e; color:#e0e0e0; + border:1px solid rgba(255,255,255,0.2); border-radius:6px; padding:8px 10px; + font-size:14px; font-family:inherit; outline:none; +} +.up-dialog .tc-input:focus { border-color:rgba(78,204,163,0.5); } + /* 删除确认 modal */ #del-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center; } #del-modal .del-dialog { background:#1a1a2e; border:1px solid #ff6b6b; border-radius:12px; padding:24px; width:440px; max-width:90vw; text-align:center; } @@ -406,6 +432,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
+ @@ -473,6 +500,50 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
+ + + + + +