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}")
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": "链接不存在"}, 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):
}
.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;
<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;">
// ========== 快捷链接配置页 ==========
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');
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';
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) {
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();
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) {
};
// 快捷链接页:顶栏 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;
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');
})();