486 lines
16 KiB
JavaScript
486 lines
16 KiB
JavaScript
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
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);
|