]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 快捷链接支持拖拽排序与名称文字颜色
authorCodebuddy <codebuddy@localhost>
Mon, 14 Sep 2026 03:43:56 +0000 (11:43 +0800)
committerCodebuddy <codebuddy@localhost>
Mon, 14 Sep 2026 03:43:56 +0000 (11:43 +0800)
- 后端: POST/PUT /api/urls 支持可选 color 字段;新增 POST /api/urls/reorder
  接收 {order:[id...]} 持久化拖拽顺序,缺失项自动补齐防丢
- 前端: 链接行可拖拽排序(仅手柄 ⠿ 可拖,避免误触链接跳转);
  编辑 modal 新增 10 色文字颜色选择(与底色协调,含主题色);
  名称按所选色渲染;顶栏「🔗 链接」改为 window.open 新标签页打开 /list
- 调色板微调: 移除与主题色过近的青蓝,改用 periwinkle 紫,避免相邻/同色相近

backend/app.py
frontend/index.html

index db1013de7581cbdc9885c8f81b622af1ca848d33..0a86c392644fb5ced70203feef207aea06771798 100644 (file)
@@ -835,12 +835,15 @@ async def api_create_url(request: Request):
         return JSONResponse({"ok": False, "error": "无效的请求体"}, status_code=400)
     name = (body.get("name") or "").strip()
     url = (body.get("url") or "").strip()
+    color = (body.get("color") 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}
+    if color:
+        item["color"] = color
     urls.append(item)
     save_urls(urls)
     logger.info(f"新增快捷链接: name={name}, url={url}")
@@ -857,6 +860,7 @@ async def api_update_url(url_id: str, request: Request):
         return JSONResponse({"ok": False, "error": "无效的请求体"}, status_code=400)
     name = (body.get("name") or "").strip()
     url = (body.get("url") or "").strip()
+    color = (body.get("color") or "").strip()
     if not name:
         return JSONResponse({"ok": False, "error": "名称不能为空"}, status_code=400)
     if not url:
@@ -867,11 +871,38 @@ async def api_update_url(url_id: str, request: Request):
         return JSONResponse({"ok": False, "error": "链接不存在"}, status_code=404)
     item["name"] = name
     item["url"] = url
+    if color:
+        item["color"] = color
+    else:
+        item.pop("color", None)
     save_urls(urls)
     logger.info(f"更新快捷链接: id={url_id}, name={name}")
     return JSONResponse({"ok": True, "url": item})
 
 
+@app.post("/api/urls/reorder")
+async def api_reorder_urls(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)
+    order = body.get("order") or []
+    if not isinstance(order, list):
+        return JSONResponse({"ok": False, "error": "无效的排序参数"}, status_code=400)
+    urls = load_urls()
+    by_id = {u.get("id"): u for u in urls}
+    new_list = [by_id[i] for i in order if i in by_id]
+    # 补齐不在 order 中的项(防止遗漏)
+    for u in urls:
+        if u.get("id") not in order:
+            new_list.append(u)
+    save_urls(new_list)
+    logger.info(f"重排快捷链接顺序: {order}")
+    return JSONResponse({"ok": True})
+
+
 @app.delete("/api/urls/{url_id}")
 async def api_delete_url(url_id: str, request: Request):
     if not _auth_ok(request):
index 4b6a2acb7fba77665527c6e63cbe66054d4e2356..13463d0c0c56269c0e87c554a7907f3fd0259b20 100644 (file)
@@ -287,6 +287,15 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
 }
 .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); }
+.url-row .ur-handle { color:#666; cursor:grab; font-size:18px; line-height:1; user-select:none; flex-shrink:0; }
+.url-row .ur-handle:active { cursor:grabbing; }
+.url-row.dragging { opacity:0.45; border-color:rgba(78,204,163,0.6); }
+.url-row .ur-name { cursor: default; }
+/* 名称文字颜色选择 */
+.ur-color-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
+.ur-swatch { width:26px; height:26px; border-radius:50%; border:2px solid rgba(255,255,255,0.18); cursor:pointer; padding:0; transition:transform .08s; }
+.ur-swatch:hover { transform:scale(1.1); }
+.ur-swatch.selected { border-color:#fff; box-shadow:0 0 0 2px rgba(78,204,163,0.7); }
 .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;
@@ -535,6 +544,10 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
         <div style="color:#aaa; font-size:12px; margin-bottom:4px;">URL</div>
         <input id="url-url-input" class="tc-input" placeholder="https://...">
       </div>
+      <div>
+        <div style="color:#aaa; font-size:12px; margin-bottom:6px;">名称文字颜色</div>
+        <div id="url-color-row" class="ur-color-row"></div>
+      </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;">
@@ -716,12 +729,60 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   // ========== 快捷链接配置页 ==========
   let urlsCache = [];        // 当前列表缓存,供编辑时回填
   let editingUrlId = null;   // 正在编辑的链接 id;null 表示新增
+  let editingUrlColor = null;
+  const URL_COLORS = ['#e0e0e0', '#4ecca3', '#74c0fc', '#9d7cff', '#ffd166', '#ff9f6b', '#ff6b6b', '#f78fb3', '#c792ea', '#a3e635'];
+  const URL_COLOR_DEFAULT = '#e0e0e0';
 
   async function showUrlPage() {
     show('url');
     await renderUrls();
   }
 
+  function buildColorSwatches() {
+    const row = document.getElementById('url-color-row');
+    if (!row) return;
+    row.innerHTML = '';
+    URL_COLORS.forEach(c => {
+      const b = document.createElement('button');
+      b.type = 'button';
+      b.className = 'ur-swatch';
+      b.style.background = c;
+      b.dataset.color = c;
+      b.title = c;
+      b.onclick = () => selectUrlColor(c);
+      row.appendChild(b);
+    });
+  }
+
+  function selectUrlColor(c) {
+    editingUrlColor = c;
+    document.querySelectorAll('#url-color-row .ur-swatch').forEach(s => {
+      s.classList.toggle('selected', s.dataset.color === c);
+    });
+  }
+
+  function getDragAfterElement(container, y) {
+    const els = [...container.querySelectorAll('.url-row:not(.dragging)')];
+    return els.reduce((closest, child) => {
+      const box = child.getBoundingClientRect();
+      const offset = y - box.top - box.height / 2;
+      if (offset < 0 && offset > closest.offset) return { offset, element: child };
+      return closest;
+    }, { offset: -Infinity, element: null }).element;
+  }
+
+  async function persistUrlOrder() {
+    const ids = [...document.querySelectorAll('#url-list .url-row')].map(r => r.dataset.id);
+    urlsCache.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
+    try {
+      await fetch(API + '/api/urls/reorder', {
+        method: 'POST',
+        headers: Object.assign(authHeaders(), { 'Content-Type': 'application/json' }),
+        body: JSON.stringify({ order: ids })
+      });
+    } catch (e) { /* 失败则下次渲染按服务端顺序恢复 */ }
+  }
+
   async function renderUrls() {
     const listEl = document.getElementById('url-list');
     const emptyEl = document.getElementById('url-empty');
@@ -737,17 +798,31 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
       urls.forEach(u => {
         const row = document.createElement('div');
         row.className = 'url-row';
+        row.dataset.id = u.id;
+
+        const handle = document.createElement('span');
+        handle.className = 'ur-handle';
+        handle.textContent = '⠿';
+        handle.title = '拖动调整顺序';
+        handle.draggable = true;
 
         const name = document.createElement('span');
         name.className = 'ur-name';
         name.textContent = u.name || '';
+        name.style.color = u.color || URL_COLOR_DEFAULT;
 
         const link = document.createElement('a');
         link.className = 'ur-link';
         link.href = u.url || '#';
         link.target = '_blank';
         link.rel = 'noopener noreferrer';
+        link.draggable = false;
         link.textContent = u.url || '';
+        link.addEventListener('click', e => {
+          const url = (u.url || '').trim();
+          e.preventDefault();
+          if (url && url !== '#') window.open(url, '_blank', 'noopener,noreferrer');
+        });
 
         const actions = document.createElement('div');
         actions.className = 'ur-actions';
@@ -764,9 +839,21 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
 
         actions.appendChild(editBtn);
         actions.appendChild(delBtn);
+        row.appendChild(handle);
         row.appendChild(name);
         row.appendChild(link);
         row.appendChild(actions);
+
+        row.addEventListener('dragstart', e => {
+          row.classList.add('dragging');
+          e.dataTransfer.effectAllowed = 'move';
+          try { e.dataTransfer.setData('text/plain', u.id); } catch (_) {}
+        });
+        row.addEventListener('dragend', () => {
+          row.classList.remove('dragging');
+          persistUrlOrder();
+        });
+
         listEl.appendChild(row);
       });
     } catch (e) {
@@ -784,6 +871,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
     const cur = editingUrlId ? urlsCache.find(u => u.id === editingUrlId) : null;
     nameInput.value = cur ? (cur.name || '') : '';
     urlInput.value = cur ? (cur.url || '') : '';
+    selectUrlColor(cur && cur.color ? cur.color : URL_COLOR_DEFAULT);
     title.textContent = editingUrlId ? '编辑链接' : '新增链接';
     document.getElementById('url-modal').style.display = 'flex';
     nameInput.focus();
@@ -804,12 +892,13 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
     errEl.textContent = '';
     if (!name) { errEl.textContent = '名称不能为空'; nameInput.focus(); return; }
     if (!url) { errEl.textContent = 'URL 不能为空'; urlInput.focus(); return; }
+    const color = editingUrlColor || URL_COLOR_DEFAULT;
     btn.disabled = true;
     try {
       const opts = {
         method: editingUrlId ? 'PUT' : 'POST',
         headers: Object.assign(authHeaders(), { 'Content-Type': 'application/json' }),
-        body: JSON.stringify({ name, url })
+        body: JSON.stringify({ name, url, color })
       };
       const res = await fetch(API + '/api/urls' + (editingUrlId ? '/' + editingUrlId : ''), opts);
       if (res.ok) {
@@ -2242,7 +2331,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   };
 
   // 快捷链接页:顶栏 Icon → /list(同标签切换);返回 → 首页;新增/编辑/删除 modal
-  document.getElementById('btn-urls').onclick = () => { window.location.href = '/list'; };
+  document.getElementById('btn-urls').onclick = () => { window.open('/list', '_blank', 'noopener'); };
   document.getElementById('btn-url-back').onclick = () => { window.location.href = '/'; };
   document.getElementById('btn-url-new').onclick = () => openUrlModal(null);
   document.getElementById('url-modal-close').onclick = closeUrlModal;
@@ -2251,11 +2340,23 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   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(); });
 
+  // 链接列表拖拽排序:在容器上统一处理 dragover,按指针位置插入占位
+  const urlListEl = document.getElementById('url-list');
+  urlListEl.addEventListener('dragover', e => {
+    e.preventDefault();
+    const dragging = document.querySelector('#url-list .url-row.dragging');
+    if (!dragging) return;
+    const after = getDragAfterElement(urlListEl, e.clientY);
+    if (after == null) urlListEl.appendChild(dragging);
+    else urlListEl.insertBefore(dragging, after);
+  });
+
   // 注意:关闭浏览器不再杀进程——后端会在 WS 断开时保留任务后台继续运行(detach)。
   // 只有显式点“关闭任务”或“重启”才会通过 /api/kill 杀进程。
 
   // ========== 初始化 ==========
   (async () => {
+    buildColorSwatches();
     if (await checkAuth()) await enterApp();
     else show('login');
   })();