From: Codebuddy Date: Wed, 30 Sep 2026 15:04:28 +0000 (+0800) Subject: feat: 快捷链接支持 git 持久化、开机自启开关与关联服务(含 systemd 自启脚本) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=refs%2Fheads%2Fmaster;p=codebuddy-web.git feat: 快捷链接支持 git 持久化、开机自启开关与关联服务(含 systemd 自启脚本) --- diff --git a/.gitignore b/.gitignore index 25c1581..4c4b082 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,9 @@ data/tasks.json.tmp data/tasks.json.corrupted* # Runtime state: user-configured quick links (name + URL entries) +# 链接配置纳入版本管理(可 git 提交 / 跨环境同步),故取消忽略 data/urls.json +!data/urls.json # Runtime state: pasted screenshots uploaded via web console data/pastes/ diff --git a/backend/app.py b/backend/app.py index f001f47..19bd0a3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -260,6 +260,36 @@ def _git_commit_tasks() -> None: logger.warning(f"任务清单 git commit 失败(文件已保存): {e}") +def _git_commit_path(rel_path: str, message: str) -> None: + """提交指定文件到 git 并同步推送到 github。失败仅告警,不影响接口正常返回。""" + try: + with _GIT_LOCK: + subprocess.run(["git", "add", "--", rel_path], cwd=_REPO_ROOT, + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + r = subprocess.run(["git", "commit", "-m", message], + cwd=_REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # 无实际变更时 git commit 返回非 0,忽略 + if r.returncode == 0: + logger.info(f"{rel_path} 已自动 git commit") + p = subprocess.run(["git", "push", "github", "master"], + cwd=_REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if p.returncode == 0: + logger.info(f"{rel_path} 已自动 git push 到 github") + else: + logger.warning(f"{rel_path} git push 到 github 失败(本地 commit 已保留)") + except Exception as e: + logger.warning(f"git commit {rel_path} 失败: {e}") + + +def _git_commit_urls() -> None: + """保存快捷链接后自动提交 urls.json 到 git 并推送到 github。""" + rel = os.path.relpath(URLS_FILE, _REPO_ROOT) + _git_commit_path(rel, f"urls: auto-save {now_iso()}") + + + + + def _restore_tasks_from_git() -> dict | None: """尝试从 git HEAD 恢复任务清单;不可用时返回 None。""" try: @@ -327,6 +357,7 @@ def load_urls() -> list: def save_urls(urls: list) -> None: + URLS_FILE.parent.mkdir(parents=True, exist_ok=True) tmp = str(URLS_FILE) + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(urls, f, ensure_ascii=False, indent=2) @@ -893,6 +924,39 @@ async def api_list_urls(request: Request): return JSONResponse({"ok": True, "urls": load_urls()}) +@app.get("/api/services") +async def api_list_services(request: Request): + """返回本机可作为链接「关联服务」的 systemd 单元名清单(供前端下拉选择)。""" + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + keywords = ("web", "lexicon", "study", "station", "word", "codebuddy", + "nginx", "chatbot", "gitweb", "frontend", "backend", + "mountion", "builder", "keepalive", "healthcheck", "sync") + result = [] + try: + out = subprocess.run( + ["systemctl", "list-unit-files", "--type=service", "--no-legend", "--no-pager"], + capture_output=True, text=True, timeout=10, + ) + for line in out.stdout.splitlines(): + parts = line.split() + name = parts[0] if parts else "" + if not name.endswith(".service"): + continue + low = name.lower() + if any(k in low for k in keywords): + result.append(name) + except Exception as e: + logger.warning(f"列举 systemd 服务失败: {e}") + result = [] + # 始终包含本应用与反代,确保可选 + for must in ("codebuddy-web.service", "nginx.service"): + if must not in result: + result.append(must) + result = sorted(set(result)) + return JSONResponse({"ok": True, "services": result}) + + @app.post("/api/urls") async def api_create_url(request: Request): if not _auth_ok(request): @@ -904,17 +968,24 @@ async def api_create_url(request: Request): name = (body.get("name") or "").strip() url = (body.get("url") or "").strip() color = (body.get("color") or "").strip() + autostart = bool(body.get("autostart", False)) + services = body.get("services") or [] + if not isinstance(services, list): + services = [] + services = [str(s).strip() for s in services if str(s).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} + item = {"id": gen_id(), "name": name, "url": url, + "autostart": autostart, "services": services} if color: item["color"] = color urls.append(item) save_urls(urls) - logger.info(f"新增快捷链接: name={name}, url={url}") + _git_commit_urls() + logger.info(f"新增快捷链接: name={name}, url={url}, autostart={autostart}, services={services}") return JSONResponse({"ok": True, "url": item}) @@ -929,6 +1000,11 @@ async def api_update_url(url_id: str, request: Request): name = (body.get("name") or "").strip() url = (body.get("url") or "").strip() color = (body.get("color") or "").strip() + autostart = bool(body.get("autostart", False)) + services = body.get("services") or [] + if not isinstance(services, list): + services = [] + services = [str(s).strip() for s in services if str(s).strip()] if not name: return JSONResponse({"ok": False, "error": "名称不能为空"}, status_code=400) if not url: @@ -939,12 +1015,15 @@ async def api_update_url(url_id: str, request: Request): return JSONResponse({"ok": False, "error": "链接不存在"}, status_code=404) item["name"] = name item["url"] = url + item["autostart"] = autostart + item["services"] = services if color: item["color"] = color else: item.pop("color", None) save_urls(urls) - logger.info(f"更新快捷链接: id={url_id}, name={name}") + _git_commit_urls() + logger.info(f"更新快捷链接: id={url_id}, name={name}, autostart={autostart}, services={services}") return JSONResponse({"ok": True, "url": item}) @@ -967,6 +1046,7 @@ async def api_reorder_urls(request: Request): if u.get("id") not in order: new_list.append(u) save_urls(new_list) + _git_commit_urls() logger.info(f"重排快捷链接顺序: {order}") return JSONResponse({"ok": True}) @@ -980,6 +1060,7 @@ async def api_delete_url(url_id: str, request: Request): return JSONResponse({"ok": False, "error": "链接不存在"}, status_code=404) urls = [u for u in urls if u.get("id") != url_id] save_urls(urls) + _git_commit_urls() logger.info(f"删除快捷链接: id={url_id}") return JSONResponse({"ok": True}) diff --git a/data/urls.json b/data/urls.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/urls.json @@ -0,0 +1 @@ +[] diff --git a/frontend/index.html b/frontend/index.html index 13463d0..9830497 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -548,6 +548,16 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
名称文字颜色
+
+ +
+
+
关联服务(前端 + 后端,开机自启时一并拉起)
+ +
Ctrl / ⌘ + 点击 多选
+
@@ -732,6 +742,16 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh let editingUrlColor = null; const URL_COLORS = ['#e0e0e0', '#4ecca3', '#74c0fc', '#9d7cff', '#ffd166', '#ff9f6b', '#ff6b6b', '#f78fb3', '#c792ea', '#a3e635']; const URL_COLOR_DEFAULT = '#e0e0e0'; + let servicesCache = null; // 候选 systemd 服务清单缓存 + async function loadServices() { + if (servicesCache) return servicesCache; + try { + const res = await fetch(API + '/api/services', { headers: authHeaders() }); + if (res.ok) { const d = await res.json(); servicesCache = (d.services || []); } + else servicesCache = []; + } catch (e) { servicesCache = []; } + return servicesCache; + } async function showUrlPage() { show('url'); @@ -824,6 +844,19 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh if (url && url !== '#') window.open(url, '_blank', 'noopener,noreferrer'); }); + const meta = document.createElement('span'); + meta.className = 'ur-services'; + const svcTxt = (Array.isArray(u.services) && u.services.length) ? u.services.join(', ') : ''; + if (u.autostart) { + meta.textContent = '🚀 开机自启' + (svcTxt ? ':' + svcTxt : '(未关联服务)'); + meta.style.color = '#ffd166'; + } else if (svcTxt) { + meta.textContent = '关联服务:' + svcTxt; + meta.style.color = '#888'; + } else { + meta.textContent = ''; + } + const actions = document.createElement('div'); actions.className = 'ur-actions'; @@ -842,6 +875,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh row.appendChild(handle); row.appendChild(name); row.appendChild(link); + row.appendChild(meta); row.appendChild(actions); row.addEventListener('dragstart', e => { @@ -861,17 +895,30 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh } } - function openUrlModal(id) { + async 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'); + const autoEl = document.getElementById('url-autostart-input'); + const selEl = document.getElementById('url-services-input'); errEl.textContent = ''; 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); + autoEl.checked = !!(cur && cur.autostart); + // 填充关联服务下拉 + const svc = await loadServices(); + const curSvcs = (cur && Array.isArray(cur.services)) ? cur.services : []; + selEl.innerHTML = ''; + svc.forEach(s => { + const o = document.createElement('option'); + o.value = s; o.textContent = s; + if (curSvcs.includes(s)) o.selected = true; + selEl.appendChild(o); + }); title.textContent = editingUrlId ? '编辑链接' : '新增链接'; document.getElementById('url-modal').style.display = 'flex'; nameInput.focus(); @@ -887,18 +934,22 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh const urlInput = document.getElementById('url-url-input'); const errEl = document.getElementById('url-modal-error'); const btn = document.getElementById('url-modal-save'); + const autoEl = document.getElementById('url-autostart-input'); + const selEl = document.getElementById('url-services-input'); 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; } const color = editingUrlColor || URL_COLOR_DEFAULT; + const autostart = !!autoEl.checked; + const services = [...selEl.selectedOptions].map(o => o.value); btn.disabled = true; try { const opts = { method: editingUrlId ? 'PUT' : 'POST', headers: Object.assign(authHeaders(), { 'Content-Type': 'application/json' }), - body: JSON.stringify({ name, url, color }) + body: JSON.stringify({ name, url, color, autostart, services }) }; const res = await fetch(API + '/api/urls' + (editingUrlId ? '/' + editingUrlId : ''), opts); if (res.ok) { diff --git a/scripts/autostart_links.sh b/scripts/autostart_links.sh new file mode 100644 index 0000000..4e5e632 --- /dev/null +++ b/scripts/autostart_links.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# 按 CodeBuddy Web「快捷链接」配置,在系统重启后拉起标记为「开机自启」的链接所关联的前后端服务。 +# 仅对 urls.json 中 autostart=true 的链接生效;每个链接的 services 为要 systemctl start 的单元名列表。 +set -u + +REPO_DIR="/home/Codebuddy-web" +URLS_FILE="$REPO_DIR/data/urls.json" + +[ -f "$URLS_FILE" ] || exit 0 + +python3 - "$URLS_FILE" <<'PY' +import json, sys, subprocess + +path = sys.argv[1] +try: + with open(path, encoding="utf-8") as f: + urls = json.load(f) +except Exception as e: + print("读取链接配置失败:", e, file=sys.stderr) + sys.exit(0) + +if not isinstance(urls, list): + sys.exit(0) + +seen = set() +for u in urls: + if not isinstance(u, dict): + continue + if not u.get("autostart"): + continue + services = u.get("services") or [] + if not isinstance(services, list): + services = [] + for svc in services: + svc = str(svc).strip() + if not svc or svc in seen: + continue + seen.add(svc) + print("拉起服务:", svc) + try: + subprocess.run(["systemctl", "start", svc], check=False) + except Exception as e: + print(" 拉起失败:", svc, e, file=sys.stderr) +PY