// ==UserScript== // @name 洛谷队务助手 — 全员榜单与跨团队管理 // @namespace luogu-team-helper // @version 0.2.0 // @description 洛谷团队管理增强:原生作业全员榜单、跨团队重复成员提示(测试版) // @author fsfgrafb // @tag 洛谷 // @tag 团队管理 // @match https://www.luogu.com.cn/* // @run-at document-start // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @grant GM_addStyle // ==/UserScript== (() => { // src/core.js var MAX_CONCURRENCY = 16; var DEFAULT_SETTINGS = { schemaVersion: 1, enabled: false, linkedTeamIds: [], concurrency: 4, excludeAdmins: false }; function normalizeConcurrency(value) { const number = typeof value === "number" || typeof value === "string" ? Number(value) : NaN; return Number.isInteger(number) && number >= 1 && number <= MAX_CONCURRENCY ? number : DEFAULT_SETTINGS.concurrency; } var requestInterval = (count) => Math.max(125, Math.min(250, Math.ceil(1e3 / normalizeConcurrency(count)))); function parseTeamIds(text) { const ids = [], invalid = []; for (const token of text.trim().split(/[\s,,;;]+/).filter(Boolean)) { let id = token; if (!/^\d+$/.test(token)) { try { const url = new URL(token.startsWith("/") ? `https://www.luogu.com.cn${token}` : token); if (url.origin !== "https://www.luogu.com.cn") throw Error(); id = url.pathname.match(/^\/team\/(\d+)(?:\/member)?\/?$/)?.[1]; if (url.search || url.hash) throw Error(); } catch { id = null; } } if (!id || !/^\d+$/.test(id) || BigInt(id) <= 0n || BigInt(id) > BigInt(Number.MAX_SAFE_INTEGER)) invalid.push(token); else if (!ids.includes(String(Number(id)))) ids.push(String(Number(id))); } return { ids, invalid }; } function normalizeSettings(value) { if (!value || typeof value !== "object") return structuredClone(DEFAULT_SETTINGS); return { schemaVersion: 1, enabled: value.enabled === true, concurrency: normalizeConcurrency(value.concurrency), excludeAdmins: value.excludeAdmins === true, linkedTeamIds: parseTeamIds(Array.isArray(value.linkedTeamIds) ? value.linkedTeamIds.join("\n") : "").ids }; } function unionMembers(teams) { const users2 = /* @__PURE__ */ new Map(); for (const team of teams) for (const member of team.members) { const uid = Number(member.user?.uid); if (!Number.isSafeInteger(uid) || uid <= 0) throw Error("\u56E2\u961F\u540D\u5355\u7F3A\u5C11\u6709\u6548 UID"); const entry = users2.get(uid) || { member, teams: [] }; if (!entry.teams.some((t) => t.id === team.id)) entry.teams.push({ id: team.id, name: team.name, type: member.type }); users2.set(uid, entry); } return users2; } function rankMembers(members, excludeAdmins) { return excludeAdmins ? new Map([...members].filter(([, entry]) => !entry.teams.some((team) => [2, 3].includes(Number(team.type))))) : members; } var FINAL_STATUSES = /* @__PURE__ */ new Set([2, 3, 4, 5, 6, 7, 12, 14]); function aggregateRecords(records, problems2, deadline) { const score = {}, status = {}, sources = {}, pending = /* @__PURE__ */ new Set(); const pids = new Set(problems2.map((p) => p.pid)); for (const record of records) { const pid = record.problem?.pid; if (!pids.has(pid)) continue; if (!Number.isFinite(record.submitTime)) { pending.add(pid); continue; } if (deadline && record.submitTime > deadline) continue; if (!FINAL_STATUSES.has(record.status)) { pending.add(pid); continue; } if (record.status === 12) status[pid] = true; const value = typeof record.score === "number" && Number.isFinite(record.score) ? record.score : record.status === 2 ? 0 : null; if (value === null || value < 0) { pending.add(pid); continue; } if (!(pid in score) || value > score[pid]) { score[pid] = value; sources[pid] = { id: record.id, time: record.submitTime }; } } return { score, status, sources, unknown: [...pending], totalScore: Object.values(score).reduce((a, b) => a + b, 0) }; } function combineRows(original, members, supplemental) { const originalMap = new Map(original.map((r, index) => [Number(r.user.uid), { r, index }])); return [...members].map(([uid, entry]) => { const existing = originalMap.get(uid); if (existing) return { ...existing.r, _lgh: { original: true, index: existing.index, complete: true } }; const extra = supplemental.get(uid); return { user: entry.member.user, teamMember: entry.member, score: extra?.score || {}, status: extra?.status || {}, totalScore: extra?.totalScore || 0, _lgh: { original: false, complete: !!extra?.complete, error: extra?.error, sources: extra?.sources || {}, index: Number.MAX_SAFE_INTEGER } }; }).sort((a, b) => Number(b._lgh.complete) - Number(a._lgh.complete) || b.totalScore - a.totalScore || a._lgh.index - b._lgh.index || Number(a.user.uid) - Number(b.user.uid)); } function assertPage(page2, field) { const data = page2?.[field]; if (!data || !Array.isArray(data.result) || !Number.isInteger(data.count) || data.count < 0 || !Number.isInteger(data.perPage) || data.perPage < 1) { throw Error(`${field === "members" ? "\u6210\u5458" : "\u8BB0\u5F55"}\u9875\u9762\u6570\u636E\u7ED3\u6784\u5DF2\u53D8\u5316`); } return data; } // src/storage.js var SETTINGS = "lgh:settings:v1"; function createStorage(win, gm2 = {}) { const get = (key, fallback) => { try { return gm2.get ? gm2.get(key, fallback) : JSON.parse(win.localStorage.getItem(key) || "null") ?? fallback; } catch { return fallback; } }; const set = (key, value) => { if (gm2.set) gm2.set(key, value); else win.localStorage.setItem(key, JSON.stringify(value)); }; return { settings: () => normalizeSettings(get(SETTINGS, null)), save: (value) => set(SETTINGS, normalizeSettings(value)), listen(callback) { const local = (e) => { if (e.key === SETTINGS) callback(); }; win.addEventListener("storage", local); const id = gm2.listen?.(SETTINGS, (_k, _old, _new, remote) => { if (remote) callback(); }); return () => { win.removeEventListener("storage", local); if (id !== void 0) gm2.unlisten?.(id); }; }, cacheGet(account2, key, ttl) { const item = get(`lgh:cache:${account2}:${key}`, null); return item && Date.now() - item.time < ttl ? item.value : null; }, clearGrades(account2) { const indexKey = `lgh:cache-index:${account2}`, index = get(indexKey, []); for (const key of index) if (/^(grade:|progress:v2:)/.test(key)) set(`lgh:cache:${account2}:${key}`, null); set(indexKey, index.filter((key) => !/^(grade:|progress:v2:)/.test(key))); }, cacheSet(account2, key, value) { const indexKey = `lgh:cache-index:${account2}`; const index = get(indexKey, []).filter((k) => k !== key); index.push(key); while (index.length > 160) set(`lgh:cache:${account2}:${index.shift()}`, null); set(`lgh:cache:${account2}:${key}`, { time: Date.now(), value }); set(indexKey, index); } }; } // src/concurrency.js var RequestQueue = class { constructor(signal, check, { concurrency = 4, interval: interval2 = 250 } = {}) { this.signal = signal; this.check = check; this.concurrency = concurrency; this.interval = interval2; this.pending = []; this.running = 0; this.lastStart = -Infinity; this.timer = null; signal.addEventListener("abort", () => this.drain(), { once: true }); } run(operation) { return new Promise((resolve, reject) => { this.pending.push({ operation, resolve, reject }); this.drain(); }); } drain() { clearTimeout(this.timer); this.timer = null; try { this.check(); } catch (error) { for (const job of this.pending.splice(0)) job.reject(error); return; } while (this.pending.length && this.running < this.concurrency) { const wait = this.interval - (Date.now() - this.lastStart); if (wait > 0) { this.timer = setTimeout(() => this.drain(), wait); return; } const job = this.pending.shift(); this.running++; this.lastStart = Date.now(); Promise.resolve().then(() => { this.check(); return job.operation(); }).then(job.resolve, job.reject).finally(() => { this.running--; this.drain(); }); } } }; async function mapConcurrent(items, concurrency, operation) { const results = new Array(items.length); let next = 0; await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (next < items.length) { const index = next++; results[index] = await operation(items[index], index); } })); return results; } // src/api.js function readContext(doc2) { const el = doc2.getElementById("lentille-context"); if (!el) throw Error("\u9875\u9762\u672A\u8FD4\u56DE\u7ED3\u6784\u5316\u6570\u636E\uFF0C\u53EF\u80FD\u9700\u8981\u767B\u5F55\u6216\u9A8C\u8BC1\u7801"); const context = JSON.parse(el.textContent); if (context.status !== 200) throw Error(`\u9875\u9762\u65E0\u6743\u8BBF\u95EE\uFF08${context.status}\uFF09`); return context; } var ReadApi = class { constructor(win, store2, account2, signal, queueOptions) { this.win = win; this.store = store2; this.account = account2; this.signal = signal; this.paused = null; this.queue = new RequestQueue(signal, () => this.check(), queueOptions); this.concurrency = this.queue.concurrency; } check() { this.signal.throwIfAborted(); if (this.paused) throw Error(this.paused); } async text(path2) { this.check(); const timeout = AbortSignal.timeout(25e3); const signal = AbortSignal.any([this.signal, timeout]); let response; try { response = await this.win.fetch(new URL(path2, this.win.location.origin), { credentials: "same-origin", signal, headers: { Accept: "text/html" } }); } catch (error) { this.signal.throwIfAborted(); this.paused = timeout.aborted ? "\u8BFB\u53D6\u8D85\u65F6\uFF0C\u5DF2\u6682\u505C\u3002\u8BF7\u7A0D\u540E\u91CD\u8BD5" : "\u7F51\u7EDC\u8BFB\u53D6\u5931\u8D25\uFF0C\u5DF2\u6682\u505C\u3002\u8BF7\u68C0\u67E5\u7F51\u7EDC\u540E\u91CD\u8BD5"; throw Error(this.paused); } if ([401, 403, 429].includes(response.status)) { this.paused = response.status === 429 ? "\u8BF7\u6C42\u9891\u7387\u53D7\u9650\uFF0C\u5DF2\u6682\u505C\u3002\u7A0D\u540E\u70B9\u51FB\u91CD\u8BD5" : "\u767B\u5F55\u6216\u8BBF\u95EE\u6821\u9A8C\u53D7\u9650\uFF0C\u5DF2\u6682\u505C\u3002\u8BF7\u5148\u5728\u539F\u9875\u9762\u5904\u7406"; throw Error(this.paused); } if (!response.ok) throw Error(`\u8BFB\u53D6\u5931\u8D25\uFF1AHTTP ${response.status}`); if (new URL(response.url).origin !== this.win.location.origin || /\/auth\//.test(response.url)) { this.paused = "\u767B\u5F55\u72B6\u6001\u5931\u6548\uFF0C\u8BF7\u5728\u6D1B\u8C37\u91CD\u65B0\u767B\u5F55"; throw Error(this.paused); } return await response.text(); } async page(path2) { return this.queue.run(async () => { const text = await this.text(path2); this.check(); try { const context = readContext(new this.win.DOMParser().parseFromString(text, "text/html")); if (Number(context.user?.uid || 0) !== this.account) throw Error("\u8D26\u53F7\u5DF2\u53D8\u5316\uFF0C\u8BF7\u91CD\u65B0\u5F00\u542F\u811A\u672C"); return context.data; } catch (error) { if (/验证码|登录|校验/.test(error.message)) this.paused = error.message; throw error; } }); } async pages(path2, field, first = null) { const records = []; const seen = /* @__PURE__ */ new Set(); let count = 0, perPage = 0; for (let page2 = 1; page2 <= 200; page2++) { this.check(); const url = new URL(path2, this.win.location.origin); if (page2 > 1) url.searchParams.set("page", String(page2)); const payload = page2 === 1 && first ? first : await this.page(url.pathname + url.search); const data = assertPage(payload, field); if (page2 === 1) { count = data.count; perPage = data.perPage; } if (data.count !== count || data.perPage !== perPage) throw Error("\u5206\u9875\u671F\u95F4\u6570\u636E\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u91CD\u8BD5"); for (const row of data.result) { const id = field === "members" ? row.user?.uid : row.id; if (!id || seen.has(id)) throw Error("\u5206\u9875\u91CD\u590D\u6216\u8BB0\u5F55\u6807\u8BC6\u7F3A\u5931\uFF0C\u672A\u53D6\u5F97\u5B8C\u6574\u6570\u636E"); seen.add(id); records.push(row); } if (records.length === count) return records; if (records.length > count || !data.result.length) throw Error("\u5206\u9875\u4E0D\u5B8C\u6574"); } throw Error("\u8BB0\u5F55\u9875\u6570\u8FC7\u591A\uFF0C\u672A\u5B8C\u6210\u91C7\u96C6"); } async team(id) { const cached = this.store.cacheGet(this.account, `team:${id}`, 5 * 6e4); if (cached) return cached; const path2 = `/team/${id}/member`, data = await this.page(path2); const members = await this.pages(path2, "members", data); if (String(data.team?.id) !== String(id)) throw Error("\u56E2\u961F\u6570\u636E\u4E0D\u5339\u914D"); const team = { id: String(id), name: data.team.name, members }; this.check(); this.store.cacheSet(this.account, `team:${id}`, team); return team; } async grades(uid, training, { fresh = false } = {}) { const signature = training.problems.map((p) => `${p.pid}:${p.fullScore ?? p.problem?.fullScore ?? 100}`).join(","); const key = `progress:v2:${training.id}:${training.deadline || 0}:${signature}:${uid}`; const previous = fresh ? null : this.store.cacheGet(this.account, key, 30 * 864e5); const summarize = (records, problems2) => ({ ...aggregateRecords(records, problems2, training.deadline), verified: Object.fromEntries(problems2.map((p) => [p.pid, Date.now()])) }); const passed = training.problems.filter((p) => previous?.status?.[p.pid] === true && !previous.unknown?.includes(p.pid) && previous.score?.[p.pid] >= Number(p.fullScore ?? p.problem?.fullScore ?? 100) && Number.isFinite(previous.verified?.[p.pid]) && Date.now() - previous.verified[p.pid] >= 0 && Date.now() - previous.verified[p.pid] < 30 * 864e5); const frozen = new Set(passed.map((p) => p.pid)); const remaining = training.problems.filter((p) => !frozen.has(p.pid)); const parts = passed.map((p) => ({ score: { [p.pid]: previous.score[p.pid] }, status: { [p.pid]: true }, sources: previous.sources?.[p.pid] ? { [p.pid]: previous.sources[p.pid] } : {}, unknown: [], verified: { [p.pid]: previous.verified[p.pid] } })); if (remaining.length) { let first = null; if (!passed.length) first = await this.page(`/record/list?user=${uid}`); if (first && assertPage(first, "records").count <= 100) { const records = await this.pages(`/record/list?user=${uid}`, "records", first); parts.push(summarize(records, training.problems)); } else { const batches = await mapConcurrent(remaining, this.concurrency, async (problem) => { try { const records = await this.pages(`/record/list?user=${uid}&pid=${encodeURIComponent(problem.pid)}`, "records"); return summarize(records, [problem]); } catch (error) { this.signal.throwIfAborted(); return { score: {}, status: {}, sources: {}, unknown: [problem.pid], error: error.message }; } }); parts.push(...batches); } } const grade = { score: {}, status: {}, sources: {}, unknown: [], verified: {} }; for (const part of parts) { Object.assign(grade.score, part.score); Object.assign(grade.status, part.status); Object.assign(grade.sources, part.sources); Object.assign(grade.verified, part.verified); grade.unknown.push(...part.unknown); if (part.error) grade.error = part.error; } grade.unknown = [...new Set(grade.unknown)]; grade.complete = grade.unknown.length === 0; grade.totalScore = Object.values(grade.score).reduce((sum, score) => sum + score, 0); if (!grade.complete && !grade.error) grade.error = "\u5B58\u5728\u672A\u5B8C\u6210\u5224\u5B9A\u6216\u4E0D\u53EF\u89C1\u8BB0\u5F55"; this.check(); this.store.cacheSet(this.account, key, grade); return grade; } }; // src/bridge.js function installScoreboardBridge(win, adapt, remember = () => { }) { const NativeXHR = win.XMLHttpRequest; const nativeFetch = win.fetch; const match = (method, raw) => { try { const url = new URL(raw, win.location.href); return String(method || "GET").toUpperCase() === "GET" && url.origin === win.location.origin && url.pathname.match(/^\/api\/training\/scoreboard\/(\d+)$/)?.[1]; } catch { return null; } }; let active2 = true; class HelperXHR extends NativeXHR { constructor() { super(); this._lghId = null; this._lghReplay = false; this._lghPending = false; this._lghText = null; for (const type of ["readystatechange", "load", "loadend"]) { this.addEventListener(type, (event) => { if (!active2 || !this._lghId || this._lghReplay || this.readyState !== 4 || this.status !== 200) return; if (!["", "text", "json"].includes(this.responseType)) return; let original; try { original = this.responseType === "json" ? super.response : JSON.parse(super.responseText); } catch { return; } if (!Array.isArray(original?.scoreboard)) return; event.stopImmediatePropagation(); if (this._lghPending) return; this._lghPending = true; remember(this._lghId, original); Promise.resolve().then(() => adapt(this._lghId, original)).then((result) => { this._lghText = JSON.stringify(result ?? original); }).catch(() => { this._lghText = JSON.stringify(original); }).finally(() => { this._lghReplay = true; for (const name of ["readystatechange", "load", "loadend"]) this.dispatchEvent(new win.ProgressEvent(name)); }); }, true); } } open(method, url, ...rest) { this._lghText = null; this._lghReplay = false; this._lghPending = false; this._lghId = rest[0] === false ? null : match(method, url); return super.open(method, url, ...rest); } get responseText() { return this._lghText ?? super.responseText; } get response() { return this._lghText === null ? super.response : this.responseType === "json" ? JSON.parse(this._lghText) : this._lghText; } } win.XMLHttpRequest = HelperXHR; const wrappedFetch = async function(input, init) { const response = await nativeFetch.call(win, input, init); const id = match(init?.method || input?.method, typeof input === "string" ? input : input?.url ?? input?.href); if (!active2 || !id || !response.ok) return response; try { const original = await response.clone().json(); remember(id, original); const result = await adapt(id, original); if (!result || result === original) return response; const headers = new win.Headers(response.headers); headers.delete("content-length"); headers.delete("content-encoding"); return new win.Response(JSON.stringify(result), { status: response.status, headers }); } catch { return response; } }; win.fetch = wrappedFetch; return () => { active2 = false; if (win.XMLHttpRequest === HelperXHR) win.XMLHttpRequest = NativeXHR; if (win.fetch === wrappedFetch) win.fetch = nativeFetch; }; } // src/ui.js var GEAR = ''; function element(doc2, tag, cls, text) { const node = doc2.createElement(tag); if (cls) node.className = cls; if (text !== void 0) node.textContent = text; return node; } function mountNav(doc2, settings2, toggle, open) { let nav = doc2.getElementById("lgh-nav"); if (!nav) { const target = doc2.querySelector(".top-bar > .right"); if (!target) return; target.classList.add("lgh-nav-host"); nav = element(doc2, "span", "lgh-nav"); nav.id = "lgh-nav"; nav.append(element(doc2, "span", "lgh-name", "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B")); const button2 = element(doc2, "button", "lgh-switch"); button2.type = "button"; button2.setAttribute("role", "switch"); button2.setAttribute("aria-label", "\u542F\u7528 \u6D1B\u8C37\u961F\u52A1\u52A9\u624B"); button2.onclick = toggle; nav.append(button2); const gear = element(doc2, "button", "lgh-icon"); gear.type = "button"; gear.title = "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u8BBE\u7F6E"; gear.setAttribute("aria-label", "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u8BBE\u7F6E"); gear.innerHTML = GEAR; gear.onclick = open; nav.append(gear); target.prepend(nav); } const checked = String(settings2.enabled); const button = nav.querySelector("[role=switch]"); if (button.getAttribute("aria-checked") !== checked) button.setAttribute("aria-checked", checked); const title = settings2.enabled ? "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u5DF2\u542F\u7528\uFF0C\u70B9\u51FB\u505C\u7528\u5E76\u6062\u590D\u539F\u9875\u9762" : "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u5DF2\u505C\u7528"; if (button.title !== title) button.title = title; return nav; } function settingsDialog(doc2, settings2, save, teamInfo2, closeCallback = () => { }, clearCache = null) { doc2.getElementById("lgh-settings")?.close(); const returnFocus = doc2.activeElement; const dialog2 = element(doc2, "dialog", "lgh-dialog lfe-body"); dialog2.id = "lgh-settings"; dialog2.setAttribute("aria-labelledby", "lgh-settings-title"); const head = element(doc2, "div", "lgh-dialog-head"); const title = element(doc2, "h3", "", "\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u8BBE\u7F6E"); title.id = "lgh-settings-title"; head.append(title); const close = element(doc2, "button", "lgh-icon", "\xD7"); close.type = "button"; close.setAttribute("aria-label", "\u5173\u95ED\u8BBE\u7F6E"); close.onclick = () => dialog2.close(); head.append(close); dialog2.append(head); const label = element(doc2, "label", "", "\u5173\u8054\u56E2\u961F"); label.htmlFor = "lgh-team-input"; dialog2.append(label); const input = element(doc2, "textarea", "lgh-input"); input.id = "lgh-team-input"; input.value = settings2.linkedTeamIds.join("\n"); input.placeholder = "139185\n139186"; input.spellcheck = false; dialog2.append(input); dialog2.append(element(doc2, "p", "lgh-caption", "\u652F\u6301\u56E2\u961F ID \u6216\u6D1B\u8C37\u56E2\u961F\u94FE\u63A5\uFF0C\u7528\u7A7A\u683C\u3001\u9017\u53F7\u6216\u6362\u884C\u5206\u9694\u3002\u5168\u5C40\u5171\u4EAB\uFF1B\u5F00\u542F\u540E\u7EDF\u8BA1\u8FD9\u4E9B\u56E2\u961F\u7684\u6210\u5458\u5E76\u96C6\u3002")); const error = element(doc2, "div", "lgh-caption lgh-error"); error.setAttribute("aria-live", "polite"); dialog2.append(error); const list = element(doc2, "div", "lgh-settings-list"); dialog2.append(list); const concurrencyLabel = element(doc2, "label", "", "\u5E76\u53D1\u6570\u91CF"); concurrencyLabel.htmlFor = "lgh-concurrency"; dialog2.append(concurrencyLabel); const concurrency = element(doc2, "select", "lgh-input lgh-select"); concurrency.id = "lgh-concurrency"; for (let count = 1; count <= MAX_CONCURRENCY; count++) { const option = element(doc2, "option", "", `${count}${count === 4 ? "\uFF08\u9ED8\u8BA4\uFF09" : ""}`); option.value = String(count); concurrency.append(option); } concurrency.value = String(normalizeConcurrency(settings2.concurrency)); dialog2.append(concurrency); dialog2.append(element(doc2, "p", "lgh-caption", "\u6240\u6709\u8BFB\u53D6\u4EFB\u52A1\u5171\u4EAB\u5E76\u53D1\u4E0A\u9650\u3002\u9ED8\u8BA4\u95F4\u9694 250ms\uFF0C\u63D0\u9AD8\u5E76\u53D1\u65F6\u6700\u4F4E\u4E3A 125ms\uFF1B\u9047\u5230\u9650\u6D41\u6682\u505C\u3002\u4FDD\u5B58\u540E\u91CD\u65B0\u7EDF\u8BA1\uFF0C\u590D\u7528\u6709\u6548\u7F13\u5B58\u3002")); const adminLabel = element(doc2, "label", "lgh-checkbox"); const excludeAdmins = element(doc2, "input"); excludeAdmins.type = "checkbox"; excludeAdmins.checked = settings2.excludeAdmins === true; adminLabel.append(excludeAdmins, doc2.createTextNode("\u6392\u884C\u699C\u6392\u9664\u7BA1\u7406\u5458")); dialog2.append(adminLabel); dialog2.append(element(doc2, "p", "lgh-caption", "\u6392\u9664\u4EFB\u4E00\u5173\u8054\u56E2\u961F\u4E2D\u7684\u7BA1\u7406\u5458\u53CA\u521B\u5EFA\u8005\uFF0C\u4EC5\u5F71\u54CD\u589E\u5F3A\u6392\u884C\u699C\uFF0C\u4E0D\u5F71\u54CD\u91CD\u590D\u6210\u5458\u6807\u7B7E\u3002\u5DF2\u901A\u8FC7\u9898\u76EE\u7F13\u5B58\u6700\u957F 30 \u5929\uFF0C\u4E0B\u6B21\u53EA\u5237\u65B0\u672A\u5B8C\u6210\u9898\u76EE\uFF1B\u91CD\u6D4B\u6216\u6539\u5206\u540E\u8BF7\u6E05\u9664\u6210\u7EE9\u7F13\u5B58\u3002")); if (clearCache) { const clear = element(doc2, "button", "lgh-button", "\u6E05\u9664\u6210\u7EE9\u7F13\u5B58"); clear.type = "button"; clear.onclick = () => { clearCache(); clear.textContent = "\u6210\u7EE9\u7F13\u5B58\u5DF2\u6E05\u9664"; }; dialog2.append(clear); } dialog2.append(element(doc2, "p", "lgh-caption", "\u5173\u95ED\u65F6\u4FDD\u5B58\u53EA\u4FEE\u6539\u672C\u5730\u914D\u7F6E\uFF0C\u4E0D\u4E3B\u52A8\u8BFB\u53D6\u56E2\u961F\u3002")); const footer = element(doc2, "div", "lgh-footer"); const cancel = element(doc2, "button", "lgh-button", "\u53D6\u6D88"); cancel.type = "button"; cancel.onclick = () => dialog2.close(); const submit = element(doc2, "button", "lgh-button solid", "\u4FDD\u5B58"); submit.type = "button"; footer.append(cancel, submit); dialog2.append(footer); const update = () => { const parsed = parseTeamIds(input.value); error.textContent = parsed.invalid.length ? `\u65E0\u6548\u8F93\u5165\uFF1A${parsed.invalid.join("\u3001")}` : ""; const validConcurrency = !!concurrency.value && normalizeConcurrency(concurrency.value) === Number(concurrency.value); if (!validConcurrency) error.textContent = `\u8BF7\u9009\u62E9 1\u2013${MAX_CONCURRENCY} \u7684\u5E76\u53D1\u6570\u91CF`; submit.disabled = !!parsed.invalid.length || !validConcurrency; input.setAttribute("aria-invalid", String(!!parsed.invalid.length)); list.replaceChildren(); for (const id of parsed.ids) { const item = element(doc2, "div", "lgh-settings-item"), info = teamInfo2(id); item.append(element(doc2, "span", "", `${info?.name || "\u56E2\u961F"} #${id}`), element(doc2, "span", `lgh-caption${info?.error ? " lgh-error" : ""}`, info?.error || (info?.name ? "\u5DF2\u8BFB\u53D6" : "\u672A\u9A8C\u8BC1"))); list.append(item); } }; input.oninput = update; concurrency.onchange = update; submit.onclick = () => { const parsed = parseTeamIds(input.value); if (!parsed.invalid.length && concurrency.value && normalizeConcurrency(concurrency.value) === Number(concurrency.value)) { save({ linkedTeamIds: parsed.ids, concurrency: Number(concurrency.value), excludeAdmins: excludeAdmins.checked }); dialog2.close(); } }; dialog2.addEventListener("close", () => { dialog2.remove(); if (returnFocus?.isConnected) returnFocus.focus(); closeCallback(); }, { once: true }); dialog2.addEventListener("click", (event) => { if (event.target === dialog2) { const r = dialog2.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) dialog2.close(); } }); (doc2.querySelector(".theme-page") || doc2.body).append(dialog2); update(); dialog2.showModal(); input.focus(); return { dialog: dialog2, update }; } function setStatus(doc2, message2) { doc2.getElementById("lgh-status")?.remove(); const gear = doc2.querySelector("#lgh-nav .lgh-icon"); if (gear) gear.title = `\u6D1B\u8C37\u961F\u52A1\u52A9\u624B \u8BBE\u7F6E${message2 ? `\uFF1A${message2}` : ""}`; } function markDuplicates(doc2, currentTeamId, users2) { for (const row of doc2.querySelectorAll("main .member-card")) { const link = row.querySelector('.left a[href^="/user/"]'); const uid = Number(link?.getAttribute("href")?.match(/^\/user\/(\d+)/)?.[1]); const teams = users2.get(uid)?.teams.filter((t) => t.id !== String(currentTeamId)) || []; const signature = teams.map((t) => `${t.id}:${t.name}`).join("|"); if (row.dataset.lghTeams === signature) continue; row.dataset.lghTeams = signature; row.querySelector(".lgh-team-tags")?.remove(); row.classList.toggle("lgh-duplicate", teams.length > 0); if (!teams.length) continue; const host = row.querySelector(".right") || row; const tags = element(doc2, "span", "lgh-team-tags"); for (const team of teams) { const tag = element(doc2, "a", "lgh-team-tag", team.name || `\u56E2\u961F #${team.id}`); tag.href = `/team/${team.id}/member`; tag.title = `${team.name || "\u56E2\u961F"} #${team.id}`; tag.addEventListener("click", (event) => event.stopPropagation()); tags.append(tag); } host.prepend(tags); } } function clearDuplicates(doc2) { for (const row of doc2.querySelectorAll("[data-lgh-teams]")) { delete row.dataset.lghTeams; row.classList.remove("lgh-duplicate"); } doc2.querySelectorAll(".lgh-team-tags").forEach((n) => n.remove()); } function annotateScores(doc2, rows2, problems2) { const rendered = doc2.querySelectorAll(".table-container .row-wrap > .row"); if (rendered.length !== rows2.length) return; rendered.forEach((node, index) => { const row = rows2[index], meta = row._lgh; if (!meta) return; const cells = [...node.children].filter((n) => n.classList.contains("score")); if (!meta.original && !node.querySelector(".lgh-badge")) node.querySelector(".user")?.append(element(doc2, "span", "lgh-badge", "\u8865\u5145\u7EDF\u8BA1")); if (!meta.complete) { const rank = node.querySelector(".rank"); if (rank && rank.textContent !== "\u2014") rank.textContent = "\u2014"; if (cells[0] && cells[0].textContent !== "\u672A\u77E5") { cells[0].textContent = "\u672A\u77E5"; cells[0].title = meta.error || "\u6570\u636E\u5C1A\u672A\u5B8C\u6574"; } } else if (!meta.original) { problems2.forEach((p, i) => { const cell = cells[i + 1]; if (!cell) return; const value = row.score[p.pid]; const hint = meta.sources?.[p.pid]; if (!cell.textContent && value === 0) cell.textContent = "0"; if (!cell.textContent && value === void 0) cell.textContent = "\u2014"; const title = hint ? `\u8BA1\u5165\u63D0\u4EA4 #${hint.id} \xB7 ${new Date(hint.time * 1e3).toLocaleString()}` : value === void 0 ? "\u5B8C\u6574\u8BB0\u5F55\u4E2D\u672A\u53D1\u73B0\u8BE5\u9898\u63D0\u4EA4" : "\u622A\u6B62\u524D\u6700\u9AD8\u6709\u6548\u5206"; if (cell.title !== title) cell.title = title; }); } }); } // src/style.css var style_default = '.lgh-nav,.lgh-nav *,.lgh-dialog,.lgh-dialog * {box-sizing:border-box}\r\n.top-bar>.right.lgh-nav-host {display:flex;align-items:center}\r\n.lgh-nav {display:inline-flex;align-items:center;gap:.55rem;margin-right:.75rem;color:inherit;font:inherit;vertical-align:middle}\r\n.lgh-name {font-size:.8rem;white-space:nowrap}\r\n.lgh-icon {font:inherit;color:inherit;display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:.4rem;border:0;border-radius:.5rem;background:transparent;cursor:pointer}\r\n.lgh-icon:hover {background:rgba(var(--theme-navi-fore-rgb,51,51,51),.1)}\r\n.lgh-icon svg {width:1.15rem;height:1.15rem;fill:none;stroke:currentColor;stroke-width:1.7}\r\n.lgh-switch {width:30px;height:17px;border:1px solid var(--lfe-color--grey-3,#bfbfbf);border-radius:10px;padding:2px;background:var(--lfe-color--grey-2,#e8e8e8);cursor:pointer;flex:none}\r\n.lgh-switch::after {content:"";display:block;width:11px;height:11px;border-radius:50%;background:white;box-shadow:0 1px 2px #0002;transition:transform .12s}\r\n.lgh-switch[aria-checked=true] {background:var(--lfe-color--primary,#3498db);border-color:var(--lfe-color--primary,#3498db)}\r\n.lgh-switch[aria-checked=true]::after {transform:translateX(13px)}\r\n.lgh-nav :focus-visible,.lgh-dialog :focus-visible,.lgh-team-tag:focus-visible {outline:2px solid var(--lfe-color--primary,#3498db);outline-offset:3px}\r\n.lgh-dialog {border:0;border-radius:4px;background:var(--theme-card-background,#fff);color:#404040;box-shadow:0 1px 3px #1a1a1a1a;padding:1em;width:min(560px,calc(100vw - 32px));max-height:calc(100dvh - 40px);overflow:auto;font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue","PingFang SC","Noto Sans SC","Segoe UI",Arial,"Microsoft YaHei",sans-serif;font-size:16px;line-height:1.5}\r\n.lgh-dialog::backdrop {background:#0008}\r\n.lgh-dialog-head {display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-bottom:.875em}\r\n.lgh-dialog h3 {font-family:inherit;font-size:1.125em;font-weight:700;line-height:1.2;margin:0;color:inherit}\r\n.lgh-dialog label {display:block;margin-bottom:.4em}\r\n.lgh-input {width:100%;min-height:110px;resize:vertical;font:inherit;font-size:.875em;line-height:1.5;padding:.3125em 1em;border:1px solid #bfbfbf;border-radius:3px;background:#fff;color:#404040}\r\n.lgh-input:focus {outline:none;border-color:var(--lfe-color--primary,#3498db);box-shadow:0 0 3px var(--lfe-color--primary,#3498db)}\r\n.lgh-select {min-height:0;height:2.25em;resize:none}\r\n.lgh-caption {font-size:.875em;color:#808080;margin:.5em 0}\r\n.lgh-error {color:var(--lfe-color--error,#e74c3c)}\r\n.lgh-settings-list {font-size:.875em;max-height:160px;overflow:auto;margin:.5rem 0}\r\n.lgh-settings-item {display:flex;align-items:baseline;justify-content:space-between;gap:.5rem;padding:.35rem 0;border-bottom:1px solid var(--lfe-color--grey-2,#e8e8e8)}\r\n.lgh-settings-item>span {min-width:0;overflow-wrap:anywhere}\r\n.lgh-footer {display:flex;justify-content:flex-end;gap:.75em;margin-top:1em}\r\n.lgh-button {font:inherit;font-size:.875em;line-height:1.5;padding:.3125em 1em;border:1px solid var(--lfe-color--primary,#3498db);border-radius:3px;color:var(--lfe-color--primary,#3498db);background:transparent;cursor:pointer}\r\n.lgh-button:hover {background:rgba(var(--lcolor--primary,52,152,219),.1)}\r\n.lgh-button.solid {background:var(--lfe-color--primary,#3498db);color:white}\r\n.lgh-button.solid:hover {opacity:.9}\r\n.lgh-button:disabled {opacity:.65;cursor:not-allowed}\r\n.lgh-team-tags {display:inline-flex;flex-wrap:wrap;gap:.35rem;align-items:center;margin-right:.75rem;max-width:100%}\r\na.lgh-team-tag {font-size:.875em;line-height:1.5;padding:0 .5em;border:1px solid rgba(var(--lcolor--primary,52,152,219),.35);border-radius:2px;color:var(--lfe-color--link,#3498db);background:rgba(var(--lcolor--primary,52,152,219),.07);max-width:16em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-decoration:none}\r\na.lgh-team-tag:hover {background:rgba(var(--lcolor--primary,52,152,219),.14)}\r\n.member-card.lgh-duplicate {background:rgba(var(--lcolor--primary,52,152,219),.07)!important;box-shadow:inset 2px 0 var(--lfe-color--primary,#3498db)}\r\n.lgh-badge {display:inline-block;font-size:11px;font-weight:400;color:#808080;border:1px solid #ddd;border-radius:2px;padding:0 3px;margin-left:5px;white-space:nowrap;vertical-align:middle}\r\n.lgh-unknown {color:#808080}\r\n@media(max-width:900px) {.lgh-name {display:none}.lgh-nav {gap:.4rem;margin-right:.4rem}}\r\n@media(max-width:576px) {.lgh-team-tags {margin-right:0}.lgh-nav {gap:.3rem}.lgh-icon {width:1.75rem;padding:.3rem}}\r\n@media(prefers-reduced-motion:reduce) {.lgh-switch::after {transition:none}}\r\n.lgh-dialog .lgh-checkbox {display:flex;align-items:center;gap:.5em;margin-top:1em}\r\n'; // src/main.js var page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; var doc = document; var gm = { get: typeof GM_getValue === "function" ? GM_getValue : null, set: typeof GM_setValue === "function" ? GM_setValue : null, listen: typeof GM_addValueChangeListener === "function" ? GM_addValueChangeListener : null, unlisten: typeof GM_removeValueChangeListener === "function" ? GM_removeValueChangeListener : null }; var store = createStorage(window, gm); var settings = store.settings(); var account = 0; var path = ""; var generation = 0; var controller = new AbortController(); var api; var users = /* @__PURE__ */ new Map(); var rows = []; var problems = []; var memberJob = null; var scoreJob = null; var message = ""; var dialog = null; var teamInfo = /* @__PURE__ */ new Map(); var originalBoards = /* @__PURE__ */ new Map(); function getAccount() { const avatar = doc.querySelector('.top-bar a:has(img[alt="User Avatar"])'); const uid = avatar?.getAttribute("href")?.match(/^\/user\/(\d+)$/)?.[1]; if (uid) return Number(uid); try { return Number(readContext(doc).user?.uid || 0); } catch { return 0; } } function currentTraining() { try { const t = readContext(doc).data.training; return String(t?.id) === page.location.pathname.match(/^\/training\/(\d+)/)?.[1] ? t : null; } catch { return null; } } function scoreboardVisible() { return /^\/training\/\d+\/?$/.test(page.location.pathname) && !![...doc.querySelectorAll(".entry.selected")].find((e) => e.textContent.trim() === "\u6392\u884C\u699C"); } function active(token) { return token === generation && settings.enabled && !controller.signal.aborted; } function notice(text) { message = text; render(); } function reset() { generation++; controller.abort(); controller = new AbortController(); api = new ReadApi(page, store, account, controller.signal, { concurrency: settings.concurrency, interval: requestInterval(settings.concurrency) }); users = /* @__PURE__ */ new Map(); rows = []; problems = []; memberJob = null; scoreJob = null; message = ""; clearDuplicates(doc); setStatus(doc, ""); } function refreshNative() { if (!scoreboardVisible()) return; const before = generation, route = page.location.pathname; const intro = [...doc.querySelectorAll(".entry")].find((e) => e.textContent.trim() === "\u9898\u5355\u7B80\u4ECB"); if (!intro) { notice("\u539F\u751F\u9875\u9762\u7ED3\u6784\u5DF2\u53D8\u5316\uFF0C\u65E0\u6CD5\u81EA\u52A8\u6062\u590D\uFF1B\u8BF7\u91CD\u65B0\u5207\u6362\u6392\u884C\u699C\u6807\u7B7E"); return; } intro.click(); setTimeout(() => { if (generation !== before || page.location.pathname !== route) return; [...doc.querySelectorAll(".entry")].find((e) => e.textContent.trim() === "\u6392\u884C\u699C")?.click(); }, 100); } function applySettings(value) { settings = normalizeSettings(value); store.save(settings); reset(); render(); refreshNative(); } function openSettings() { dialog = settingsDialog(doc, settings, (values) => applySettings({ ...settings, ...values }), (id) => teamInfo.get(id), () => { dialog = null; }, () => { store.clearGrades(account); retry(); }); } function retry() { reset(); render(); if (scoreboardVisible()) refreshNative(); } async function loadTeams(token) { const ids = settings.linkedTeamIds; let done = 0; notice(`\u8BFB\u53D6\u5173\u8054\u56E2\u961F 0/${ids.length}\u2026`); const results = await mapConcurrent(ids, settings.concurrency, async (id) => { if (!active(token)) throw new DOMException("Cancelled", "AbortError"); let result; try { const team = await api.team(id); result = { team }; teamInfo.set(id, { name: team.name }); } catch (error) { if (!active(token)) throw error; result = { error: `#${id}\uFF1A${error.message}` }; teamInfo.set(id, { error: error.message }); } if (active(token)) { notice(`\u8BFB\u53D6\u5173\u8054\u56E2\u961F ${++done}/${ids.length}\u2026`); dialog?.update(); } return result; }); return { teams: results.filter((r) => r.team).map((r) => r.team), failed: results.filter((r) => r.error).map((r) => r.error) }; } async function runMembers(token) { try { const { teams, failed } = await loadTeams(token); if (!active(token)) return; users = unionMembers(teams); notice(failed.length ? `\u91CD\u590D\u6210\u5458\u68C0\u67E5\u4E0D\u5B8C\u6574\u3002${failed.join("\uFF1B")}` : `\u5DF2\u68C0\u67E5 ${teams.length} \u4E2A\u5173\u8054\u56E2\u961F\uFF1B\u9AD8\u4EAE\u4E0E\u6807\u7B7E\u8868\u793A\u5176\u4ED6\u56E2\u961F\u4E2D\u7684\u540C\u4E00 UID\u3002`); } catch (error) { if (active(token)) notice(`\u6210\u5458\u68C0\u67E5\u5931\u8D25\uFF1A${error.message}`); } } async function getTraining(id) { const existing = currentTraining(); return existing || (await api.page(`/training/${id}`)).training; } async function calibrate(original, training, token) { const partial = original.find((r) => Object.values(r.score || {}).some((s) => s > 0 && s < 100)); const historical = original.find((r) => r.teamMember?.type === 2 && r.totalScore > 0) || original.find((r) => r.totalScore > 0); const samples = [...new Map([partial, historical].filter(Boolean).map((r) => [r.user.uid, r])).values()]; if (!samples.length) throw Error("\u539F\u699C\u6CA1\u6709\u53EF\u7528\u4E8E\u6838\u5BF9\u7684\u6709\u6548\u6210\u7EE9\uFF0C\u8865\u5145\u7EDF\u8BA1\u6682\u4E0D\u80FD\u786E\u8BA4"); for (const sample of samples) { if (!active(token)) throw new DOMException("Cancelled", "AbortError"); notice(`\u6838\u5BF9\u539F\u699C\u6210\u7EE9\u53E3\u5F84 ${samples.indexOf(sample) + 1}/${samples.length}\u2026`); const grade = await api.grades(sample.user.uid, training, { fresh: true }); if (!grade.complete || grade.totalScore !== sample.totalScore || training.problems.some((p) => (grade.score[p.pid] || 0) !== (sample.score[p.pid] || 0))) { throw Error("\u622A\u6B62\u524D\u5386\u53F2\u6700\u9AD8\u5206\u4E0E\u5F53\u524D\u539F\u699C\u6837\u672C\u4E0D\u4E00\u81F4\uFF0C\u5DF2\u4FDD\u7559\u672A\u77E5\u72B6\u6001"); } } return samples.length; } async function augment(id, original, token) { if (!Array.isArray(original?.scoreboard)) return original; try { const training = await getTraining(id); if (!active(token)) return original; if (!training || training.type !== 4 || !Array.isArray(training.problems)) return original; problems = training.problems; const { teams, failed } = await loadTeams(token); if (!active(token)) return original; if (!teams.length) { notice(`\u672A\u80FD\u8BFB\u53D6\u5173\u8054\u56E2\u961F\uFF0C\u4FDD\u7559\u539F\u699C\u3002${failed.join("\uFF1B")}`); return original; } users = rankMembers(unionMembers(teams), settings.excludeAdmins); const supplement = /* @__PURE__ */ new Map(), originalIds = new Set(original.scoreboard.map((r) => Number(r.user.uid))); const missing = [...users.keys()].filter((uid) => !originalIds.has(uid)); let sampleCount = 0, calibrationError = null; if (missing.length) { try { sampleCount = await calibrate(original.scoreboard, training, token); } catch (error) { calibrationError = error.message; } } let done = 0; notice(`\u8865\u5145\u6210\u7EE9 0/${missing.length}\uFF0C\u5173\u8054\u6210\u5458 ${users.size} \u4EBA\uFF08\u6700\u591A ${settings.concurrency} \u8DEF\u5E76\u53D1\uFF09\u2026`); await mapConcurrent(missing, settings.concurrency, async (uid) => { if (!active(token)) throw new DOMException("Cancelled", "AbortError"); try { if (calibrationError) throw Error(calibrationError); supplement.set(uid, await api.grades(uid, training)); } catch (error) { supplement.set(uid, { complete: false, error: error.message }); } if (active(token)) notice(`\u8865\u5145\u6210\u7EE9 ${++done}/${missing.length}\uFF0C\u5173\u8054\u6210\u5458 ${users.size} \u4EBA\uFF08\u6700\u591A ${settings.concurrency} \u8DEF\u5E76\u53D1\uFF09\u2026`); }); if (!active(token)) return original; rows = combineRows(original.scoreboard, users, supplement); const unknown = rows.filter((r) => !r._lgh.complete).length; const notes = []; if (failed.length) notes.push(`\u540D\u5355\u4E0D\u5B8C\u6574\uFF1A${failed.join("\uFF1B")}`); if (unknown) notes.push(`${unknown} \u4EBA\u6210\u7EE9\u672A\u77E5`); if (calibrationError) notes.push(calibrationError); if (sampleCount) notes.push(`\u5DF2\u4E0E ${sampleCount} \u4E2A\u539F\u699C\u6837\u672C\u6838\u5BF9\u6700\u9AD8\u5206\u53E3\u5F84`); notes.push("\u540C\u5206\u4F7F\u7528\u7A33\u5B9A\u6B21\u5E8F\uFF1B\u672A\u4FEE\u6539\u4F5C\u4E1A\u53C2\u4E0E\u8005"); notice(`\u5173\u8054\u6210\u5458 ${users.size} \u4EBA\uFF0C\u5DF2\u786E\u5B9A ${rows.length - unknown} \u4EBA\u3002${notes.join("\uFF1B")}`); return { ...original, scoreboard: rows }; } catch (error) { if (active(token)) notice(`\u589E\u5F3A\u7EDF\u8BA1\u5931\u8D25\uFF0C\u5DF2\u4FDD\u7559\u539F\u699C\uFF1A${error.message}`); return original; } } var disposeBridge = installScoreboardBridge(page, (id, original) => { tick(); if (!settings.enabled || !settings.linkedTeamIds.length || !account || !new RegExp(`^/training/${id}/?$`).test(page.location.pathname)) return original; const token = generation, key = `${token}:${id}`; if (!scoreJob || scoreJob.key !== key) scoreJob = { key, promise: augment(id, original, token) }; return scoreJob.promise.then((result) => active(token) ? result : original); }, (id, board) => { originalBoards.set(id, board); if (originalBoards.size > 20) originalBoards.delete(originalBoards.keys().next().value); }); var rendering = false; function render() { if (!doc.body || rendering) return; rendering = true; try { mountNav(doc, settings, () => applySettings({ ...settings, enabled: !settings.enabled }), openSettings); if (!settings.enabled) return; const memberId = page.location.pathname.match(/^\/team\/(\d+)\/member\/?$/)?.[1]; const supported = memberId || scoreboardVisible(); if (!supported) { setStatus(doc, ""); return; } if (!account) { setStatus(doc, "\u8BF7\u5148\u767B\u5F55\u6D1B\u8C37\u540E\u4F7F\u7528\u589E\u5F3A\u529F\u80FD", null, openSettings); return; } if (!settings.linkedTeamIds.length) { setStatus(doc, "\u8BF7\u5728\u5168\u5C40\u8BBE\u7F6E\u4E2D\u6DFB\u52A0\u5173\u8054\u56E2\u961F", null, openSettings); return; } if (memberId) { const key = `${generation}:${memberId}`; if (memberJob !== key) { memberJob = key; queueMicrotask(() => runMembers(generation)); } markDuplicates(doc, memberId, users); } else if (rows.length) annotateScores(doc, rows, problems); setStatus(doc, message || "\u7B49\u5F85\u539F\u751F\u699C\u5355\u52A0\u8F7D\u2026", retry, openSettings); } finally { rendering = false; } } function tick() { const nextAccount = getAccount(), nextPath = page.location.pathname; if (nextAccount !== account || nextPath !== path) { const accountChanged = nextAccount !== account; account = nextAccount; path = nextPath; reset(); if (accountChanged) { teamInfo.clear(); originalBoards.clear(); } } render(); } if (typeof GM_addStyle === "function") GM_addStyle(style_default); else { const style = doc.createElement("style"); style.textContent = style_default; (doc.head || doc.documentElement).append(style); } var scheduled = false; var observer = new MutationObserver(() => { if (scheduled) return; scheduled = true; setTimeout(() => { scheduled = false; tick(); }, 60); }); var ready = () => { observer.observe(doc.body, { childList: true, subtree: true }); tick(); }; if (doc.body) ready(); else doc.addEventListener("DOMContentLoaded", ready, { once: true }); var interval = setInterval(tick, 700); var unlisten = store.listen(() => { settings = store.settings(); reset(); render(); refreshNative(); }); if (page.__LGH_TEST__) page.__LGH_TEST__.app = { settings: () => settings, applySettings, api: () => api, originalBoards, state: () => ({ message, members: users.size, rows: rows.length, complete: rows.filter((r) => r._lgh.complete).length }), dispose: () => { settings = { ...settings, enabled: false }; reset(); refreshNative(); clearInterval(interval); observer.disconnect(); unlisten(); disposeBridge(); doc.getElementById("lgh-nav")?.remove(); doc.getElementById("lgh-settings")?.close(); doc.querySelector(".lgh-nav-host")?.classList.remove("lgh-nav-host"); } }; })();