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()
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")
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)
# 上传文件 → 所属会话任务的索引(前端在终端页上传时带上 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:
.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; }
</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>
</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">
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');
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') {
// 显示后重新 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 }; }
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();
}
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;
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 杀进程。