]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 快捷链接支持 git 持久化、开机自启开关与关联服务(含 systemd 自启脚本) master
authorCodebuddy <codebuddy@localhost>
Wed, 30 Sep 2026 15:04:28 +0000 (23:04 +0800)
committerCodebuddy <codebuddy@localhost>
Wed, 30 Sep 2026 15:04:28 +0000 (23:04 +0800)
.gitignore
backend/app.py
data/urls.json [new file with mode: 0644]
frontend/index.html
scripts/autostart_links.sh [new file with mode: 0644]

index 25c1581d1385c3276a1b2784fbba3e0ec9ffc603..4c4b082e9aff753c8b858d84fe1839c1fe77093f 100644 (file)
@@ -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/
index f001f475ecda62415c8dcfc942586a88219e8256..19bd0a3d571c79756c1f1a8538a9feaf2606c1d9 100644 (file)
@@ -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 (file)
index 0000000..fe51488
--- /dev/null
@@ -0,0 +1 @@
+[]
index 13463d0c0c56269c0e87c554a7907f3fd0259b20..9830497efdb4ddb01e78f70f74e5b169e3f58103 100644 (file)
@@ -548,6 +548,16 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
         <div style="color:#aaa; font-size:12px; margin-bottom:6px;">名称文字颜色</div>
         <div id="url-color-row" class="ur-color-row"></div>
       </div>
+      <div>
+        <label style="display:flex; align-items:center; gap:8px; color:#ddd; font-size:13px; cursor:pointer;">
+          <input type="checkbox" id="url-autostart-input"> 开机自动启动(系统重启后拉起下方关联服务)
+        </label>
+      </div>
+      <div>
+        <div style="color:#aaa; font-size:12px; margin-bottom:4px;">关联服务(前端 + 后端,开机自启时一并拉起)</div>
+        <select id="url-services-input" multiple size="6" style="width:100%; background:#2a2a2a; color:#e0e0e0; border:1px solid #444; border-radius:6px; padding:6px; font-size:13px;"></select>
+        <div style="color:#777; font-size:11px; margin-top:2px;">Ctrl / ⌘ + 点击 多选</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;">
@@ -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 (file)
index 0000000..4e5e632
--- /dev/null
@@ -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