eight-hourr/frontend/settings.js

486 lines
16 KiB
JavaScript
Raw Normal View History

2026-07-17 06:55:36 +00:00
const BOOL_FIELDS = [
"use_llm",
"use_description_variation",
"auto_fill_enabled",
];
const NUMBER_FIELDS = [
"gitlab_board_id",
"auto_fill_hour",
"auto_fill_minute",
"max_hours_per_day",
"hour_step",
"compbase_default_days",
];
const SECRET_INPUTS = new Set(["gitlab_token", "xai_api_key", "compbase_password"]);
function fillSettingsForm(data) {
const form = document.getElementById("settingsForm");
for (const el of form.querySelectorAll("[name]")) {
const name = el.name;
if (name === "dates_file_content") continue;
if (BOOL_FIELDS.includes(name)) {
el.checked = Boolean(data[name]);
continue;
}
// Never put masked secrets back into password fields (looks like a real token)
if (SECRET_INPUTS.has(name)) {
el.value = "";
el.placeholder = data[`${name}_set`]
? "已設定(重新貼上才會更新)"
: "貼上 token";
continue;
}
if (data[name] !== undefined && data[name] !== null) {
el.value = data[name];
}
}
if (data.pts_username) {
document.getElementById("ptsUsername").value = data.pts_username;
}
if (data.teams_username) {
document.getElementById("teamsUsername").value = data.teams_username;
}
document.getElementById("teamsEnabled").checked = Boolean(data.teams_calendar_enabled);
const gitlabHint = document.getElementById("gitlabTokenHint");
if (gitlabHint) {
gitlabHint.textContent = data.gitlab_token_set
? "Token 已儲存。若無法使用請重新產生並貼上新的 Personal Access Token權限至少 api。"
: "到 GitLab → Preferences → Access Tokens 建立,勾選 api。";
}
}
function collectSettingsPayload(form) {
const payload = {};
for (const el of form.querySelectorAll("[name]")) {
const name = el.name;
if (name === "dates_file_content") continue;
if (BOOL_FIELDS.includes(name)) {
payload[name] = el.checked;
continue;
}
if (NUMBER_FIELDS.includes(name)) {
const num = el.value === "" ? null : Number(el.value);
if (num !== null && !Number.isNaN(num)) payload[name] = num;
continue;
}
const value = el.value.trim();
if (name === "gitlab_milestone_title") {
payload[name] = value;
continue;
}
if (value !== "") payload[name] = value;
}
payload.teams_calendar_enabled = document.getElementById("teamsEnabled").checked;
return payload;
}
function renderAuthStatus(data) {
const ptsEl = document.getElementById("ptsAuthStatus");
if (ptsEl) {
ptsEl.textContent = data.has_pts_session ? "已連線" : "未連線";
ptsEl.className = `badge ${data.has_pts_session ? "ok" : "warn"}`;
}
const teams = data.teams_calendar || data.graph_calendar || {};
const teamsEl = document.getElementById("teamsAuthStatus");
if (teamsEl) {
if (!data.teams_calendar_enabled) {
teamsEl.textContent = "未啟用";
teamsEl.className = "badge";
} else if (teams.connected) {
teamsEl.textContent = teams.auth_mode === "graph_api" ? "Graph API" : "已連線";
teamsEl.className = "badge ok";
} else {
teamsEl.textContent = "未連線";
teamsEl.className = "badge warn";
}
}
}
async function refreshStatus() {
try {
const data = await api("/api/status");
renderAuthStatus(data);
} catch (err) {
console.error(err);
}
}
async function loadSettings() {
const [settings, dates] = await Promise.all([
api("/api/settings"),
api("/api/dates-file"),
]);
fillSettingsForm(settings);
document.getElementById("datesFileContent").value = dates.content || "";
}
async function saveSettings(event) {
event.preventDefault();
const statusEl = document.getElementById("settingsStatus");
statusEl.textContent = "儲存中…";
statusEl.className = "settings-status";
const form = document.getElementById("settingsForm");
const payload = collectSettingsPayload(form);
const datesContent = document.getElementById("datesFileContent").value;
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
await api("/api/dates-file", {
method: "PUT",
body: JSON.stringify({ content: datesContent }),
});
await loadSettings();
await refreshStatus();
statusEl.textContent = "已儲存";
statusEl.className = "settings-status ok";
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = "settings-status err";
}
}
async function ptsLogin() {
const msg = document.getElementById("ptsLoginMsg");
const username = document.getElementById("ptsUsername").value.trim();
const password = document.getElementById("ptsPassword").value;
msg.textContent = "登入中…";
msg.className = "settings-status";
try {
const data = await api("/api/pts/login", {
method: "POST",
body: JSON.stringify({ username, password, remember: true }),
});
document.getElementById("ptsPassword").value = "";
msg.textContent = data.message || "登入成功";
msg.className = "settings-status ok";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
async function ptsLogout() {
const msg = document.getElementById("ptsLoginMsg");
try {
await api("/api/pts/logout", { method: "POST" });
msg.textContent = "已清除 session";
msg.className = "settings-status";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
let _mfaPreviewTimer = null;
let _mfaMetaTimer = null;
function vncPageUrl(port) {
const host = window.location.hostname || "localhost";
const p = port || 6080;
// autoconnect + scale to fit modal
return `http://${host}:${p}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
}
function openTeamsMfaModal(view) {
const modal = document.getElementById("teamsMfaModal");
if (!modal) return;
modal.classList.remove("hidden");
const port = (view && view.vnc_port) || 6080;
const interactive = !!(view && view.interactive);
const link = document.getElementById("teamsVncLink");
const frame = document.getElementById("teamsVncFrame");
const url = vncPageUrl(port);
if (link) {
link.href = url;
link.style.display = interactive ? "" : "none";
}
if (frame) {
if (interactive) {
frame.src = url;
frame.style.display = "";
} else {
frame.removeAttribute("src");
frame.style.display = "none";
}
}
const hint = document.getElementById("teamsMfaHint");
if (hint) {
hint.innerHTML = interactive
? "帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。瀏覽器最多保持約 <strong>10 分鐘</strong>,完成前不會關掉。"
: "請在跳出的 <strong>Chromium 視窗</strong>完成 MFA。右側會顯示即時截圖若有。";
}
const status = document.getElementById("teamsMfaStatus");
if (status) {
status.textContent = (view && view.message) || "登入中…";
status.className = "settings-status";
}
startMfaPreviewPoll();
}
function closeTeamsMfaModal() {
stopMfaPreviewPoll();
const modal = document.getElementById("teamsMfaModal");
if (modal) modal.classList.add("hidden");
const frame = document.getElementById("teamsVncFrame");
if (frame) {
frame.removeAttribute("src");
}
}
function startMfaPreviewPoll() {
stopMfaPreviewPoll();
const img = document.getElementById("teamsMfaShot");
const meta = document.getElementById("teamsMfaShotMeta");
const status = document.getElementById("teamsMfaStatus");
const tick = () => {
if (img) {
img.src = `/api/teams/mfa-preview?t=${Date.now()}`;
}
};
tick();
_mfaPreviewTimer = setInterval(tick, 1500);
_mfaMetaTimer = setInterval(async () => {
try {
const view = await api("/api/teams/browser-view");
if (status && view.login_message) {
status.textContent = view.login_message;
}
if (meta) {
const parts = [];
if (view.mfa_preview_label) parts.push(view.mfa_preview_label);
if (view.mfa_page_url) parts.push(view.mfa_page_url.replace(/^https?:\/\//, "").slice(0, 48));
meta.textContent = parts.join(" · ") || "更新中";
}
} catch {
/* ignore */
}
}, 2000);
}
function stopMfaPreviewPoll() {
if (_mfaPreviewTimer) {
clearInterval(_mfaPreviewTimer);
_mfaPreviewTimer = null;
}
if (_mfaMetaTimer) {
clearInterval(_mfaMetaTimer);
_mfaMetaTimer = null;
}
}
async function teamsLogin() {
const msg = document.getElementById("teamsLoginMsg");
const username = document.getElementById("teamsUsername").value.trim();
const password = document.getElementById("teamsPassword").value;
if (!username || !password) {
msg.textContent = "請輸入 Microsoft 帳號與密碼";
msg.className = "settings-status err";
return;
}
msg.textContent = "登入中…(請在 MFA 畫面完成驗證)";
msg.className = "settings-status";
document.getElementById("teamsEnabled").checked = true;
let view = {};
try {
view = await api("/api/teams/browser-view");
} catch {
view = { interactive: false, vnc_port: 6080 };
}
openTeamsMfaModal(view);
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify({ teams_calendar_enabled: true }),
});
const data = await api("/api/teams/login", {
method: "POST",
body: JSON.stringify({ username, password, remember: true, headless: false }),
});
document.getElementById("teamsPassword").value = "";
msg.textContent = data.message || "登入成功";
msg.className = "settings-status ok";
const st = document.getElementById("teamsMfaStatus");
if (st) {
st.textContent = data.message || "登入成功";
st.className = "settings-status ok";
}
await refreshStatus();
setTimeout(closeTeamsMfaModal, 1500);
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
const st = document.getElementById("teamsMfaStatus");
if (st) {
st.textContent = err.message;
st.className = "settings-status err";
}
}
}
async function teamsLogout() {
const msg = document.getElementById("teamsLoginMsg");
try {
await api("/api/teams/logout", { method: "POST" });
msg.textContent = "已中斷";
msg.className = "settings-status";
await refreshStatus();
} catch (err) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
function escapeHtml(text) {
return String(text ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function initTeamsTestDates() {
const today = new Date().toISOString().slice(0, 10);
const startEl = document.getElementById("teamsTestStart");
const endEl = document.getElementById("teamsTestEnd");
if (startEl && !startEl.value) startEl.value = today;
if (endEl && !endEl.value) endEl.value = today;
}
async function teamsShowStatus() {
const msg = document.getElementById("teamsTestMsg");
const debug = document.getElementById("teamsTestDebug");
try {
const data = await api("/api/teams/status");
if (msg) {
msg.textContent = data.connected
? `連線中(${data.auth_mode || "session"}`
: "未連線或 session 過期,請先登入 Teams";
msg.className = `settings-status ${data.connected ? "ok" : "err"}`;
}
if (debug) debug.textContent = JSON.stringify(data, null, 2);
} catch (err) {
if (msg) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
}
}
async function teamsTestMeetings() {
const msg = document.getElementById("teamsTestMsg");
const summary = document.getElementById("teamsTestSummary");
const list = document.getElementById("teamsTestList");
const debug = document.getElementById("teamsTestDebug");
const start = document.getElementById("teamsTestStart")?.value;
const end = document.getElementById("teamsTestEnd")?.value || start;
const skip = document.getElementById("teamsTestSkipHolidays")?.checked !== false;
if (!start) {
if (msg) {
msg.textContent = "請選開始日期";
msg.className = "settings-status err";
}
return;
}
if (msg) {
msg.textContent = "測試中…(可能開瀏覽器做 MFA";
msg.className = "settings-status";
}
if (list) list.innerHTML = "";
if (summary) summary.innerHTML = "";
if (debug) debug.textContent = "請求中…";
try {
const q = new URLSearchParams({
start_date: start,
end_date: end,
skip_holidays: skip ? "true" : "false",
debug: "true",
});
const data = await api(`/api/teams/meetings?${q.toString()}`);
if (summary) {
summary.innerHTML = [
`<span class="pill"><span class="pill-label">會議</span><span class="pill-value">${data.total_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">時數</span><span class="pill-value">${data.total_hours ?? 0}h</span></span>`,
`<span class="pill"><span class="pill-label">天數</span><span class="pill-value">${data.workday_count ?? 0}</span></span>`,
`<span class="pill"><span class="pill-label">連線</span><span class="pill-value">${data.connected ? "是" : "否"}</span></span>`,
].join("");
}
const days = (data.days || []).filter((d) => d.count > 0);
if (list) {
if (!days.length) {
list.innerHTML = `<p class="hint">${escapeHtml(data.fetch_hint || data.error || "0 筆會議")}</p>`;
} else {
list.innerHTML = days
.map((day) => {
const rows = (day.meetings || [])
.map(
(m) =>
`<li class="plan-row"><span class="meeting-hours">${escapeHtml(String(m.hours))}h</span>` +
`<span class="meeting-time">${escapeHtml(m.time_label || "")}</span>` +
`<span class="meeting-subject">${escapeHtml(m.subject || "")}</span></li>`
)
.join("");
return `<div class="plan-day"><div class="plan-day-head"><strong>${escapeHtml(day.date)}</strong>` +
`<span class="muted-inline">${day.count} 場 · ${day.hours}h</span></div>` +
`<ul class="meeting-items">${rows}</ul></div>`;
})
.join("");
}
}
if (msg) {
if (data.error) {
msg.textContent = data.error;
msg.className = "settings-status err";
} else if ((data.total_count || 0) > 0) {
msg.textContent = `成功:${data.total_count} 場會議 · ${data.total_hours}h`;
msg.className = "settings-status ok";
} else {
msg.textContent = data.fetch_hint || "完成但 0 筆會議";
msg.className = "settings-status err";
}
}
if (debug) debug.textContent = JSON.stringify(data, null, 2);
} catch (err) {
if (msg) {
msg.textContent = err.message;
msg.className = "settings-status err";
}
if (debug) debug.textContent = err.message;
}
}
document.getElementById("settingsForm").addEventListener("submit", saveSettings);
document.getElementById("ptsLoginBtn").addEventListener("click", ptsLogin);
document.getElementById("ptsLogoutBtn").addEventListener("click", ptsLogout);
document.getElementById("teamsLoginBtn").addEventListener("click", teamsLogin);
document.getElementById("teamsLogoutBtn").addEventListener("click", teamsLogout);
document.getElementById("teamsTestBtn")?.addEventListener("click", () => {
teamsTestMeetings().catch(console.error);
});
document.getElementById("teamsStatusBtn")?.addEventListener("click", () => {
teamsShowStatus().catch(console.error);
});
document.getElementById("teamsMfaClose")?.addEventListener("click", () => {
// Only hide preview; login request keeps running until MFA done or timeout
closeTeamsMfaModal();
});
document.getElementById("teamsMfaShot")?.addEventListener("error", () => {
/* 404 until first snapshot — ignore */
});
initTeamsTestDates();
loadSettings().then(refreshStatus).catch(console.error);