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 @@
+
@@ -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