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:
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)
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):
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})
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": "链接不存在"}, 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})
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})
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})
<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;">
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');
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';
row.appendChild(handle);
row.appendChild(name);
row.appendChild(link);
+ row.appendChild(meta);
row.appendChild(actions);
row.addEventListener('dragstart', e => {
}
}
- 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();
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) {