sidepanel.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. // sidepanel/sidepanel.js — Side Panel logic
  2. const STATUS_ICONS = {
  3. pending: '',
  4. running: '',
  5. completed: '\u2713', // ✓
  6. failed: '\u2717', // ✗
  7. stopped: '\u25A0', // ■
  8. };
  9. const logArea = document.getElementById('log-area');
  10. const displayOauthUrl = document.getElementById('display-oauth-url');
  11. const displayLocalhostUrl = document.getElementById('display-localhost-url');
  12. const displayStatus = document.getElementById('display-status');
  13. const statusBar = document.getElementById('status-bar');
  14. const inputEmail = document.getElementById('input-email');
  15. const inputPassword = document.getElementById('input-password');
  16. const btnFetchEmail = document.getElementById('btn-fetch-email');
  17. const btnTogglePassword = document.getElementById('btn-toggle-password');
  18. const btnStop = document.getElementById('btn-stop');
  19. const btnReset = document.getElementById('btn-reset');
  20. const stepsProgress = document.getElementById('steps-progress');
  21. const btnAutoRun = document.getElementById('btn-auto-run');
  22. const btnAutoContinue = document.getElementById('btn-auto-continue');
  23. const autoContinueBar = document.getElementById('auto-continue-bar');
  24. const btnClearLog = document.getElementById('btn-clear-log');
  25. const inputVpsUrl = document.getElementById('input-vps-url');
  26. const inputVpsPassword = document.getElementById('input-vps-password');
  27. const selectMailProvider = document.getElementById('select-mail-provider');
  28. const rowInbucketHost = document.getElementById('row-inbucket-host');
  29. const inputInbucketHost = document.getElementById('input-inbucket-host');
  30. const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
  31. const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
  32. const inputRunCount = document.getElementById('input-run-count');
  33. const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures');
  34. // ============================================================
  35. // Toast Notifications
  36. // ============================================================
  37. const toastContainer = document.getElementById('toast-container');
  38. const TOAST_ICONS = {
  39. error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
  40. warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
  41. success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
  42. info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
  43. };
  44. const LOG_LEVEL_LABELS = {
  45. info: '信息',
  46. ok: '成功',
  47. warn: '警告',
  48. error: '错误',
  49. };
  50. function showToast(message, type = 'error', duration = 4000) {
  51. const toast = document.createElement('div');
  52. toast.className = `toast toast-${type}`;
  53. toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
  54. toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
  55. toastContainer.appendChild(toast);
  56. if (duration > 0) {
  57. setTimeout(() => dismissToast(toast), duration);
  58. }
  59. }
  60. function dismissToast(toast) {
  61. if (!toast.parentNode) return;
  62. toast.classList.add('toast-exit');
  63. toast.addEventListener('animationend', () => toast.remove());
  64. }
  65. // ============================================================
  66. // State Restore on load
  67. // ============================================================
  68. async function restoreState() {
  69. try {
  70. const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
  71. if (state.oauthUrl) {
  72. displayOauthUrl.textContent = state.oauthUrl;
  73. displayOauthUrl.classList.add('has-value');
  74. }
  75. if (state.localhostUrl) {
  76. displayLocalhostUrl.textContent = state.localhostUrl;
  77. displayLocalhostUrl.classList.add('has-value');
  78. }
  79. if (state.email) {
  80. inputEmail.value = state.email;
  81. }
  82. syncPasswordField(state);
  83. if (state.vpsUrl) {
  84. inputVpsUrl.value = state.vpsUrl;
  85. }
  86. if (state.vpsPassword) {
  87. inputVpsPassword.value = state.vpsPassword;
  88. }
  89. if (state.mailProvider) {
  90. selectMailProvider.value = state.mailProvider;
  91. }
  92. if (state.inbucketHost) {
  93. inputInbucketHost.value = state.inbucketHost;
  94. }
  95. if (state.inbucketMailbox) {
  96. inputInbucketMailbox.value = state.inbucketMailbox;
  97. }
  98. inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures);
  99. if (state.stepStatuses) {
  100. for (const [step, status] of Object.entries(state.stepStatuses)) {
  101. updateStepUI(Number(step), status);
  102. }
  103. }
  104. if (state.logs) {
  105. for (const entry of state.logs) {
  106. appendLog(entry);
  107. }
  108. }
  109. updateStatusDisplay(state);
  110. updateProgressCounter();
  111. updateMailProviderUI();
  112. } catch (err) {
  113. console.error('Failed to restore state:', err);
  114. }
  115. }
  116. function syncPasswordField(state) {
  117. inputPassword.value = state.customPassword || state.password || '';
  118. }
  119. function updateMailProviderUI() {
  120. const useInbucket = selectMailProvider.value === 'inbucket';
  121. rowInbucketHost.style.display = useInbucket ? '' : 'none';
  122. rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
  123. }
  124. // ============================================================
  125. // UI Updates
  126. // ============================================================
  127. function updateStepUI(step, status) {
  128. const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
  129. const row = document.querySelector(`.step-row[data-step="${step}"]`);
  130. if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
  131. if (row) {
  132. row.className = `step-row ${status}`;
  133. }
  134. updateButtonStates();
  135. updateProgressCounter();
  136. }
  137. function updateProgressCounter() {
  138. let completed = 0;
  139. document.querySelectorAll('.step-row').forEach(row => {
  140. if (row.classList.contains('completed')) completed++;
  141. });
  142. stepsProgress.textContent = `${completed} / 9`;
  143. }
  144. function updateButtonStates() {
  145. const statuses = {};
  146. document.querySelectorAll('.step-row').forEach(row => {
  147. const step = Number(row.dataset.step);
  148. if (row.classList.contains('completed')) statuses[step] = 'completed';
  149. else if (row.classList.contains('running')) statuses[step] = 'running';
  150. else if (row.classList.contains('failed')) statuses[step] = 'failed';
  151. else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
  152. else statuses[step] = 'pending';
  153. });
  154. const anyRunning = Object.values(statuses).some(s => s === 'running');
  155. for (let step = 1; step <= 9; step++) {
  156. const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
  157. if (!btn) continue;
  158. if (anyRunning) {
  159. btn.disabled = true;
  160. } else if (step === 1) {
  161. btn.disabled = false;
  162. } else {
  163. const prevStatus = statuses[step - 1];
  164. const currentStatus = statuses[step];
  165. btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
  166. }
  167. }
  168. updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
  169. }
  170. function updateStopButtonState(active) {
  171. btnStop.disabled = !active;
  172. }
  173. function updateStatusDisplay(state) {
  174. if (!state || !state.stepStatuses) return;
  175. statusBar.className = 'status-bar';
  176. const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
  177. if (running) {
  178. displayStatus.textContent = `步骤 ${running[0]} 运行中...`;
  179. statusBar.classList.add('running');
  180. return;
  181. }
  182. const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
  183. if (failed) {
  184. displayStatus.textContent = `步骤 ${failed[0]} 失败`;
  185. statusBar.classList.add('failed');
  186. return;
  187. }
  188. const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
  189. if (stopped) {
  190. displayStatus.textContent = `步骤 ${stopped[0]} 已停止`;
  191. statusBar.classList.add('stopped');
  192. return;
  193. }
  194. const lastCompleted = Object.entries(state.stepStatuses)
  195. .filter(([, s]) => s === 'completed')
  196. .map(([k]) => Number(k))
  197. .sort((a, b) => b - a)[0];
  198. if (lastCompleted === 9) {
  199. displayStatus.textContent = '全部步骤已完成';
  200. statusBar.classList.add('completed');
  201. } else if (lastCompleted) {
  202. displayStatus.textContent = `步骤 ${lastCompleted} 已完成`;
  203. } else {
  204. displayStatus.textContent = '就绪';
  205. }
  206. }
  207. function appendLog(entry) {
  208. const time = new Date(entry.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
  209. const levelLabel = LOG_LEVEL_LABELS[entry.level] || entry.level;
  210. const line = document.createElement('div');
  211. line.className = `log-line log-${entry.level}`;
  212. const stepMatch = entry.message.match(/(?:Step\s+(\d+)|步骤\s*(\d+))/);
  213. const stepNum = stepMatch ? (stepMatch[1] || stepMatch[2]) : null;
  214. let html = `<span class="log-time">${time}</span> `;
  215. html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
  216. if (stepNum) {
  217. html += `<span class="log-step-tag step-${stepNum}">步${stepNum}</span>`;
  218. }
  219. html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
  220. line.innerHTML = html;
  221. logArea.appendChild(line);
  222. logArea.scrollTop = logArea.scrollHeight;
  223. }
  224. function escapeHtml(text) {
  225. const div = document.createElement('div');
  226. div.textContent = text;
  227. return div.innerHTML;
  228. }
  229. async function fetchDuckEmail(options = {}) {
  230. const { showFailureToast = true } = options;
  231. const defaultLabel = '获取';
  232. btnFetchEmail.disabled = true;
  233. btnFetchEmail.textContent = '...';
  234. try {
  235. const response = await chrome.runtime.sendMessage({
  236. type: 'FETCH_DUCK_EMAIL',
  237. source: 'sidepanel',
  238. payload: { generateNew: true },
  239. });
  240. if (response?.error) {
  241. throw new Error(response.error);
  242. }
  243. if (!response?.email) {
  244. throw new Error('未返回 Duck 邮箱。');
  245. }
  246. inputEmail.value = response.email;
  247. showToast(`已获取 ${response.email}`, 'success', 2500);
  248. return response.email;
  249. } catch (err) {
  250. if (showFailureToast) {
  251. showToast(`自动获取失败:${err.message}`, 'error');
  252. }
  253. throw err;
  254. } finally {
  255. btnFetchEmail.disabled = false;
  256. btnFetchEmail.textContent = defaultLabel;
  257. }
  258. }
  259. function syncPasswordToggleLabel() {
  260. btnTogglePassword.textContent = inputPassword.type === 'password' ? '显示' : '隐藏';
  261. }
  262. // ============================================================
  263. // Button Handlers
  264. // ============================================================
  265. document.querySelectorAll('.step-btn').forEach(btn => {
  266. btn.addEventListener('click', async () => {
  267. const step = Number(btn.dataset.step);
  268. if (step === 3) {
  269. let email = inputEmail.value.trim();
  270. if (!email) {
  271. try {
  272. email = await fetchDuckEmail({ showFailureToast: false });
  273. } catch (err) {
  274. showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn');
  275. return;
  276. }
  277. }
  278. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
  279. } else {
  280. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
  281. }
  282. });
  283. });
  284. btnFetchEmail.addEventListener('click', async () => {
  285. await fetchDuckEmail().catch(() => {});
  286. });
  287. btnTogglePassword.addEventListener('click', () => {
  288. inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
  289. syncPasswordToggleLabel();
  290. });
  291. btnStop.addEventListener('click', async () => {
  292. btnStop.disabled = true;
  293. await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
  294. showToast('正在停止当前流程...', 'warn', 2000);
  295. });
  296. // Auto Run
  297. btnAutoRun.addEventListener('click', async () => {
  298. const totalRuns = parseInt(inputRunCount.value) || 1;
  299. btnAutoRun.disabled = true;
  300. inputRunCount.disabled = true;
  301. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> 运行中...';
  302. await chrome.runtime.sendMessage({
  303. type: 'AUTO_RUN',
  304. source: 'sidepanel',
  305. payload: {
  306. totalRuns,
  307. autoRunSkipFailures: inputAutoSkipFailures.checked,
  308. },
  309. });
  310. });
  311. btnAutoContinue.addEventListener('click', async () => {
  312. const email = inputEmail.value.trim();
  313. if (!email) {
  314. showToast('请先获取或粘贴 DuckDuckGo 邮箱。', 'warn');
  315. return;
  316. }
  317. autoContinueBar.style.display = 'none';
  318. await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
  319. });
  320. // Reset
  321. btnReset.addEventListener('click', async () => {
  322. if (confirm('确认重置全部步骤和数据吗?')) {
  323. await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
  324. displayOauthUrl.textContent = '等待中...';
  325. displayOauthUrl.classList.remove('has-value');
  326. displayLocalhostUrl.textContent = '等待中...';
  327. displayLocalhostUrl.classList.remove('has-value');
  328. inputEmail.value = '';
  329. displayStatus.textContent = '就绪';
  330. statusBar.className = 'status-bar';
  331. logArea.innerHTML = '';
  332. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  333. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  334. btnAutoRun.disabled = false;
  335. inputRunCount.disabled = false;
  336. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> 自动';
  337. autoContinueBar.style.display = 'none';
  338. updateStopButtonState(false);
  339. updateButtonStates();
  340. updateProgressCounter();
  341. }
  342. });
  343. // Clear log
  344. btnClearLog.addEventListener('click', () => {
  345. logArea.innerHTML = '';
  346. });
  347. // Save settings on change
  348. inputEmail.addEventListener('change', async () => {
  349. const email = inputEmail.value.trim();
  350. if (email) {
  351. await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
  352. }
  353. });
  354. inputVpsUrl.addEventListener('change', async () => {
  355. const vpsUrl = inputVpsUrl.value.trim();
  356. if (vpsUrl) {
  357. await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
  358. }
  359. });
  360. inputVpsPassword.addEventListener('change', async () => {
  361. await chrome.runtime.sendMessage({
  362. type: 'SAVE_SETTING',
  363. source: 'sidepanel',
  364. payload: { vpsPassword: inputVpsPassword.value },
  365. });
  366. });
  367. inputPassword.addEventListener('change', async () => {
  368. await chrome.runtime.sendMessage({
  369. type: 'SAVE_SETTING',
  370. source: 'sidepanel',
  371. payload: { customPassword: inputPassword.value },
  372. });
  373. });
  374. selectMailProvider.addEventListener('change', async () => {
  375. updateMailProviderUI();
  376. await chrome.runtime.sendMessage({
  377. type: 'SAVE_SETTING', source: 'sidepanel',
  378. payload: { mailProvider: selectMailProvider.value },
  379. });
  380. });
  381. inputInbucketMailbox.addEventListener('change', async () => {
  382. await chrome.runtime.sendMessage({
  383. type: 'SAVE_SETTING',
  384. source: 'sidepanel',
  385. payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
  386. });
  387. });
  388. inputInbucketHost.addEventListener('change', async () => {
  389. await chrome.runtime.sendMessage({
  390. type: 'SAVE_SETTING',
  391. source: 'sidepanel',
  392. payload: { inbucketHost: inputInbucketHost.value.trim() },
  393. });
  394. });
  395. inputAutoSkipFailures.addEventListener('change', async () => {
  396. await chrome.runtime.sendMessage({
  397. type: 'SAVE_SETTING',
  398. source: 'sidepanel',
  399. payload: { autoRunSkipFailures: inputAutoSkipFailures.checked },
  400. });
  401. });
  402. // ============================================================
  403. // Listen for Background broadcasts
  404. // ============================================================
  405. chrome.runtime.onMessage.addListener((message) => {
  406. switch (message.type) {
  407. case 'LOG_ENTRY':
  408. appendLog(message.payload);
  409. if (message.payload.level === 'error') {
  410. showToast(message.payload.message, 'error');
  411. }
  412. break;
  413. case 'STEP_STATUS_CHANGED': {
  414. const { step, status } = message.payload;
  415. updateStepUI(step, status);
  416. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
  417. if (status === 'completed') {
  418. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
  419. syncPasswordField(state);
  420. if (state.oauthUrl) {
  421. displayOauthUrl.textContent = state.oauthUrl;
  422. displayOauthUrl.classList.add('has-value');
  423. }
  424. if (state.localhostUrl) {
  425. displayLocalhostUrl.textContent = state.localhostUrl;
  426. displayLocalhostUrl.classList.add('has-value');
  427. }
  428. });
  429. }
  430. break;
  431. }
  432. case 'AUTO_RUN_RESET': {
  433. // Full UI reset for next run
  434. displayOauthUrl.textContent = '等待中...';
  435. displayOauthUrl.classList.remove('has-value');
  436. displayLocalhostUrl.textContent = '等待中...';
  437. displayLocalhostUrl.classList.remove('has-value');
  438. inputEmail.value = '';
  439. displayStatus.textContent = '就绪';
  440. statusBar.className = 'status-bar';
  441. logArea.innerHTML = '';
  442. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  443. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  444. updateStopButtonState(false);
  445. updateProgressCounter();
  446. break;
  447. }
  448. case 'DATA_UPDATED': {
  449. if (message.payload.email) {
  450. inputEmail.value = message.payload.email;
  451. }
  452. if (message.payload.password !== undefined) {
  453. inputPassword.value = message.payload.password || '';
  454. }
  455. if (message.payload.oauthUrl) {
  456. displayOauthUrl.textContent = message.payload.oauthUrl;
  457. displayOauthUrl.classList.add('has-value');
  458. }
  459. if (message.payload.localhostUrl) {
  460. displayLocalhostUrl.textContent = message.payload.localhostUrl;
  461. displayLocalhostUrl.classList.add('has-value');
  462. }
  463. break;
  464. }
  465. case 'AUTO_RUN_STATUS': {
  466. const { phase, currentRun, totalRuns, attemptRun } = message.payload;
  467. const attemptLabel = attemptRun ? ` · 尝试${attemptRun}` : '';
  468. const runLabel = totalRuns > 1 ? ` (${currentRun}/${totalRuns}${attemptLabel})` : (attemptLabel ? ` (${attemptLabel.slice(3)})` : '');
  469. switch (phase) {
  470. case 'waiting_email':
  471. autoContinueBar.style.display = 'flex';
  472. btnAutoRun.innerHTML = `已暂停${runLabel}`;
  473. updateStopButtonState(true);
  474. break;
  475. case 'running':
  476. btnAutoRun.innerHTML = `运行中${runLabel}`;
  477. updateStopButtonState(true);
  478. break;
  479. case 'retrying':
  480. btnAutoRun.innerHTML = `重试中${runLabel}`;
  481. updateStopButtonState(true);
  482. break;
  483. case 'complete':
  484. btnAutoRun.disabled = false;
  485. inputRunCount.disabled = false;
  486. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> 自动';
  487. autoContinueBar.style.display = 'none';
  488. updateStopButtonState(false);
  489. break;
  490. case 'stopped':
  491. btnAutoRun.disabled = false;
  492. inputRunCount.disabled = false;
  493. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> 自动';
  494. autoContinueBar.style.display = 'none';
  495. updateStopButtonState(false);
  496. break;
  497. }
  498. break;
  499. }
  500. }
  501. });
  502. // ============================================================
  503. // Theme Toggle
  504. // ============================================================
  505. const btnTheme = document.getElementById('btn-theme');
  506. function setTheme(theme) {
  507. document.documentElement.setAttribute('data-theme', theme);
  508. localStorage.setItem('multipage-theme', theme);
  509. }
  510. function initTheme() {
  511. const saved = localStorage.getItem('multipage-theme');
  512. if (saved) {
  513. setTheme(saved);
  514. } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  515. setTheme('dark');
  516. }
  517. }
  518. btnTheme.addEventListener('click', () => {
  519. const current = document.documentElement.getAttribute('data-theme');
  520. setTheme(current === 'dark' ? 'light' : 'dark');
  521. });
  522. // ============================================================
  523. // Init
  524. // ============================================================
  525. initTheme();
  526. restoreState().then(() => {
  527. syncPasswordToggleLabel();
  528. updateButtonStates();
  529. });