]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 新增 /list 快捷链接配置页(URL 手工增删改、新标签打开)
authorCodebuddy <codebuddy@localhost>
Mon, 14 Sep 2026 02:07:06 +0000 (10:07 +0800)
committerCodebuddy <codebuddy@localhost>
Mon, 14 Sep 2026 02:07:06 +0000 (10:07 +0800)
- 后端: 新增 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
backend/app.py
frontend/index.html

index 43d86fbd6971cf0443153869acc4336b5086d1b1..2cc51c4b53c3b8f8cec22821e1cdd254a23ec1fa 100644 (file)
@@ -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/
 
index efb66abb8d690546c1682d032500646f4b413ac4..db1013de7581cbdc9885c8f81b622af1ca848d33 100644 (file)
@@ -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:
index cadbd496154e8ce264159f98656c7c5ddb0bf0cd..4b6a2acb7fba77665527c6e63cbe66054d4e2356 100644 (file)
@@ -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
     </div>
     <div class="actions">
       <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
+      <button class="action-btn" id="btn-urls" title="快捷链接配置">🔗 链接</button>
       <button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
       <button class="action-btn" id="btn-paste" title="粘贴文字或图片">📋 粘贴</button>
       <button class="action-btn" id="btn-skills" title="管理自定义 skill">🧩 技能</button>
@@ -473,6 +500,50 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   </div>
 </div>
 
+<!-- 快捷链接配置页 -->
+<div id="url-page" style="display:none; height:100%; flex-direction:column;">
+  <div class="top-bar">
+    <div class="left">
+      <span class="task-name">🔗 快捷链接</span>
+    </div>
+    <div class="actions">
+      <button class="action-btn" id="btn-url-back" title="返回列表">← 返回</button>
+    </div>
+  </div>
+  <div class="url-body">
+    <div class="url-toolbar">
+      <button class="send-btn" id="btn-url-new">+ 新增链接</button>
+    </div>
+    <div id="url-list" class="url-list"></div>
+    <div id="url-empty" class="picker-empty" style="display:none;">暂无快捷链接,点击「+ 新增链接」添加。</div>
+  </div>
+</div>
+
+<!-- 快捷链接 新增/编辑 modal -->
+<div id="url-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:300; align-items:center; justify-content:center;">
+  <div class="up-dialog" style="width:480px; max-width:90vw;">
+    <div class="up-head">
+      <div class="up-title" id="url-modal-title">新增链接</div>
+      <button class="up-close" id="url-modal-close">✕</button>
+    </div>
+    <div style="padding:16px; display:flex; flex-direction:column; gap:12px;">
+      <div>
+        <div style="color:#aaa; font-size:12px; margin-bottom:4px;">名称</div>
+        <input id="url-name-input" class="tc-input" maxlength="40" placeholder="如:官网 / 文档">
+      </div>
+      <div>
+        <div style="color:#aaa; font-size:12px; margin-bottom:4px;">URL</div>
+        <input id="url-url-input" class="tc-input" placeholder="https://...">
+      </div>
+      <div id="url-modal-error" style="color:#ff6b6b; font-size:12px; min-height:14px;"></div>
+    </div>
+    <div style="display:flex; gap:10px; justify-content:flex-end; padding:0 16px 16px;">
+      <button class="del-btn del-cancel" id="url-modal-cancel">取消</button>
+      <button class="send-btn" id="url-modal-save">保存</button>
+    </div>
+  </div>
+</div>
+
 <!-- 已上传文件 modal(列表页“已上传文件”按钮触发) -->
 <div id="uploads-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:300; align-items:center; justify-content:center;">
   <div class="up-dialog">
@@ -549,6 +620,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   const loginPage = document.getElementById('login-page');
   const pickerPage = document.getElementById('picker-page');
   const terminalPage = document.getElementById('terminal-page');
+  const urlPage = document.getElementById('url-page');
   const passwordInput = document.getElementById('password-input');
   const loginBtn = document.getElementById('login-btn');
   const loginError = document.getElementById('login-error');
@@ -561,6 +633,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
     loginPage.style.display = 'none';
     pickerPage.style.display = 'none';
     terminalPage.style.display = 'none';
+    urlPage.style.display = 'none';
     if (page === 'login') { loginPage.style.display = 'flex'; passwordInput.focus(); }
     else if (page === 'picker') { pickerPage.style.display = 'flex'; startPickerRefresh(); }
     else if (page === 'terminal') {
@@ -568,6 +641,10 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
       // 显示后重新 fit 并聚焦,避免重连后需要先按一次回车才能输入
       setTimeout(() => { try { fitAddon && fitAddon.fit(); } catch (e) {} try { term && term.focus(); } catch (e) {} }, 30);
     }
+    else if (page === 'url') {
+      urlPage.style.display = 'flex';
+      document.title = '快捷链接 - CLI';
+    }
   }
 
   function authHeaders() { return { 'Authorization': 'Bearer ' + authToken }; }
@@ -616,6 +693,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   document.getElementById('btn-logout-picker').onclick = logout;
 
   async function enterApp() {
+    if (location.pathname === '/list') { await showUrlPage(); return; }
     if (MODE === 'terminal') await showTerminalMode();
     else await showPicker();
   }
@@ -635,6 +713,127 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
     await renderSkillsSidebar();
   }
 
+  // ========== 快捷链接配置页 ==========
+  let urlsCache = [];        // 当前列表缓存,供编辑时回填
+  let editingUrlId = null;   // 正在编辑的链接 id;null 表示新增
+
+  async function showUrlPage() {
+    show('url');
+    await renderUrls();
+  }
+
+  async function renderUrls() {
+    const listEl = document.getElementById('url-list');
+    const emptyEl = document.getElementById('url-empty');
+    try {
+      const res = await fetch(API + '/api/urls', { headers: authHeaders() });
+      if (!res.ok) { if (res.status === 401) show('login'); return; }
+      const data = await res.json();
+      const urls = Array.isArray(data.urls) ? data.urls : [];
+      urlsCache = urls;
+      listEl.innerHTML = '';
+      if (urls.length === 0) { emptyEl.style.display = 'block'; return; }
+      emptyEl.style.display = 'none';
+      urls.forEach(u => {
+        const row = document.createElement('div');
+        row.className = 'url-row';
+
+        const name = document.createElement('span');
+        name.className = 'ur-name';
+        name.textContent = u.name || '';
+
+        const link = document.createElement('a');
+        link.className = 'ur-link';
+        link.href = u.url || '#';
+        link.target = '_blank';
+        link.rel = 'noopener noreferrer';
+        link.textContent = u.url || '';
+
+        const actions = document.createElement('div');
+        actions.className = 'ur-actions';
+
+        const editBtn = document.createElement('button');
+        editBtn.className = 'ur-btn ur-edit';
+        editBtn.textContent = '编辑';
+        editBtn.onclick = () => openUrlModal(u.id);
+
+        const delBtn = document.createElement('button');
+        delBtn.className = 'ur-btn ur-del';
+        delBtn.textContent = '删除';
+        delBtn.onclick = () => deleteUrl(u.id);
+
+        actions.appendChild(editBtn);
+        actions.appendChild(delBtn);
+        row.appendChild(name);
+        row.appendChild(link);
+        row.appendChild(actions);
+        listEl.appendChild(row);
+      });
+    } catch (e) {
+      emptyEl.style.display = 'none';
+    }
+  }
+
+  function openUrlModal(id) {
+    editingUrlId = (id == null) ? null : id;
+    const nameInput = document.getElementById('url-name-input');
+    const urlInput = document.getElementById('url-url-input');
+    const title = document.getElementById('url-modal-title');
+    const errEl = document.getElementById('url-modal-error');
+    errEl.textContent = '';
+    const cur = editingUrlId ? urlsCache.find(u => u.id === editingUrlId) : null;
+    nameInput.value = cur ? (cur.name || '') : '';
+    urlInput.value = cur ? (cur.url || '') : '';
+    title.textContent = editingUrlId ? '编辑链接' : '新增链接';
+    document.getElementById('url-modal').style.display = 'flex';
+    nameInput.focus();
+  }
+
+  function closeUrlModal() {
+    document.getElementById('url-modal').style.display = 'none';
+    editingUrlId = null;
+  }
+
+  async function saveUrl() {
+    const nameInput = document.getElementById('url-name-input');
+    const urlInput = document.getElementById('url-url-input');
+    const errEl = document.getElementById('url-modal-error');
+    const btn = document.getElementById('url-modal-save');
+    const name = nameInput.value.trim();
+    const url = urlInput.value.trim();
+    errEl.textContent = '';
+    if (!name) { errEl.textContent = '名称不能为空'; nameInput.focus(); return; }
+    if (!url) { errEl.textContent = 'URL 不能为空'; urlInput.focus(); return; }
+    btn.disabled = true;
+    try {
+      const opts = {
+        method: editingUrlId ? 'PUT' : 'POST',
+        headers: Object.assign(authHeaders(), { 'Content-Type': 'application/json' }),
+        body: JSON.stringify({ name, url })
+      };
+      const res = await fetch(API + '/api/urls' + (editingUrlId ? '/' + editingUrlId : ''), opts);
+      if (res.ok) {
+        closeUrlModal();
+        await renderUrls();
+      } else {
+        const d = await res.json().catch(() => ({}));
+        errEl.textContent = d.error || '保存失败';
+      }
+    } catch (e) {
+      errEl.textContent = '保存失败: ' + e.message;
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
+  async function deleteUrl(id) {
+    if (!confirm('确定删除该链接?')) return;
+    try {
+      const res = await fetch(API + '/api/urls/' + id, { method: 'DELETE', headers: authHeaders() });
+      if (res.ok) await renderUrls();
+    } catch (e) { /* 忽略,下次刷新恢复 */ }
+  }
+
   // 列表页自动刷新:进入时启动,离开/退出时停止
   let pickerTimer = null;
   let pickerRefreshing = false;
@@ -2042,6 +2241,16 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
     window.close();
   };
 
+  // 快捷链接页:顶栏 Icon → /list(同标签切换);返回 → 首页;新增/编辑/删除 modal
+  document.getElementById('btn-urls').onclick = () => { window.location.href = '/list'; };
+  document.getElementById('btn-url-back').onclick = () => { window.location.href = '/'; };
+  document.getElementById('btn-url-new').onclick = () => openUrlModal(null);
+  document.getElementById('url-modal-close').onclick = closeUrlModal;
+  document.getElementById('url-modal-cancel').onclick = closeUrlModal;
+  document.getElementById('url-modal-save').onclick = saveUrl;
+  document.getElementById('url-name-input').addEventListener('keydown', e => { if (e.key === 'Enter') saveUrl(); });
+  document.getElementById('url-url-input').addEventListener('keydown', e => { if (e.key === 'Enter') saveUrl(); });
+
   // 注意:关闭浏览器不再杀进程——后端会在 WS 断开时保留任务后台继续运行(detach)。
   // 只有显式点“关闭任务”或“重启”才会通过 /api/kill 杀进程。