From 61109b884acc05747c04bf9715d4f3202b6fd438 Mon Sep 17 00:00:00 2001 From: yunyun <1159428885@qq.com> Date: Mon, 1 Jun 2026 12:50:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=9B=B4=E6=96=B0=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=B8=87=E4=B8=AA=E4=B8=9C=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 465 ++++++++++++++++++++++++++++++++++++++++++++++++--- main.js | 204 +++++++++++++++++++++- package.json | 6 +- preload.js | 13 +- 4 files changed, 659 insertions(+), 29 deletions(-) diff --git a/index.html b/index.html index 33ddc1d..f34721a 100644 --- a/index.html +++ b/index.html @@ -388,6 +388,26 @@ align-items: center; gap: 10px; margin: 30px 0; + cursor: pointer; + padding: 20px; + border-radius: 12px; + transition: background 0.2s; + } + + .timer-display-container:hover { + background: #f8f9fa; + } + + .timer-display-container.clickable { + cursor: pointer; + } + + .timer-display-container.not-clickable { + cursor: default; + } + + .timer-display-container.not-clickable:hover { + background: transparent; } .timer-digit-group { @@ -721,6 +741,219 @@ font-weight: 700; color: #212529; } + + .settings-container { + max-width: 600px; + margin: 0 auto; + } + + .settings-title { + font-size: 22px; + font-weight: 700; + color: #212529; + margin-bottom: 25px; + text-align: center; + } + + .settings-section { + background: #f8f9fa; + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid #e9ecef; + } + + .settings-section-title { + font-size: 16px; + font-weight: 600; + color: #495057; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #dee2e6; + } + + .settings-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + } + + .settings-item + .settings-item { + border-top: 1px solid #e9ecef; + } + + .settings-label { + font-size: 14px; + color: #495057; + } + + .settings-hint { + display: block; + font-size: 12px; + color: #adb5bd; + margin-top: 2px; + font-weight: normal; + } + + .settings-radio-group { + display: flex; + gap: 15px; + } + + .settings-radio-item { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 14px; + color: #495057; + } + + .settings-radio-item input[type="radio"] { + accent-color: #667eea; + width: 16px; + height: 16px; + cursor: pointer; + } + + .settings-custom-input { + display: none; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 10px; + background: white; + border-radius: 8px; + } + + .settings-custom-input.visible { + display: flex; + } + + .settings-custom-input label { + font-size: 14px; + color: #495057; + white-space: nowrap; + } + + .settings-input { + width: 100px; + padding: 8px 12px; + border: 2px solid #dee2e6; + border-radius: 6px; + font-size: 14px; + text-align: center; + transition: border-color 0.2s; + } + + .settings-input:focus { + outline: none; + border-color: #667eea; + } + + .settings-unit { + font-size: 14px; + color: #6c757d; + } + + .toggle-switch { + position: relative; + width: 48px; + height: 26px; + cursor: pointer; + } + + .toggle-switch input { + opacity: 0; + width: 0; + height: 0; + } + + .toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #dee2e6; + border-radius: 26px; + transition: all 0.3s; + } + + .toggle-slider::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: all 0.3s; + } + + .toggle-switch input:checked + .toggle-slider { + background: #667eea; + } + + .toggle-switch input:checked + .toggle-slider::before { + transform: translateX(22px); + } + + .about-section { + text-align: center; + } + + .about-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 15px; + gap: 15px; + } + + .about-logo { + width: 128px; + height: 128px; + border-radius: 20px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + } + + .about-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + + .about-name { + font-size: 18px; + font-weight: 600; + color: #343a40; + margin: 0; + } + + .about-version { + font-size: 14px; + color: #868e96; + margin: 0; + } + + .about-link { + font-size: 14px; + color: #667eea; + text-decoration: none; + padding: 8px 20px; + border: 1px solid #667eea; + border-radius: 20px; + transition: all 0.3s; + } + + .about-link:hover { + background: #667eea; + color: white; + } @@ -759,6 +992,10 @@ 🎲 随机摇号 + @@ -777,7 +1014,7 @@ -
+
@@ -858,7 +1095,6 @@
-
@@ -873,9 +1109,69 @@
+ +
+
+

设置

+ +
+
⏱️ 高效计时
+
+ 默认填充的时间 +
+ + +
+
+
+ + + +
+
+ +
+
🎲 随机摇号
+
+ 跳过动画 + +
+
+ 智能匹配 + 避免重复抽中同一号码 + + +
+
+ +
+
关于
+
+ +
+

课堂小助手 Classroom Assistant

+

+ 源代码 +
+
+
+
+
-

课堂小助手 v1.0.0 | 支持Windows 7及以上系统

+

课堂小助手 Copyrights 2026 Yunyun By CaelLab

@@ -919,6 +1215,7 @@ function updateTimerButtonsVisibility() { const btns = document.querySelectorAll('.timer-adj-btn') const digitBtns = document.querySelectorAll('.timer-digit-btn') + const displayArea = document.getElementById('timer-display-area') const isActive = isTimerRunning || isTimerPaused const hideAdj = isActive || timerMode === 'stopwatch' btns.forEach(b => b.style.visibility = hideAdj ? 'hidden' : 'visible') @@ -938,6 +1235,15 @@ t.style.opacity = isActive ? '0.5' : '1' t.style.pointerEvents = isActive ? 'none' : 'auto' }) + if (displayArea) { + if (isActive) { + displayArea.classList.remove('not-clickable') + displayArea.classList.add('clickable') + } else { + displayArea.classList.remove('clickable') + displayArea.classList.add('not-clickable') + } + } } function adjustDigit(id, delta) { @@ -990,8 +1296,18 @@ async function loadTimeLastSeconds() { if (window.electronAPI) { - const seconds = await window.electronAPI.getTimeLastSeconds() - setTimerDigits(seconds) + try { + const fillMode = await window.electronAPI.getTimeFillMode() + let seconds + if (fillMode === 'custom') { + seconds = await window.electronAPI.getTimeCustomSeconds() + } else { + seconds = await window.electronAPI.getTimeLastSeconds() + } + setTimerDigits(seconds) + } catch (error) { + console.log('加载计时器记忆失败:', error) + } } } @@ -1065,6 +1381,13 @@ updateTimerButtonsVisibility() } + function handleTimerDisplayClick() { + const isActive = isTimerRunning || isTimerPaused + if (isActive) { + openFullscreen() + } + } + let fullscreenInterval = null let wasMaximizedBeforeFullscreen = false @@ -1104,9 +1427,11 @@ document.getElementById('fs-s').textContent = s } - let randomInterval = null let isRandomRunning = false let randomMax = 75 + let randomTimeout = null + let skipAnimation = false + let smartMatch = true async function loadRandomMax() { if (window.electronAPI) { @@ -1117,30 +1442,53 @@ } async function adjustRandomMax(delta) { + if (isRandomRunning) return randomMax = Math.max(1, randomMax + delta) document.getElementById('random-max-display').textContent = randomMax if (window.electronAPI) { await window.electronAPI.setRandomMaxNumber(randomMax) + await window.electronAPI.resetSmartMatchWeights() } } - function startRandom() { + async function startRandom() { if (isRandomRunning) return isRandomRunning = true - document.getElementById('random-start-btn').style.display = 'none' - document.getElementById('random-stop-btn').style.display = 'inline-block' - - randomInterval = setInterval(() => { - const num = Math.floor(Math.random() * randomMax) + 1 - document.getElementById('random-display').textContent = num - }, 50) + document.getElementById('random-start-btn').disabled = true + document.getElementById('random-start-btn').style.opacity = '0.5' + document.getElementById('random-display').style.color = '#667eea' + let finalNum + if (smartMatch && window.electronAPI) { + finalNum = await window.electronAPI.pickWeightedRandom() + } else { + finalNum = Math.floor(Math.random() * randomMax) + 1 + } + if (skipAnimation) { + document.getElementById('random-display').textContent = finalNum + isRandomRunning = false + document.getElementById('random-start-btn').disabled = false + document.getElementById('random-start-btn').style.opacity = '1' + } else { + rollRandom(25, 0, finalNum) + } } - function stopRandom() { - isRandomRunning = false - clearInterval(randomInterval) - document.getElementById('random-start-btn').style.display = 'inline-block' - document.getElementById('random-stop-btn').style.display = 'none' + function rollRandom(delay, count, finalNum) { + const num = Math.floor(Math.random() * randomMax) + 1 + document.getElementById('random-display').textContent = num + + const maxRolls = 15 + Math.floor(Math.random() * 8) + const newDelay = count > maxRolls * 0.5 ? delay * 1.12 : delay + + if (newDelay < 350) { + randomTimeout = setTimeout(() => rollRandom(newDelay, count + 1, finalNum), newDelay) + } else { + document.getElementById('random-display').textContent = finalNum + isRandomRunning = false + document.getElementById('random-start-btn').disabled = false + document.getElementById('random-start-btn').style.opacity = '1' + document.getElementById('random-display').style.color = '#667eea' + } } async function loadAppInfo() { @@ -1149,17 +1497,88 @@ const version = await window.electronAPI.getAppVersion() const appName = await window.electronAPI.getAppName() document.getElementById('version-info').textContent = - appName + ' v' + version + ' | 支持Windows 7及以上系统' + appName + ' v' + version + ' | Copyrights 2026 Yunyun By CaelLab' + document.getElementById('about-version').textContent = 'v' + version } } catch (error) { console.log('无法获取应用信息:', error) } } - - document.addEventListener('DOMContentLoaded', () => { + + function handleFillModeChange(mode) { + const container = document.getElementById('custom-seconds-container') + if (mode === 'custom') { + container.classList.add('visible') + } else { + container.classList.remove('visible') + } + if (window.electronAPI) { + window.electronAPI.setTimeFillMode(mode) + } + } + + let customSecondsDebounce = null + function handleCustomSecondsChange(value) { + clearTimeout(customSecondsDebounce) + customSecondsDebounce = setTimeout(() => { + const seconds = Math.max(1, Math.min(86400, parseInt(value) || 300)) + if (window.electronAPI) { + window.electronAPI.setTimeCustomSeconds(seconds) + } + }, 500) + } + + function handleSkipAnimationChange(checked) { + skipAnimation = checked + if (window.electronAPI) { + window.electronAPI.setRandomSkipAnimation(checked) + } + } + + function handleSmartMatchChange(checked) { + smartMatch = checked + if (window.electronAPI) { + window.electronAPI.setSmartMatch(checked) + if (checked) { + window.electronAPI.resetSmartMatchWeights() + } + } + } + + async function loadSettings() { + if (window.electronAPI) { + const fillMode = await window.electronAPI.getTimeFillMode() + const radios = document.querySelectorAll('input[name="timer-fill-mode"]') + radios.forEach(radio => { + radio.checked = radio.value === fillMode + }) + if (fillMode === 'custom') { + document.getElementById('custom-seconds-container').classList.add('visible') + } + + const customSeconds = await window.electronAPI.getTimeCustomSeconds() + document.getElementById('custom-seconds-input').value = customSeconds + + skipAnimation = await window.electronAPI.getRandomSkipAnimation() + document.getElementById('skip-animation-toggle').checked = skipAnimation + + smartMatch = await window.electronAPI.getSmartMatch() + document.getElementById('smart-match-toggle').checked = smartMatch + } + } + + function openSourceCode(e) { + e.preventDefault() + if (window.electronAPI) { + window.electronAPI.openExternalUrl('https://git.caellab.com/yunyun/Classroom-Assistant') + } + } + + document.addEventListener('DOMContentLoaded', async () => { loadAppInfo() loadRandomMax() - loadTimeLastSeconds() + await loadTimeLastSeconds() + loadSettings() }) diff --git a/main.js b/main.js index 066066f..16d07ff 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, ipcMain } = require('electron') +const { app, BrowserWindow, ipcMain, shell } = require('electron') const path = require('path') const fs = require('fs') const ini = require('ini') @@ -11,8 +11,13 @@ if (process.platform === 'win32') { let mainWindow -const configDir = path.join(app.getPath('appData'), 'classroom-assistant', 'ca') +const portableDir = process.env.PORTABLE_EXECUTABLE_DIR +const configDir = portableDir + ? path.join(portableDir, 'data') + : path.join(app.getPath('appData'), 'classroom-assistant', 'ca') const configPath = path.join(configDir, 'memory.ini') +const settingsPath = path.join(configDir, 'setting.ini') +const goodRandomPath = path.join(configDir, 'goodrandom.json') const defaultConfig = { Random: { @@ -23,6 +28,17 @@ const defaultConfig = { } } +const defaultSettings = { + Timer: { + FillMode: 'last', + CustomSeconds: 300 + }, + Random: { + SkipAnimation: false, + SmartMatch: true + } +} + function ensureConfigDir() { if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) @@ -38,11 +54,15 @@ function loadConfig() { try { const content = fs.readFileSync(configPath, 'utf-8') const config = ini.parse(content) - if (!config.Random || typeof config.Random.MaxNumber !== 'number') { + if (!config.Random) { config.Random = defaultConfig.Random + } else { + config.Random.MaxNumber = parseInt(config.Random.MaxNumber) || defaultConfig.Random.MaxNumber } - if (!config.Time || typeof config.Time.LastSeconds !== 'number') { + if (!config.Time) { config.Time = defaultConfig.Time + } else { + config.Time.LastSeconds = parseInt(config.Time.LastSeconds) || defaultConfig.Time.LastSeconds } saveConfig(config) return config @@ -57,6 +77,88 @@ function saveConfig(config) { fs.writeFileSync(configPath, ini.stringify(config), 'utf-8') } +function loadSettings() { + ensureConfigDir() + if (!fs.existsSync(settingsPath)) { + saveSettings(defaultSettings) + return defaultSettings + } + try { + const content = fs.readFileSync(settingsPath, 'utf-8') + const settings = ini.parse(content) + if (!settings.Timer) { + settings.Timer = defaultSettings.Timer + } else { + settings.Timer.FillMode = settings.Timer.FillMode || 'last' + settings.Timer.CustomSeconds = parseInt(settings.Timer.CustomSeconds) || defaultSettings.Timer.CustomSeconds + } + if (!settings.Random) { + settings.Random = defaultSettings.Random + } else { + settings.Random.SkipAnimation = settings.Random.SkipAnimation === 'true' || settings.Random.SkipAnimation === true + settings.Random.SmartMatch = settings.Random.SmartMatch === 'true' || settings.Random.SmartMatch === true || settings.Random.SmartMatch === undefined + } + return settings + } catch (e) { + saveSettings(defaultSettings) + return defaultSettings + } +} + +function saveSettings(settings) { + ensureConfigDir() + fs.writeFileSync(settingsPath, ini.stringify(settings), 'utf-8') +} + +function generateWeights(maxNumber) { + const weights = {} + const base = maxNumber + for (let i = 1; i <= maxNumber; i++) { + weights[i] = parseFloat(base.toFixed(3)) + } + return weights +} + +function loadGoodRandom() { + ensureConfigDir() + if (!fs.existsSync(goodRandomPath)) { + const config = loadConfig() + const maxNumber = config.Random.MaxNumber + const data = { + maxNumber: maxNumber, + weights: generateWeights(maxNumber) + } + saveGoodRandom(data) + return data + } + try { + const content = fs.readFileSync(goodRandomPath, 'utf-8') + const data = JSON.parse(content) + const config = loadConfig() + const currentMax = config.Random.MaxNumber + if (data.maxNumber !== currentMax) { + data.maxNumber = currentMax + data.weights = generateWeights(currentMax) + saveGoodRandom(data) + } + return data + } catch (e) { + const config = loadConfig() + const maxNumber = config.Random.MaxNumber + const data = { + maxNumber: maxNumber, + weights: generateWeights(maxNumber) + } + saveGoodRandom(data) + return data + } +} + +function saveGoodRandom(data) { + ensureConfigDir() + fs.writeFileSync(goodRandomPath, JSON.stringify(data, null, 2), 'utf-8') +} + function createWindow() { mainWindow = new BrowserWindow({ width: 1000, @@ -181,4 +283,98 @@ ipcMain.handle('set-time-last-seconds', (event, seconds) => { config.Time.LastSeconds = seconds saveConfig(config) return true +}) + +ipcMain.handle('get-time-fill-mode', () => { + const settings = loadSettings() + return settings.Timer.FillMode +}) + +ipcMain.handle('set-time-fill-mode', (event, mode) => { + const settings = loadSettings() + settings.Timer.FillMode = mode + saveSettings(settings) + return true +}) + +ipcMain.handle('get-time-custom-seconds', () => { + const settings = loadSettings() + return settings.Timer.CustomSeconds +}) + +ipcMain.handle('set-time-custom-seconds', (event, seconds) => { + const settings = loadSettings() + settings.Timer.CustomSeconds = seconds + saveSettings(settings) + return true +}) + +ipcMain.handle('get-random-skip-animation', () => { + const settings = loadSettings() + return settings.Random.SkipAnimation +}) + +ipcMain.handle('set-random-skip-animation', (event, skip) => { + const settings = loadSettings() + settings.Random.SkipAnimation = skip + saveSettings(settings) + return true +}) + +ipcMain.handle('get-smart-match', () => { + const settings = loadSettings() + return settings.Random.SmartMatch +}) + +ipcMain.handle('set-smart-match', (event, enabled) => { + const settings = loadSettings() + settings.Random.SmartMatch = enabled + saveSettings(settings) + return true +}) + +ipcMain.handle('pick-weighted-random', () => { + const data = loadGoodRandom() + const maxNumber = data.maxNumber + const fixedWeight = parseFloat((maxNumber * 0.3).toFixed(3)) + const effectiveWeights = {} + let totalWeight = 0 + for (let i = 1; i <= maxNumber; i++) { + effectiveWeights[i] = Math.round(data.weights[i]) ** 2 + fixedWeight + totalWeight += effectiveWeights[i] + } + let rand = Math.random() * totalWeight + let picked = 1 + for (let i = 1; i <= maxNumber; i++) { + rand -= effectiveWeights[i] + if (rand <= 0) { + picked = i + break + } + } + for (let i = 1; i <= maxNumber; i++) { + if (i === picked) { + data.weights[i] = 0.5 + } else { + data.weights[i] = parseFloat(Math.min(data.weights[i] + 1, maxNumber).toFixed(3)) + } + } + saveGoodRandom(data) + return picked +}) + +ipcMain.handle('reset-smart-match-weights', () => { + const config = loadConfig() + const maxNumber = config.Random.MaxNumber + const data = { + maxNumber: maxNumber, + weights: generateWeights(maxNumber) + } + saveGoodRandom(data) + return true +}) + +ipcMain.handle('open-external-url', (event, url) => { + shell.openExternal(url) + return true }) \ No newline at end of file diff --git a/package.json b/package.json index 30b406d..fa8582a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "start": "electron .", "build": "electron-builder", "build:win": "electron-builder --win", - "build:win7": "electron-builder --win --config.win.target=nsis" + "build:win7": "electron-builder --win --config.win.target=nsis", + "build:portable": "electron-builder --win portable --config.win.target=portable" }, "keywords": [ "electron", @@ -46,6 +47,9 @@ "createDesktopShortcut": true, "createStartMenuShortcut": true, "shortcutName": "课堂小助手" + }, + "portable": { + "artifactName": "课堂小助手-${version}-便携版.exe" } }, "dependencies": { diff --git a/preload.js b/preload.js index ed8122c..89bf9ce 100644 --- a/preload.js +++ b/preload.js @@ -20,5 +20,16 @@ contextBridge.exposeInMainWorld('electronAPI', { getRandomMaxNumber: () => ipcRenderer.invoke('get-random-max-number'), setRandomMaxNumber: (maxNumber) => ipcRenderer.invoke('set-random-max-number', maxNumber), getTimeLastSeconds: () => ipcRenderer.invoke('get-time-last-seconds'), - setTimeLastSeconds: (seconds) => ipcRenderer.invoke('set-time-last-seconds', seconds) + setTimeLastSeconds: (seconds) => ipcRenderer.invoke('set-time-last-seconds', seconds), + getTimeFillMode: () => ipcRenderer.invoke('get-time-fill-mode'), + setTimeFillMode: (mode) => ipcRenderer.invoke('set-time-fill-mode', mode), + getTimeCustomSeconds: () => ipcRenderer.invoke('get-time-custom-seconds'), + setTimeCustomSeconds: (seconds) => ipcRenderer.invoke('set-time-custom-seconds', seconds), + getRandomSkipAnimation: () => ipcRenderer.invoke('get-random-skip-animation'), + setRandomSkipAnimation: (skip) => ipcRenderer.invoke('set-random-skip-animation', skip), + getSmartMatch: () => ipcRenderer.invoke('get-smart-match'), + setSmartMatch: (enabled) => ipcRenderer.invoke('set-smart-match', enabled), + pickWeightedRandom: () => ipcRenderer.invoke('pick-weighted-random'), + resetSmartMatchWeights: () => ipcRenderer.invoke('reset-smart-match-weights'), + openExternalUrl: (url) => ipcRenderer.invoke('open-external-url', url) }) \ No newline at end of file