feat: AgentCheckin v1.2.2 — fnOS 应用:TRAE/WorkBuddy/Qoder 三平台每日积分自动签到

- 原生 fnOS 应用(CGI 入口 + Python 守护进程),依赖 python312 运行时
- 三平台签到:TRAE 每日 200 积分、WorkBuddy 7 天循环、Qoder 每日 100 Credits
- WorkBuddy 网页授权(OAuth 设备流)、TRAE icubeAuth 解密 Token、Qoder Cosy 设备头
- 多时段调度(默认 00:05/09:00/12:30/19:00)、启动补跑、幂等签到+回查确认
- 多平台 Webhook 通知(企业微信/钉钉/飞书/Bark/Server酱/PushPlus 自动识别)
- Qoder 双站点 Token 隔离自动探测与修复
- 前端:仪表盘/账号管理/签到记录/设置四个页签,30s 轮询+变化检测+隐藏暂停
- 测试:34 后端 + 25 E2E + 9 前端回归 + 真实时钟调度测试
This commit is contained in:
cbb
2026-10-13 19:09:48 +08:00
commit a3b84d5ce2
39 changed files with 5164 additions and 0 deletions
+512
View File
@@ -0,0 +1,512 @@
/* AgentCheckin frontend — vanilla JS, no dependencies */
(function () {
"use strict";
var state = null;
var pollTimer = null;
var oauthState = null;
var oauthTimer = null;
var logTimer = null;
var $ = function (id) { return document.getElementById(id); };
// ---------------------------------------------------------------- helpers
function toast(msg, kind) {
var t = $("toast");
t.textContent = msg;
t.className = "toast" + (kind ? " " + kind : "");
clearTimeout(t._timer);
t._timer = setTimeout(function () { t.classList.add("hidden"); }, 3200);
}
function api(method, path, body) {
var opts = { method: method, headers: {} };
if (body !== undefined && body !== null) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
return fetch("api" + path, opts).then(function (r) {
return r.json().catch(function () { return {}; });
}).then(function (d) {
if (d && d.error) throw new Error(d.error);
return d;
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
var STATUS = {
ok: { label: "签到成功", cls: "ok", icon: "✅" },
ok_unverified: { label: "已领取·待确认", cls: "ok_unverified", icon: "✅" },
already: { label: "今日已签", cls: "already", icon: "☑️" },
inactive: { label: "未开放签到", cls: "inactive", icon: "➖" },
fail: { label: "签到失败", cls: "fail", icon: "❌" },
no_session: { label: "需重新授权", cls: "no_session", icon: "🔒" },
no_auth: { label: "缺少凭据", cls: "no_auth", icon: "🔒" },
retry_later: { label: "风控冷却", cls: "retry_later", icon: "⏳" }
};
function platformName(p) { return p === "trae" ? "TRAE" : (p === "qoder" ? "Qoder" : "WorkBuddy"); }
// ---------------------------------------------------------------- tabs
document.querySelectorAll(".tab").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".tab").forEach(function (b) { b.classList.remove("active"); });
document.querySelectorAll(".tabpage").forEach(function (p) { p.classList.remove("active"); });
btn.classList.add("active");
$("tab-" + btn.dataset.tab).classList.add("active");
if (btn.dataset.tab === "logs") loadLogs();
if (btn.dataset.tab === "accounts") renderAccounts();
if (btn.dataset.tab === "settings") openSettingsTab();
});
});
// ---------------------------------------------------------------- dashboard
function renderTop() {
var alive = state && state.daemon_alive;
var chip = $("daemon-chip");
chip.innerHTML = '<span class="dot"></span>守护进程 ' + (alive ? "运行中" : "已停止");
chip.className = "chip " + (alive ? "chip-green" : "chip-red");
var nr = $("nextrun-chip");
nr.textContent = "下次签到 " + ((state && state.next_run_at) || "—");
nr.className = "chip " + (state && state.next_run_at ? "chip-blue" : "chip-gray");
}
function renderCards() {
var wrap = $("dash-cards");
var accounts = (state && state.accounts) || [];
if (!accounts.length) {
wrap.innerHTML = '<div class="panel" style="grid-column:1/-1;text-align:center;color:var(--muted)">' +
"暂无账号,请先到「账号管理」添加 TRAE / WorkBuddy 账号</div>";
return;
}
wrap.innerHTML = accounts.map(function (a) {
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
var statusHtml = st
? '<span class="status-chip ' + st.cls + '">' + st.icon + " " + st.label + "</span>"
: '<span class="status-chip pending">⏳ 今日未签</span>';
var credits = "";
if (a.last_result && typeof a.last_result.credits === "number") {
credits = "剩余积分 <b>" + a.last_result.credits + "</b>";
} else if (a.last_result && a.last_result.points) {
credits = "本次 +<b>" + a.last_result.points + "</b>";
}
var msg = a.today && a.today.message ? esc(a.today.message) : "";
return (
'<div class="acct-card' + (a.enabled ? "" : " disabled") + '">' +
'<div class="acct-head"><span class="platform-badge ' + esc(a.platform) + '">' + platformName(a.platform) + "</span>" +
'<span class="acct-name">' + esc(a.name) + "</span></div>" +
'<div class="acct-status">' + statusHtml + (msg ? '<span class="muted">' + msg + "</span>" : "") + "</div>" +
'<div class="acct-meta">' + (credits ? "<span>" + credits + "</span>" : "") +
(a.last_result ? "<span>最近运行 " + esc(a.last_result.time || "") + "</span>" : "") +
(!a.enabled ? "<span>已禁用</span>" : "") + "</div>" +
'<div class="acct-actions">' +
'<button class="btn btn-sm" data-act="run" data-id="' + esc(a.id) + '">立即签到</button>' +
'<button class="btn btn-sm" data-act="test" data-id="' + esc(a.id) + '">测连接</button>' +
"</div></div>"
);
}).join("");
wrap.querySelectorAll("button[data-act]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.act === "run") runCheckin(btn.dataset.id, btn);
else testAccount(btn.dataset.id, btn);
});
});
}
var lastStateJson = "";
function refreshState(force) {
return api("GET", "/state").then(function (d) {
var json = JSON.stringify(d);
if (!force && json === lastStateJson) return; // 数据没变,跳过重渲染
lastStateJson = json;
state = d;
renderTop();
renderCards();
}).catch(function (e) { /* keep old state on transient errors */ });
}
// ---------------------------------------------------------------- run / test
var runningBtn = null;
function runCheckin(accountId, btn) {
if (btn) { btn.disabled = true; btn.textContent = "执行中…"; runningBtn = btn; }
api("POST", "/checkin", accountId ? { account_id: accountId } : {})
.then(function (d) {
if (d.mode === "trigger") {
toast("已触发签到,守护进程正在执行…");
waitForRun(accountId);
} else {
toast("签到完成", "ok");
refreshState();
}
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () {
if (runningBtn) { runningBtn.disabled = false; runningBtn.textContent = "立即签到"; runningBtn = null; }
});
}
function waitForRun(accountId) {
// poll until today_results contains a fresh entry for the target
var started = Date.now();
var before = JSON.stringify((state && state.today_results) || {});
(function poll() {
if (Date.now() - started > 90000) { refreshState(); return; }
refreshState().then(function () {
var after = JSON.stringify((state && state.today_results) || {});
var done = after !== before && (!accountId || (state.today_results || {})[accountId]);
if (done) {
var r = accountId ? state.today_results[accountId] : null;
if (r) toast(platformName(r.platform) + " " + r.name + ":" + (r.message || r.status), r.status === "fail" || r.status === "no_session" ? "err" : "ok");
else toast("签到完成", "ok");
} else {
setTimeout(poll, 3000);
}
});
})();
}
function testAccount(id, btn) {
btn.disabled = true; btn.textContent = "检测中…";
api("POST", "/accounts/test", { id: id })
.then(function (d) {
var parts = [];
if (d.refresh) parts.push("续期:" + d.refresh);
if (typeof d.checked_in === "boolean") parts.push("今日" + (d.checked_in ? "已签" : "未签"));
if (d.enable === false) parts.push("活动未开放");
if (d.credits && typeof d.credits.remain === "number") parts.push("剩余积分 " + d.credits.remain);
else if (typeof d.credits === "number") parts.push("积分 " + d.credits);
if (d.hint) parts.push(d.hint);
if (d.error) parts.push("错误:" + d.error);
toast(parts.join(";") || "检测完成", d.error ? "err" : "ok");
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () { btn.disabled = false; btn.textContent = "测连接"; });
}
// ---------------------------------------------------------------- accounts tab
function renderAccounts() {
var wrap = $("account-table-wrap");
var accounts = (state && state.accounts) || [];
if (!accounts.length) {
wrap.innerHTML = '<p class="muted">暂无账号,点击右上角「添加账号」。</p>';
return;
}
var rows = accounts.map(function (a) {
var st = a.today && a.today.status ? STATUS[a.today.status] : null;
return "<tr>" +
"<td><span class='platform-badge " + esc(a.platform) + "'>" + platformName(a.platform) + "</span></td>" +
"<td>" + esc(a.name) + (a.need_reauth ? ' <span class="status-chip no_session">需重新授权</span>' : "") +
(a.platform === "qoder" ? '<div class="muted" style="font-size:11px">' + esc((a.auth && a.auth.domain) || "") + "</div>" : "") + "</td>" +
"<td>" + esc(a.auth.accessToken || "") + "</td>" +
"<td>" + (st ? st.icon + " " + st.label : "⏳ 今日未签") + "</td>" +
"<td>" + (a.last_result && typeof a.last_result.credits === "number" ? a.last_result.credits : "—") + "</td>" +
"<td>" + (a.enabled ? "启用" : "禁用") + "</td>" +
'<td class="actions">' +
'<button class="btn btn-sm" data-a2="run" data-id="' + esc(a.id) + '">签到</button>' +
(a.platform === "qoder" ? '<button class="btn btn-sm" data-a2="fixdomain" data-id="' + esc(a.id) + '" title="自动探测 Token 所属站点并切换">修复站点</button>' : "") +
'<button class="btn btn-sm" data-a2="toggle" data-id="' + esc(a.id) + '">' + (a.enabled ? "禁用" : "启用") + "</button>" +
'<button class="btn btn-sm btn-danger" data-a2="del" data-id="' + esc(a.id) + '">删除</button>' +
"</td></tr>";
}).join("");
wrap.innerHTML = '<table class="tbl"><thead><tr><th>平台</th><th>名称</th><th>Token</th><th>今日</th><th>积分</th><th>状态</th><th>操作</th></tr></thead><tbody>' + rows + "</tbody></table>";
wrap.querySelectorAll("button[data-a2]").forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.dataset.id, act = btn.dataset.a2;
if (act === "run") runCheckin(id, btn);
else if (act === "fixdomain") {
btn.disabled = true; btn.textContent = "探测中…";
api("POST", "/accounts/fix_domain", { id: id })
.then(function (d) {
if (d.ok) { toast("已切换到 " + d.domain, "ok"); refreshState(); }
else { toast(d.error || "探测失败", "err"); }
})
.catch(function (e) { toast(e.message, "err"); })
.finally(function () { btn.disabled = false; btn.textContent = "修复站点"; });
}
else if (act === "toggle") {
var acc = state.accounts.find(function (x) { return x.id === id; });
api("POST", "/accounts/update", { id: id, enabled: !acc.enabled })
.then(function () { refreshState(); }).catch(function (e) { toast(e.message, "err"); });
} else if (act === "del") {
if (!confirm("确定删除该账号?")) return;
api("POST", "/accounts/delete", { id: id })
.then(function () { toast("已删除", "ok"); refreshState(); })
.catch(function (e) { toast(e.message, "err"); });
}
});
});
}
// ---------------------------------------------------------------- add modal
function openModal() { $("modal-add").classList.remove("hidden"); }
function closeModal(id) { $(id).classList.add("hidden"); stopOauth(); $("add-msg").textContent = ""; }
document.querySelectorAll("[data-close]").forEach(function (b) {
b.addEventListener("click", function () { closeModal(b.dataset.close); });
});
$("modal-add").addEventListener("click", function (e) {
if (e.target === this) closeModal("modal-add");
});
$("btn-add-account").addEventListener("click", openModal);
document.querySelectorAll(".mtab").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".mtab").forEach(function (b) { b.classList.remove("active"); });
document.querySelectorAll(".mtabpage").forEach(function (p) { p.classList.remove("active"); });
btn.classList.add("active");
$("mtab-" + btn.dataset.mtab).classList.add("active");
stopOauth();
});
});
function addMsg(msg, ok) {
var m = $("add-msg");
m.textContent = msg;
m.className = "msg " + (ok ? "ok" : "err");
}
$("btn-wb-manual-add").addEventListener("click", function () {
var token = $("wb-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
api("POST", "/accounts/add_wb_manual", {
name: $("wb-name").value.trim(),
access_token: token,
refresh_token: $("wb-refresh").value.trim(),
domain: $("wb-domain").value
}).then(function () {
toast("WorkBuddy 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
$("btn-trae-add").addEventListener("click", function () {
var token = $("trae-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
api("POST", "/accounts/add_trae", {
name: $("trae-name").value.trim(),
access_token: token,
refresh_token: $("trae-refresh").value.trim()
}).then(function () {
toast("TRAE 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
$("btn-qoder-add").addEventListener("click", function () {
var token = $("qoder-token").value.trim();
if (!token) return addMsg("请填写 Access Token", false);
if (token.length < 10) return addMsg("Access Token 仅 " + token.length + " 字符,明显不完整,请重新复制", false);
var rt0 = $("qoder-refresh").value.trim();
if (rt0 && rt0.length < 10) return addMsg("Refresh Token 仅 " + rt0.length + " 字符,明显不完整,请重新复制", false);
var dev = {};
var map = {
"qoder-machine-id": "machineId",
"qoder-machine-token": "machineToken",
"qoder-machine-code": "machineCode",
"qoder-machine-type": "machineType",
"qoder-version": "version",
};
Object.keys(map).forEach(function (id) {
var v = $(id).value.trim();
if (v) dev[map[id]] = v;
});
api("POST", "/accounts/add_qoder", {
name: $("qoder-name").value.trim(),
access_token: token,
refresh_token: $("qoder-refresh").value.trim(),
domain: $("qoder-domain").value,
device: dev
}).then(function () {
toast("Qoder 账号已添加", "ok");
closeModal("modal-add"); refreshState();
}).catch(function (e) { addMsg(e.message, false); });
});
// ---- workbuddy oauth flow
function stopOauth() {
if (oauthTimer) { clearInterval(oauthTimer); oauthTimer = null; }
oauthState = null;
$("oauth-box").classList.add("hidden");
}
$("btn-oauth-begin").addEventListener("click", function () {
this.disabled = true;
api("POST", "/wb_oauth_begin", {})
.then(function (d) {
oauthState = d.state;
$("oauth-url").value = d.login_url;
$("oauth-box").classList.remove("hidden");
$("oauth-status").textContent = "等待登录…(链接 " + Math.round(d.ttl / 60) + " 分钟内有效)";
oauthTimer = setInterval(pollOauth, 2000);
})
.catch(function (e) { addMsg(e.message, false); })
.finally(function () { this.disabled = false; }.bind(this));
});
$("btn-oauth-copy").addEventListener("click", function () {
var input = $("oauth-url");
input.select();
var done = function () { toast("链接已复制", "ok"); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(input.value).then(done, function () { document.execCommand("copy"); done(); });
} else { document.execCommand("copy"); done(); }
});
function pollOauth() {
if (!oauthState) return;
api("GET", "/wb_oauth_poll?state=" + encodeURIComponent(oauthState))
.then(function (d) {
if (d.status === "done") {
stopOauth();
toast("WorkBuddy 账号已绑定", "ok");
closeModal("modal-add");
refreshState();
}
})
.catch(function (e) {
$("oauth-status").textContent = e.message;
stopOauth();
});
}
// ---------------------------------------------------------------- logs
function loadLogs() {
api("GET", "/logs?lines=300").then(function (d) {
$("log-view").textContent = (d.lines || []).join("\n") || "暂无日志";
$("log-view").scrollTop = $("log-view").scrollHeight;
}).catch(function () {
$("log-view").textContent = "日志加载失败";
});
}
$("btn-refresh-logs").addEventListener("click", loadLogs);
setInterval(function () {
if (document.hidden) return;
if ($("log-autorefresh").checked && $("tab-logs").classList.contains("active")) loadLogs();
}, 15000);
// ---------------------------------------------------------------- settings
// Local editing copy: initialised from server state ONLY when the tab is
// opened (and there are no unsaved edits). The 10s state poll must never
// overwrite it, otherwise in-progress edits are silently discarded.
var settings = null;
var settingsDirty = false;
function openSettingsTab() {
if (!settingsDirty) {
settings = JSON.parse(JSON.stringify(
(state && state.settings) || { schedule_times: [], webhook_url: "", notify_on: [] }));
}
renderSettings();
}
function renderSettings() {
// renders ONLY from the local copy — never re-reads from `state` here
settings = settings || { schedule_times: [], webhook_url: "", notify_on: [] };
var chips = $("time-chips");
chips.innerHTML = "";
(settings.schedule_times || []).forEach(function (t) {
var el = document.createElement("span");
el.className = "time-chip";
el.innerHTML = esc(t) + '<button title="删除">×</button>';
el.querySelector("button").addEventListener("click", function () {
settings.schedule_times = settings.schedule_times.filter(function (x) { return x !== t; });
settingsDirty = true;
renderSettings();
});
chips.appendChild(el);
});
$("webhook-url").value = settings.webhook_url || "";
$("webhook-type").value = settings.webhook_type || "auto";
$("notify-fail").checked = (settings.notify_on || []).indexOf("fail") >= 0;
$("notify-success").checked = (settings.notify_on || []).indexOf("success") >= 0;
}
$("btn-add-time").addEventListener("click", function () {
var v = $("new-time").value;
if (!v) { toast("请先选择要添加的时间", "err"); return; }
settings.schedule_times = settings.schedule_times || [];
if (settings.schedule_times.indexOf(v) >= 0) {
toast("该时间已存在", "err");
return;
}
settings.schedule_times.push(v);
settings.schedule_times.sort();
settingsDirty = true;
renderSettings();
$("new-time").value = "";
});
$("btn-save-settings").addEventListener("click", function () {
var times = (settings.schedule_times || []).slice().sort();
if (!times.length) { $("settings-msg").textContent = "至少保留一个签到时间点"; $("settings-msg").className = "msg err"; return; }
var notify = [];
if ($("notify-fail").checked) notify.push("fail");
if ($("notify-success").checked) notify.push("success");
api("POST", "/settings", {
schedule_times: times,
webhook_url: $("webhook-url").value.trim(),
webhook_type: $("webhook-type").value,
notify_on: notify
}).then(function () {
settingsDirty = false;
$("settings-msg").textContent = "已保存";
$("settings-msg").className = "msg ok";
toast("设置已保存", "ok");
refreshState();
}).catch(function (e) {
$("settings-msg").textContent = e.message;
$("settings-msg").className = "msg err";
});
});
$("btn-test-notify").addEventListener("click", function () {
var url = $("webhook-url").value.trim();
if (!url) { $("settings-msg").textContent = "请先填写 Webhook 地址"; $("settings-msg").className = "msg err"; return; }
// save first so the daemon/notify uses the new url, then fire a real run
var notify = [];
if ($("notify-fail").checked) notify.push("fail");
if ($("notify-success").checked) notify.push("success");
api("POST", "/settings", { webhook_url: url, webhook_type: $("webhook-type").value, notify_on: notify })
.then(function () {
settingsDirty = false;
return api("POST", "/checkin", {});
})
.then(function () {
$("settings-msg").textContent = "已触发一次签到,通知将随结果发送";
$("settings-msg").className = "msg ok";
waitForRun(null);
})
.catch(function (e) {
$("settings-msg").textContent = e.message;
$("settings-msg").className = "msg err";
});
});
// ---------------------------------------------------------------- run all
$("btn-run-all").addEventListener("click", function () { runCheckin(null, null); });
// ---------------------------------------------------------------- boot
var STATE_POLL_MS = 30000;
function boot() {
refreshState(true);
renderAccounts();
pollTimer = setInterval(function () {
if (document.hidden) return; // 页面/iframe 不可见时暂停轮询
refreshState().then(renderAccountsIfVisible);
}, STATE_POLL_MS);
document.addEventListener("visibilitychange", function () {
if (!document.hidden) refreshState(true).then(renderAccountsIfVisible);
});
}
function renderAccountsIfVisible() {
if ($("tab-accounts").classList.contains("active")) renderAccounts();
}
boot();
})();
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b6cff"/>
<stop offset="1" stop-color="#7a5af8"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
<rect x="14" y="16" width="36" height="34" rx="6" fill="none" stroke="#fff" stroke-width="3.5"/>
<rect x="22" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
<rect x="37.5" y="11" width="4.5" height="8" rx="2.25" fill="#fff"/>
<line x1="14" y1="26" x2="50" y2="26" stroke="#fff" stroke-width="3.5"/>
<path d="M23 38 L29 44 L42 31" fill="none" stroke="#7ef0b2" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 769 B

+241
View File
@@ -0,0 +1,241 @@
/* AgentCheckin — fnOS app styles */
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--border: #e5e7eb;
--text: #1f2329;
--muted: #8a909a;
--primary: #3b6cff;
--primary-dark: #2f57d6;
--green: #16a34a;
--green-bg: #eaf7ef;
--red: #dc2626;
--red-bg: #fdecec;
--amber: #d97706;
--amber-bg: #fdf3e3;
--blue-bg: #e8efff;
--gray-bg: #f0f1f3;
--radius: 12px;
--shadow: 0 1px 3px rgba(16, 24, 40, .06), 0 1px 2px rgba(16, 24, 40, .04);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.6;
}
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
padding: 16px 24px;
background: linear-gradient(135deg, #3b6cff 0%, #7a5af8 100%);
color: #fff;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo { width: 44px; height: 44px; }
.brand h1 { font-size: 18px; margin: 0; font-weight: 600; }
.brand .sub { margin: 0; font-size: 12px; opacity: .85; }
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 999px;
font-size: 12px; font-weight: 500;
background: rgba(255,255,255,.22); color: #fff;
}
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; }
.chip.chip-green .dot { background: #4ade80; }
.chip.chip-red .dot { background: #f87171; }
/* ---------- tabs ---------- */
.tabs {
display: flex; gap: 4px;
padding: 0 24px;
background: var(--panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.tab {
border: none; background: none; cursor: pointer;
padding: 12px 18px; font-size: 14px; color: var(--muted);
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 60px; }
.tabpage { display: none; }
.tabpage.active { display: block; }
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: 1px solid var(--border); background: var(--panel); color: var(--text);
border-radius: 8px; padding: 7px 14px; font-size: 13px; cursor: pointer;
transition: all .15s ease; white-space: nowrap;
}
.btn:hover { border-color: #c7cdd6; background: #fafbfc; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn-danger { color: var(--red); border-color: #f3c2c2; }
.btn-danger:hover { background: var(--red-bg); border-color: var(--red); }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
/* ---------- panels & cards ---------- */
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 20px; margin-bottom: 20px;
}
.panel h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; }
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.panel-head h2 { margin: 0; }
.muted { color: var(--muted); font-size: 13px; margin: 4px 0; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px; margin-bottom: 20px;
}
.acct-card {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 16px; display: flex; flex-direction: column; gap: 10px;
}
.acct-card.disabled { opacity: .62; }
.acct-head { display: flex; align-items: center; gap: 8px; }
.platform-badge {
font-size: 11px; font-weight: 700; letter-spacing: .4px;
padding: 3px 8px; border-radius: 6px;
}
.platform-badge.trae { background: #e8f3ff; color: #0a5cd8; }
.platform-badge.workbuddy { background: #e8f7ef; color: #0a8a4a; }
.platform-badge.qoder { background: #f3eaff; color: #7c3aed; }
.acct-name { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-status { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.status-chip { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
.status-chip.ok { background: var(--green-bg); color: var(--green); }
.status-chip.already { background: var(--blue-bg); color: var(--primary); }
.status-chip.pending { background: var(--gray-bg); color: var(--muted); }
.status-chip.fail, .status-chip.no_session, .status-chip.no_auth { background: var(--red-bg); color: var(--red); }
.status-chip.inactive, .status-chip.retry_later, .status-chip.ok_unverified { background: var(--amber-bg); color: var(--amber); }
.acct-meta { font-size: 12px; color: var(--muted); display: flex; flex-direction: column; gap: 2px; }
.acct-meta b { color: var(--text); font-weight: 600; }
.acct-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.hint-list { margin: 0; padding-left: 20px; color: var(--text); }
.hint-list li { margin-bottom: 6px; }
/* ---------- table ---------- */
table.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th {
text-align: left; padding: 8px 10px; color: var(--muted);
font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border);
}
.tbl td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.tbl .actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* ---------- forms ---------- */
.input {
border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; font-size: 13px; font-family: inherit;
background: #fff; color: var(--text);
outline: none; transition: border-color .15s;
}
.input:focus { border-color: var(--primary); }
.input-wide { width: 100%; }
.input-time { width: 140px; }
label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 12px; }
label .input { margin-top: 6px; }
.req { color: var(--red); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.switch-label { display: inline-flex; align-items: center; gap: 6px; font-weight: 400; margin-bottom: 0; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.time-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--blue-bg); color: var(--primary);
border-radius: 999px; padding: 4px 8px 4px 12px; font-size: 13px; font-weight: 500;
}
.time-chip button {
border: none; background: rgba(59,108,255,.15); color: var(--primary);
width: 18px; height: 18px; border-radius: 50%; cursor: pointer;
font-size: 12px; line-height: 1; display: inline-flex; align-items: center; justify-content: center;
}
.time-chip button:hover { background: var(--primary); color: #fff; }
.msg { margin-top: 10px; font-size: 13px; min-height: 20px; }
.msg.ok { color: var(--green); }
.msg.err { color: var(--red); }
/* ---------- modal ---------- */
.modal-mask {
position: fixed; inset: 0; background: rgba(15, 18, 25, .45);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
}
.modal-mask.hidden { display: none; }
.modal {
background: #fff; border-radius: 14px; width: 100%; max-width: 520px;
max-height: 90vh; overflow-y: auto; padding: 22px;
box-shadow: 0 20px 50px rgba(0,0,0,.18);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.modal-head h3 { margin: 0; font-size: 16px; }
.modal-close {
border: none; background: var(--gray-bg); width: 28px; height: 28px;
border-radius: 50%; font-size: 16px; cursor: pointer; color: var(--muted);
}
.modal-close:hover { background: var(--border); color: var(--text); }
.modal-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.mtab {
border: none; background: none; padding: 8px 12px; cursor: pointer;
font-size: 13px; color: var(--muted); border-bottom: 2px solid transparent;
}
.mtab.active { color: var(--primary); border-bottom-color: var(--primary); }
.mtabpage { display: none; }
.mtabpage.active { display: block; }
.help { border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 14px; background: #fafbfc; }
.help summary { cursor: pointer; font-size: 13px; font-weight: 500; color: var(--primary); }
.help-body { margin-top: 10px; font-size: 13px; }
.help-body p { margin: 6px 0; }
.help-body code { background: var(--gray-bg); padding: 2px 6px; border-radius: 4px; font-size: 12px; }
/* ---------- logs ---------- */
.log-view {
background: #0f1420; color: #c9d4e5; border-radius: 10px;
padding: 14px 16px; font-size: 12px; line-height: 1.7;
font-family: "SF Mono", Menlo, Consolas, "Courier New", monospace;
max-height: 560px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
margin: 0;
}
/* ---------- toast ---------- */
.toast {
position: fixed; left: 50%; bottom: 36px; transform: translateX(-50%);
background: #1f2329; color: #fff; padding: 10px 20px;
border-radius: 10px; font-size: 13px; z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,.25);
max-width: 80vw;
}
.toast.hidden { display: none; }
.toast.err { background: var(--red); }
.toast.ok { background: var(--green); }
.hidden { display: none !important; }
@media (max-width: 640px) {
.topbar { padding: 14px 16px; }
.tabs { padding: 0 12px; }
main { padding: 16px 12px 40px; }
.tab { padding: 10px 12px; }
}
+214
View File
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent 签到助手</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<img src="assets/logo.svg" alt="" class="brand-logo">
<div>
<h1>Agent 签到助手</h1>
<p class="sub">TRAE · WorkBuddy 每日积分自动签到</p>
</div>
</div>
<div class="topbar-right">
<span id="daemon-chip" class="chip chip-gray">守护进程 —</span>
<span id="nextrun-chip" class="chip chip-blue">下次签到 —</span>
<button id="btn-run-all" class="btn btn-primary">立即签到</button>
</div>
</header>
<nav class="tabs">
<button class="tab active" data-tab="dashboard">仪表盘</button>
<button class="tab" data-tab="accounts">账号管理</button>
<button class="tab" data-tab="logs">签到记录</button>
<button class="tab" data-tab="settings">设置</button>
</nav>
<main>
<!-- ======================= 仪表盘 ======================= -->
<section id="tab-dashboard" class="tabpage active">
<div id="dash-cards" class="card-grid"></div>
<div class="panel">
<h2>使用说明</h2>
<ul class="hint-list">
<li><b>TRAE</b>(字节跳动·国内版):每日自动领取 <b>200</b> Work 专属积分。</li>
<li><b>WorkBuddy / CodeBuddy</b>(腾讯):7 天循环签到,第 1-6 天每天 100 分,第 7 天 1000 分。</li>
<li><b>Qoder</b>(国内版):每日自动领取 <b>100</b> Credits(活动非零点准时下发,多时段自动补领)。</li>
<li>签到由 NAS 本地守护进程自动执行,失败会在下一个时段自动补签; Token 过期会标记需重新授权。</li>
<li>所有凭据仅保存在本机 <code>@appdata/AgentCheckin</code>,不会上传到任何第三方。</li>
</ul>
</div>
</section>
<!-- ======================= 账号管理 ======================= -->
<section id="tab-accounts" class="tabpage">
<div class="panel">
<div class="panel-head">
<h2>账号列表</h2>
<button class="btn btn-primary" id="btn-add-account">+ 添加账号</button>
</div>
<div id="account-table-wrap"></div>
</div>
</section>
<!-- ======================= 签到记录 ======================= -->
<section id="tab-logs" class="tabpage">
<div class="panel">
<div class="panel-head">
<h2>运行日志</h2>
<div>
<label class="switch-label"><input type="checkbox" id="log-autorefresh" checked> 自动刷新</label>
<button class="btn" id="btn-refresh-logs">刷新</button>
</div>
</div>
<pre id="log-view" class="log-view">加载中…</pre>
</div>
</section>
<!-- ======================= 设置 ======================= -->
<section id="tab-settings" class="tabpage">
<div class="panel">
<h2>签到时间</h2>
<p class="muted">每天在这些时间点自动检查并签到(已签会自动跳过)。错过时段后在守护进程启动时会自动补跑。</p>
<div id="time-chips" class="chips"></div>
<div class="row">
<input type="time" id="new-time" class="input input-time">
<button class="btn" id="btn-add-time">添加时间点</button>
</div>
</div>
<div class="panel">
<h2>消息通知(可选)</h2>
<p class="muted">签到后向 Webhook 发送 POST JSON(<code>{"title","content","text","msgtype"}</code>),兼容企业微信/钉钉/飞书/Bark 等机器人。留空则不通知。</p>
<input type="url" id="webhook-url" class="input input-wide" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...">
<label style="margin-top:12px">通知平台
<select id="webhook-type" class="input">
<option value="auto">自动识别(推荐)</option>
<option value="wecom">企业微信机器人</option>
<option value="dingtalk">钉钉机器人</option>
<option value="feishu">飞书机器人</option>
<option value="bark">Bark(iOS 推送)</option>
<option value="generic">通用 JSON(Server酱 / PushPlus 等)</option>
</select>
</label>
<div class="row">
<label class="switch-label"><input type="checkbox" id="notify-fail" checked> 失败时通知</label>
<label class="switch-label"><input type="checkbox" id="notify-success"> 成功时也通知</label>
</div>
<div class="row">
<button class="btn btn-primary" id="btn-save-settings">保存设置</button>
<button class="btn" id="btn-test-notify">发送测试通知</button>
</div>
<div id="settings-msg" class="msg"></div>
</div>
</section>
</main>
<!-- ======================= 添加账号弹窗 ======================= -->
<div id="modal-add" class="modal-mask hidden">
<div class="modal">
<div class="modal-head">
<h3>添加账号</h3>
<button class="modal-close" data-close="modal-add">×</button>
</div>
<div class="modal-tabs">
<button class="mtab active" data-mtab="wb-oauth">WorkBuddy 网页授权</button>
<button class="mtab" data-mtab="wb-manual">WorkBuddy 手动</button>
<button class="mtab" data-mtab="trae">TRAE</button>
<button class="mtab" data-mtab="qoder">Qoder</button>
</div>
<div id="mtab-wb-oauth" class="mtabpage active">
<p>点击下方按钮生成授权链接,在浏览器中打开并登录腾讯 CodeBuddy / WorkBuddy 账号,本页面会自动完成绑定。</p>
<button class="btn btn-primary" id="btn-oauth-begin">生成授权链接</button>
<div id="oauth-box" class="hidden">
<p>请在浏览器中打开以下链接完成登录(10 分钟内有效):</p>
<div class="row">
<input id="oauth-url" class="input input-wide" readonly>
<button class="btn" id="btn-oauth-copy">复制</button>
</div>
<p id="oauth-status" class="muted">等待登录…</p>
</div>
</div>
<div id="mtab-wb-manual" class="mtabpage">
<label>名称(可选)<input id="wb-name" class="input input-wide" placeholder="我的 CodeBuddy"></label>
<label>Access Token <span class="req">*</span><input id="wb-token" class="input input-wide" placeholder="accessToken"></label>
<label>Refresh Token(可选,用于自动续期)<input id="wb-refresh" class="input input-wide" placeholder="refreshToken"></label>
<label>区域
<select id="wb-domain" class="input">
<option value="codebuddy.cn">国内版 codebuddy.cn(支持签到)</option>
<option value="workbuddy.ai">国际版 workbuddy.ai(无签到)</option>
</select>
</label>
<button class="btn btn-primary" id="btn-wb-manual-add">添加</button>
</div>
<div id="mtab-trae" class="mtabpage">
<label>名称(可选)<input id="trae-name" class="input input-wide" placeholder="TRAE 主账号"></label>
<label>Access Token <span class="req">*</span><input id="trae-token" class="input input-wide" placeholder="eyJ 开头的 JWT"></label>
<label>Refresh Token(可选)<input id="trae-refresh" class="input input-wide" placeholder="可选"></label>
<details class="help">
<summary>如何获取 TRAE Token?</summary>
<div class="help-body">
<p>在登录了 TRAE 国内版客户端的电脑上,凭据保存在:</p>
<p><code>Windows: %APPDATA%\Trae CN\User\globalStorage\storage.json</code></p>
<p>其中键 <code>iCubeAuthInfo://icube.cloudide</code> 的值为加密串,解密后可得 <code>token</code>(即 Access Token,JWT 格式)。</p>
<p>也可使用社区工具 <code>trae_get_token.py</code>(Trae-AutoCheckin 项目)一键提取。</p>
<p class="muted">Token 约 60 天有效;过期后在本页重新粘贴即可。</p>
</div>
</details>
<button class="btn btn-primary" id="btn-trae-add">添加</button>
</div>
<div id="mtab-qoder" class="mtabpage">
<label>名称(可选)<input id="qoder-name" class="input input-wide" placeholder="Qoder 主账号"></label>
<label>Access Token <span class="req">*</span><input id="qoder-token" class="input input-wide" placeholder="dt- 开头的 JWT"></label>
<label>Refresh Token(可选,用于自动续期)<input id="qoder-refresh" class="input input-wide" placeholder="drt- 开头,可选"></label>
<label>站点区域(重要:两个站点 Token 不互通,选错会 401)
<select id="qoder-domain" class="input">
<option value="openapi.qoder.com.cn">国内版 openapi.qoder.com.cn(qoder.cn 客户端)</option>
<option value="openapi.qoder.sh">国际版 openapi.qoder.sh(qoder.com 客户端)</option>
</select>
</label>
<details class="help" open>
<summary>如何获取 Qoder Token?(三种方式)</summary>
<div class="help-body">
<p><b>方式 A · 设备授权登录(推荐,无需抓包)</b></p>
<p>在电脑上下载社区工具 <code>qoder_login.py</code>(GitHub: L0NE-6/Qoder-Daily),运行 <code>python qoder_login.py</code>,在浏览器打开授权页确认一次,工具会输出 <code>access_token|refresh_token</code>,分别粘贴到上方即可。</p>
<p><b>方式 B · 浏览器抓包(最快)</b></p>
<p>浏览器登录 Qoder 网页版 → F12 开发者工具 → 网络 → 点页面里任意功能,任选一个请求 → 在请求头里找到 <code>authorization: Bearer dt-...</code></p>
<p><b>复制技巧</b>:右键该值 →「Copy value」复制完整内容,粘贴后删掉开头的 <code>Bearer </code>。</p>
<p><b>注意</b>:Qoder 的 <code>dt-</code> Token 原生就只有 <b>27/28 个字符</b>(不是截断,这是它的原生格式),<code>drt-</code> 刷新串同理。</p>
<p><b>关键</b>:国内版(qoder.cn)与国际版(qoder.com)的 Token <b>不互通</b>——用哪个客户端登录提的 Token,上方「站点区域」就要选对应的那个,选错会直接 401。拿不准可以先用「测连接」,它会自动探测 Token 属于哪个站点。</p>
<p><b>方式 C · 本机客户端提取</b></p>
<p>在装了并登录 Qoder 客户端的 Windows 上运行社区工具 <code>01_extract.py</code>(GitHub: wallechfox/qoder-checkin),自动解密 <code>auth.v1.dat</code> 取出 Token 和设备标识。</p>
<p class="muted">Token 有效期较短,过期后重新获取即可;填了 Refresh Token 的应用会自动续期。</p>
</div>
</details>
<details class="help">
<summary>设备标识(可选,领取提示风控时填写)</summary>
<div class="help-body">
<p>Qoder 签到需要 Cosy-* 设备标识。一般情况留空即可;若提示风控/领取失败,用方式 C 的工具提取后填入:</p>
<div class="row"><input id="qoder-machine-id" class="input input-wide" placeholder="Cosy-MachineId"></div>
<div class="row"><input id="qoder-machine-token" class="input input-wide" placeholder="Cosy-MachineToken"></div>
<div class="row"><input id="qoder-machine-code" class="input input-wide" placeholder="Cosy-MachineCode"></div>
<div class="row"><input id="qoder-machine-type" class="input input-wide" placeholder="Cosy-MachineType"></div>
<div class="row"><input id="qoder-version" class="input input-wide" placeholder="Cosy-Version(客户端版本号)"></div>
</div>
</details>
<button class="btn btn-primary" id="btn-qoder-add">添加</button>
</div>
<div id="add-msg" class="msg"></div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="assets/app.js"></script>
</body>
</html>