From aa7fef50a8e9e5fb84bd59fb69110d45105aed58 Mon Sep 17 00:00:00 2001 From: Codebuddy Date: Mon, 14 Sep 2026 11:43:56 +0800 Subject: [PATCH] =?utf8?q?feat:=20=E5=BF=AB=E6=8D=B7=E9=93=BE=E6=8E=A5?= =?utf8?q?=E6=94=AF=E6=8C=81=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=E4=B8=8E?= =?utf8?q?=E5=90=8D=E7=A7=B0=E6=96=87=E5=AD=97=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - 后端: POST/PUT /api/urls 支持可选 color 字段;新增 POST /api/urls/reorder 接收 {order:[id...]} 持久化拖拽顺序,缺失项自动补齐防丢 - 前端: 链接行可拖拽排序(仅手柄 ⠿ 可拖,避免误触链接跳转); 编辑 modal 新增 10 色文字颜色选择(与底色协调,含主题色); 名称按所选色渲染;顶栏「🔗 链接」改为 window.open 新标签页打开 /list - 调色板微调: 移除与主题色过近的青蓝,改用 periwinkle 紫,避免相邻/同色相近 --- backend/app.py | 31 +++++++++++++ frontend/index.html | 105 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/backend/app.py b/backend/app.py index db1013d..0a86c39 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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): diff --git a/frontend/index.html b/frontend/index.html index 4b6a2ac..13463d0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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
URL
+
+
名称文字颜色
+
+
@@ -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'); })(); -- 2.43.0