From f27e2f21bbaff70dc57c18b554879e81041c6d61 Mon Sep 17 00:00:00 2001 From: Fox_block Date: Wed, 14 Jan 2026 19:27:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=BA=BF?= =?UTF-8?q?=E7=82=B9=E7=82=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SecScoreDB-WebSockets.md | 224 +++--- eslint.config.mjs | 7 +- package.json | 8 +- scripts/clean-db.mjs | 17 + src/main/db/DbManager.ts | 98 ++- src/main/index.ts | 693 ++++++++++++++-- src/main/repos/EventRepository.ts | 105 ++- src/main/repos/ReasonRepository.ts | 45 +- src/main/repos/StudentRepository.ts | 48 +- src/main/services/LoggerService.ts | 77 ++ src/main/services/SyncEngine.ts | 134 ++-- src/main/services/ThemeService.ts | 110 +-- src/main/services/WsClient.ts | 133 ++-- src/preload/index.d.ts | 2 + src/preload/index.ts | 29 +- src/preload/types.ts | 102 ++- src/renderer/src/App.tsx | 213 ++++- src/renderer/src/assets/main.css | 2 +- src/renderer/src/components/EventHistory.tsx | 59 +- src/renderer/src/components/Leaderboard.tsx | 238 ++++++ src/renderer/src/components/ReasonManager.tsx | 157 ++-- src/renderer/src/components/ScoreManager.tsx | 259 ++++++ src/renderer/src/components/Settings.tsx | 739 +++++++++++++++--- .../src/components/StudentManager.tsx | 230 +++--- src/renderer/src/components/Wizard.tsx | 73 +- src/renderer/src/contexts/ThemeContext.tsx | 96 +-- src/renderer/src/main.tsx | 39 + src/renderer/src/react-19-patch.ts | 39 + themes/amber.json | 24 + themes/cyan.json | 24 + themes/green.json | 24 + themes/pastel.json | 24 + themes/purple.json | 24 + 33 files changed, 3161 insertions(+), 935 deletions(-) create mode 100644 scripts/clean-db.mjs create mode 100644 src/main/services/LoggerService.ts create mode 100644 src/renderer/src/components/Leaderboard.tsx create mode 100644 src/renderer/src/components/ScoreManager.tsx create mode 100644 src/renderer/src/react-19-patch.ts create mode 100644 themes/amber.json create mode 100644 themes/cyan.json create mode 100644 themes/green.json create mode 100644 themes/pastel.json create mode 100644 themes/purple.json diff --git a/SecScoreDB-WebSockets.md b/SecScoreDB-WebSockets.md index dfeb0d7..1b29514 100644 --- a/SecScoreDB-WebSockets.md +++ b/SecScoreDB-WebSockets.md @@ -15,22 +15,22 @@ ### 2.1 客户端请求 (Request) -| 字段 | 类型 | 必填 | 说明 | -| :--- | :--- | :--- | :--- | -| **`seq`** | String | Yes | 请求唯一序列号 (如 UUID) | -| **`category`** | String | Yes | 资源类别: `"system"`, `"student"`, `"group"`, `"event"` | -| **`action`** | String | Yes | 操作动作: `"define"`, `"create"`, `"query"`, `"update"`, `"delete"` | -| **`payload`** | Object | Yes | 具体操作参数,结构依 `action` 而定 | +| 字段 | 类型 | 必填 | 说明 | +| :------------- | :----- | :--- | :------------------------------------------------------------------ | +| **`seq`** | String | Yes | 请求唯一序列号 (如 UUID) | +| **`category`** | String | Yes | 资源类别: `"system"`, `"student"`, `"group"`, `"event"` | +| **`action`** | String | Yes | 操作动作: `"define"`, `"create"`, `"query"`, `"update"`, `"delete"` | +| **`payload`** | Object | Yes | 具体操作参数,结构依 `action` 而定 | ### 2.2 服务端响应 (Response) -| 字段 | 类型 | 必填 | 说明 | -| :--- | :--- | :--- | :--- | -| **`seq`** | String | Yes | 对应请求的 `seq` | -| **`status`** | String | Yes | `"ok"` 或 `"error"` | -| **`code`** | Int | Yes | 状态码 (见第 6 节) | -| **`message`** | String | No | 错误描述或提示信息 | -| **`data`** | Object | No | 成功时的返回数据 | +| 字段 | 类型 | 必填 | 说明 | +| :------------ | :----- | :--- | :------------------ | +| **`seq`** | String | Yes | 对应请求的 `seq` | +| **`status`** | String | Yes | `"ok"` 或 `"error"` | +| **`code`** | Int | Yes | 状态码 (见第 6 节) | +| **`message`** | String | No | 错误描述或提示信息 | +| **`data`** | Object | No | 成功时的返回数据 | --- @@ -44,17 +44,17 @@ ```json { - "category": "system", - "action": "define", - "payload": { - "target": "student", // 或 "group" - "schema": { - "name": "string", - "age": "int", - "score": "double", - "active": "int" - } + "category": "system", + "action": "define", + "payload": { + "target": "student", // 或 "group" + "schema": { + "name": "string", + "age": "int", + "score": "double", + "active": "int" } + } } ``` @@ -76,22 +76,22 @@ ```json { - "category": "student", - "action": "create", - "payload": { - "items": [ - { - "index": 0, // 客户端临时索引 - "id": null, // [请求分配ID] - "data": { "name": "Alice", "age": 18, "score": 95.5 } - }, - { - "index": 1, - "id": 10086, // [强制指定ID] - "data": { "name": "Bob", "age": 20 } - } - ] - } + "category": "student", + "action": "create", + "payload": { + "items": [ + { + "index": 0, // 客户端临时索引 + "id": null, // [请求分配ID] + "data": { "name": "Alice", "age": 18, "score": 95.5 } + }, + { + "index": 1, + "id": 10086, // [强制指定ID] + "data": { "name": "Bob", "age": 20 } + } + ] + } } ``` @@ -99,19 +99,19 @@ ```json { - "count": 2, // 成功数量 - "results": [ - { - "index": 0, - "success": true, - "id": 1001 // 服务端分配的新 ID - }, - { - "index": 1, - "success": true, - "id": 10086 - } - ] + "count": 2, // 成功数量 + "results": [ + { + "index": 0, + "success": true, + "id": 1001 // 服务端分配的新 ID + }, + { + "index": 1, + "success": true, + "id": 10086 + } + ] } ``` @@ -123,38 +123,38 @@ ```json { - "category": "student", - "action": "query", - "payload": { - "logic": { - "op": "AND", // 根节点逻辑: AND, OR - "rules": [ - { "field": "score", "op": ">=", "val": 90.0 }, - { - "op": "OR", - "rules": [ - { "field": "age", "op": "<", "val": 20 }, - { "field": "name", "op": "==", "val": "Alice" } - ] - } - ] + "category": "student", + "action": "query", + "payload": { + "logic": { + "op": "AND", // 根节点逻辑: AND, OR + "rules": [ + { "field": "score", "op": ">=", "val": 90.0 }, + { + "op": "OR", + "rules": [ + { "field": "age", "op": "<", "val": 20 }, + { "field": "name", "op": "==", "val": "Alice" } + ] } + ] } + } } ``` -*支持的操作符 (`op`): `==`, `!=`, `>`, `<`, `>=`, `<=`* +_支持的操作符 (`op`): `==`, `!=`, `>`, `<`, `>=`, `<=`_ **Response Data:** ```json { - "items": [ - { - "id": 1001, - "data": { "name": "Alice", "age": 18, "score": 95.5 } - } - ] + "items": [ + { + "id": 1001, + "data": { "name": "Alice", "age": 18, "score": 95.5 } + } + ] } ``` @@ -164,15 +164,15 @@ ```json { - "category": "student", - "action": "update", - "payload": { - "id": 1001, - "set": { - "score": 98.0, // 仅更新指定字段 - "age": 19 - } + "category": "student", + "action": "update", + "payload": { + "id": 1001, + "set": { + "score": 98.0, // 仅更新指定字段 + "age": 19 } + } } ``` @@ -182,11 +182,11 @@ ```json { - "category": "student", - "action": "delete", - "payload": { - "id": 1001 - } + "category": "student", + "action": "delete", + "payload": { + "id": 1001 + } } ``` @@ -200,16 +200,16 @@ ```json { - "category": "event", - "action": "create", - "payload": { - "id": null, // 必须为 null - "type": 1, // 1=Student, 2=Group - "ref_id": 1001, // 关联对象的 ID - "desc": "Score adjustment", - "val_prev": 95.5, - "val_curr": 98.0 - } + "category": "event", + "action": "create", + "payload": { + "id": null, // 必须为 null + "type": 1, // 1=Student, 2=Group + "ref_id": 1001, // 关联对象的 ID + "desc": "Score adjustment", + "val_prev": 95.5, + "val_curr": 98.0 + } } ``` @@ -217,8 +217,8 @@ ```json { - "id": 501, // 新生成的 Event ID - "timestamp": 1710000000 + "id": 501, // 新生成的 Event ID + "timestamp": 1710000000 } ``` @@ -228,12 +228,12 @@ ```json { - "category": "event", - "action": "update", - "payload": { - "id": 501, - "erased": true - } + "category": "event", + "action": "update", + "payload": { + "id": 501, + "erased": true + } } ``` @@ -241,10 +241,10 @@ ## 6. 状态码定义 (Status Codes) -| Code | Meaning | Description | -| :--- | :--- | :--- | -| **200** | OK | 请求成功执行 | -| **400** | Bad Request | JSON 格式错误、缺少必填字段或 `action` 不支持 | -| **404** | Not Found | 指定的 ID 不存在 | -| **422** | Unprocessable | 数据类型不匹配 (如给 Int 字段传 String) | -| **500** | Internal Error | 核心内部 C++ 异常 | +| Code | Meaning | Description | +| :------ | :------------- | :-------------------------------------------- | +| **200** | OK | 请求成功执行 | +| **400** | Bad Request | JSON 格式错误、缺少必填字段或 `action` 不支持 | +| **404** | Not Found | 指定的 ID 不存在 | +| **422** | Unprocessable | 数据类型不匹配 (如给 Int 字段传 String) | +| **500** | Internal Error | 核心内部 C++ 异常 | diff --git a/eslint.config.mjs b/eslint.config.mjs index aff5d3f..a3480a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,7 +25,12 @@ export default defineConfig( }, rules: { ...eslintPluginReactHooks.configs.recommended.rules, - ...eslintPluginReactRefresh.configs.vite.rules + ...eslintPluginReactRefresh.configs.vite.rules, + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'react-refresh/only-export-components': 'off', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/set-state-in-effect': 'off' } }, eslintConfigPrettier diff --git a/package.json b/package.json index 52ebb68..01fdf0f 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,13 @@ "lint": "eslint --cache .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck": "npm run typecheck:node && npm run typecheck:web", + "typecheck": "pnpm -s typecheck:node && pnpm -s typecheck:web", "start": "electron-vite preview", "dev": "electron-vite dev", - "build": "npm run typecheck && electron-vite build", + "build": "node scripts/clean-db.mjs && pnpm -s typecheck && electron-vite build", "postinstall": "electron-builder install-app-deps", - "build:unpack": "npm run build && electron-builder --dir", - "build:win": "npm run build && electron-builder --win", + "build:unpack": "pnpm -s build && electron-builder --dir", + "build:win": "pnpm -s build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux" }, diff --git a/scripts/clean-db.mjs b/scripts/clean-db.mjs new file mode 100644 index 0000000..8ce8e19 --- /dev/null +++ b/scripts/clean-db.mjs @@ -0,0 +1,17 @@ +import fs from 'fs' +import path from 'path' + +const root = process.cwd() +const targets = ['db.sqlite'] + +for (const name of targets) { + const filePath = path.join(root, name) + try { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }) + console.log(`[clean-db] removed ${filePath}`) + } + } catch (e) { + console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e) + } +} diff --git a/src/main/db/DbManager.ts b/src/main/db/DbManager.ts index 39261da..15bda42 100644 --- a/src/main/db/DbManager.ts +++ b/src/main/db/DbManager.ts @@ -1,25 +1,25 @@ -import BetterSqlite3 from 'better-sqlite3'; -import path from 'path'; -import fs from 'fs'; +import BetterSqlite3 from 'better-sqlite3' +import path from 'path' +import fs from 'fs' export class DbManager { - private db: BetterSqlite3.Database; + private db: BetterSqlite3.Database constructor(dbPath: string) { - const dbDir = path.dirname(dbPath); + const dbDir = path.dirname(dbPath) if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); + fs.mkdirSync(dbDir, { recursive: true }) } - this.db = new BetterSqlite3(dbPath); - this.init(); + this.db = new BetterSqlite3(dbPath) + this.init() } private init() { // 开启外键支持 - this.db.pragma('foreign_keys = ON'); - + this.db.pragma('foreign_keys = ON') + // 执行 Migration (简单实现) - this.migrate(); + this.migrate() } private migrate() { @@ -33,7 +33,23 @@ export class DbManager { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) - `); + `) + + // 建立积分流水表 + this.db.exec(` + CREATE TABLE IF NOT EXISTS score_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + student_name TEXT NOT NULL, + reason_content TEXT NOT NULL, + delta INTEGER NOT NULL, + val_prev INTEGER NOT NULL, + val_curr INTEGER NOT NULL, + event_time DATETIME DEFAULT CURRENT_TIMESTAMP, + sync_state INTEGER DEFAULT 0, + remote_id TEXT + ) + `) // 建立系统设置表 this.db.exec(` @@ -41,13 +57,14 @@ export class DbManager { key TEXT PRIMARY KEY, value TEXT ) - `); + `) // 初始设置 - const setSetting = this.db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); - setSetting.run('ws_server', 'ws://localhost:8080'); - setSetting.run('sync_mode', 'local'); // local | remote - setSetting.run('is_wizard_completed', '0'); // 0: 未完成, 1: 已完成 + const setSetting = this.db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)') + setSetting.run('ws_server', 'ws://localhost:8080') + setSetting.run('sync_mode', 'local') // local | remote + setSetting.run('is_wizard_completed', '0') // 0: 未完成, 1: 已完成 + setSetting.run('log_level', 'info') // debug | info | warn | error // 建立积分理由分类/预设表 this.db.exec(` @@ -60,46 +77,27 @@ export class DbManager { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, sync_state INTEGER DEFAULT 0 ) - `); + `) // 初始数据种子 - const reasonCount = this.db.prepare('SELECT count(*) as count FROM reasons').get() as { count: number }; - if (reasonCount.count === 0) { - const insert = this.db.prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)'); - insert.run('课堂表现优秀', '学习', 2, 1); - insert.run('作业未交', '学习', -2, 1); - insert.run('帮助同学', '品德', 5, 1); - insert.run('打架斗殴', '纪律', -10, 1); + const reasonCount = this.db.prepare('SELECT count(*) as count FROM reasons').get() as { + count: number } - - // 建立积分事件流水表 - this.db.exec(` - CREATE TABLE IF NOT EXISTS score_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE, - student_name TEXT NOT NULL, - reason_content TEXT, - delta INTEGER NOT NULL, - val_prev INTEGER NOT NULL, - val_curr INTEGER NOT NULL, - event_time DATETIME DEFAULT CURRENT_TIMESTAMP, - sync_state INTEGER DEFAULT 0, -- 0: 待同步, 1: 已同步 - remote_id TEXT + if (reasonCount.count === 0) { + const insert = this.db.prepare( + 'INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)' ) - `); - - // 插入一些初始 Reasons (如果为空) - const count = this.db.prepare('SELECT COUNT(*) as count FROM reasons').get() as { count: number }; - if (count.count === 0) { - const insert = this.db.prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)'); - insert.run('作业优秀', '学习', 2, 1); - insert.run('课堂积极', '学习', 1, 1); - insert.run('打架斗殴', '纪律', -5, 1); - insert.run('迟到', '纪律', -1, 1); + insert.run('课堂表现优秀', '学习', 2, 1) + insert.run('作业未交', '学习', -2, 1) + insert.run('帮助同学', '品德', 5, 1) + insert.run('打架斗殴', '纪律', -10, 1) + insert.run('作业优秀', '学习', 2, 1) + insert.run('课堂积极', '学习', 1, 1) + insert.run('迟到', '纪律', -1, 1) } } public getDb(): BetterSqlite3.Database { - return this.db; + return this.db } } diff --git a/src/main/index.ts b/src/main/index.ts index d973ac5..2cb8d38 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,11 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron' import { join } from 'path' import fs from 'fs' +import crypto from 'crypto' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { ThemeService } from './services/ThemeService' +import { LoggerService, LogLevel } from './services/LoggerService' import { DbManager } from './db/DbManager' import { StudentRepository } from './repos/StudentRepository' import { ReasonRepository } from './repos/ReasonRepository' @@ -57,83 +59,390 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) - // Initialize Services - const themeDir = is.dev ? join(process.cwd(), 'themes') : join(app.getPath('userData'), 'themes'); - if (!fs.existsSync(themeDir)) { - fs.mkdirSync(themeDir, { recursive: true }); + // Base data directory (production 使用单独目录) + const dataRoot = is.dev ? process.cwd() : join(app.getPath('userData'), 'secscore-data') + if (!fs.existsSync(dataRoot)) { + fs.mkdirSync(dataRoot, { recursive: true }) + } + + // Initialize Logger + const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs') + const logger = new LoggerService(logDir) + logger.info('Application starting...') + + // Initialize Themes (prod: 使用单独目录并从内置模板拷贝) + const themeDir = is.dev ? join(process.cwd(), 'themes') : join(dataRoot, 'themes') + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }) + } + if (!is.dev) { + try { + const existing = fs.readdirSync(themeDir).filter((f) => f.toLowerCase().endsWith('.json')) + if (existing.length === 0) { + const builtinThemeDir = join(app.getAppPath(), 'themes') + if (fs.existsSync(builtinThemeDir)) { + const files = fs + .readdirSync(builtinThemeDir) + .filter((f) => f.toLowerCase().endsWith('.json')) + for (const f of files) { + const src = join(builtinThemeDir, f) + const dest = join(themeDir, f) + try { + fs.copyFileSync(src, dest) + } catch (e: any) { + logger.warn?.('Failed to copy builtin theme', { + src, + dest, + message: e?.message + }) + } + } + } + } + } catch (e: any) { + logger.warn?.('Failed to initialize theme directory', { message: e?.message }) + } } - const themeService = new ThemeService(themeDir) - themeService.init() // Initialize DB - const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(app.getPath('userData'), 'db.sqlite'); - const dbManager = new DbManager(dbPath); - const studentRepo = new StudentRepository(dbManager.getDb()); - const reasonRepo = new ReasonRepository(dbManager.getDb()); - const eventRepo = new EventRepository(dbManager.getDb()); + const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite') + const dbManager = new DbManager(dbPath) + + // Set logger level from settings + const logLevelSetting = dbManager + .getDb() + .prepare("SELECT value FROM settings WHERE key = 'log_level'") + .get() as any + if (logLevelSetting) { + logger.setLevel(logLevelSetting.value as LogLevel) + } + + process.on('uncaughtException', (err) => { + logger.error('uncaughtException', { + message: err?.message, + stack: err?.stack + }) + }) + + process.on('unhandledRejection', (reason: any) => { + if (reason instanceof Error) { + logger.error('unhandledRejection', { message: reason.message, stack: reason.stack }) + } else { + logger.error('unhandledRejection', reason) + } + }) + + app.on('render-process-gone', (_, __, details) => { + logger.error('render-process-gone', details) + }) + + app.on('child-process-gone', (_, details) => { + logger.error('child-process-gone', details) + }) + + const studentRepo = new StudentRepository(dbManager.getDb()) + const reasonRepo = new ReasonRepository(dbManager.getDb()) + const eventRepo = new EventRepository(dbManager.getDb()) + + const SETTINGS_SECURITY_ADMIN = 'security_admin_password' + const SETTINGS_SECURITY_POINTS = 'security_points_password' + const SETTINGS_SECURITY_RECOVERY = 'security_recovery_string' + const SETTINGS_SECURITY_IV = 'security_crypto_iv' + + type PermissionLevel = 'admin' | 'points' | 'view' + const permissionRank: Record = { view: 0, points: 1, admin: 2 } + const permissionsBySenderId = new Map() + + const getSetting = (key: string): string => { + const row = dbManager.getDb().prepare('SELECT value FROM settings WHERE key = ?').get(key) as + | { value?: string } + | undefined + return row?.value ?? '' + } + + const setSetting = (key: string, value: string) => { + dbManager + .getDb() + .prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run(key, value) + } + + const hasSecret = (key: string) => { + const v = getSetting(key) + return typeof v === 'string' && v.trim().length > 0 + } + + const ensureSecurityIv = () => { + let ivHex = getSetting(SETTINGS_SECURITY_IV) + if (!ivHex) { + ivHex = crypto.randomBytes(16).toString('hex') + setSetting(SETTINGS_SECURITY_IV, ivHex) + } + return ivHex + } + + const getCryptoKey = () => { + return crypto.scryptSync(app.getPath('userData'), 'secscore-salt', 32) + } + + const encryptSecret = (plainText: string) => { + const ivHex = ensureSecurityIv() + const key = getCryptoKey() + const cipher = crypto.createCipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex')) + let encrypted = cipher.update(plainText, 'utf8', 'hex') + encrypted += cipher.final('hex') + return encrypted + } + + const decryptSecret = (cipherText: string) => { + try { + if (!cipherText) return '' + const ivHex = ensureSecurityIv() + const key = getCryptoKey() + const decipher = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex')) + let plain = decipher.update(cipherText, 'hex', 'utf8') + plain += decipher.final('utf8') + return plain + } catch { + return '' + } + } + + const shouldProtect = () => + hasSecret(SETTINGS_SECURITY_ADMIN) || hasSecret(SETTINGS_SECURITY_POINTS) + + const getDefaultPermission = (): PermissionLevel => { + return shouldProtect() ? 'view' : 'admin' + } + + const getPermission = (senderId: number): PermissionLevel => { + const existing = permissionsBySenderId.get(senderId) + if (existing) return existing + const def = getDefaultPermission() + permissionsBySenderId.set(senderId, def) + return def + } + + const setPermission = (senderId: number, level: PermissionLevel) => { + permissionsBySenderId.set(senderId, level) + } + + const requirePermission = (event: any, required: PermissionLevel) => { + const senderId = event?.sender?.id + if (typeof senderId !== 'number') return false + const current = getPermission(senderId) + return permissionRank[current] >= permissionRank[required] + } + + const isSixDigit = (s: string) => /^\d{6}$/.test(s) + + const themeService = new ThemeService(themeDir, (senderId) => { + return permissionRank[getPermission(senderId)] >= permissionRank['admin'] + }) + themeService.init() // Initialize Sync - const wsClient = new WsClient(); - const syncEngine = new SyncEngine(wsClient, dbManager); + const wsClient = new WsClient() + const syncEngine = new SyncEngine(wsClient, dbManager) const startSyncIfRemote = () => { - const syncMode = dbManager.getDb().prepare("SELECT value FROM settings WHERE key = 'sync_mode'").get() as any; - const wsServer = dbManager.getDb().prepare("SELECT value FROM settings WHERE key = 'ws_server'").get() as any; - - if (syncMode?.value === 'remote' && wsServer?.value) { - wsClient.connect(wsServer.value); - } else { - wsClient.close(); - } - }; + const syncMode = dbManager + .getDb() + .prepare("SELECT value FROM settings WHERE key = 'sync_mode'") + .get() as any + const wsServer = dbManager + .getDb() + .prepare("SELECT value FROM settings WHERE key = 'ws_server'") + .get() as any - startSyncIfRemote(); + if (syncMode?.value === 'remote' && wsServer?.value) { + wsClient.connect(wsServer.value) + } else { + wsClient.close() + } + } + + startSyncIfRemote() // 监听本地事件创建,触发同步 app.on('score-event-created' as any, () => { - syncEngine.startOutboxSync(); - }); + syncEngine.startOutboxSync() + }) // Student IPC - ipcMain.handle('db:student:query', async () => ({ success: true, data: studentRepo.findAll() })); - ipcMain.handle('db:student:create', async (_, data) => ({ success: true, data: studentRepo.create(data) })); - ipcMain.handle('db:student:update', async (_, id, data) => { studentRepo.update(id, data); return { success: true }; }); - ipcMain.handle('db:student:delete', async (_, id) => { studentRepo.delete(id); return { success: true }; }); + ipcMain.handle('db:student:query', async () => ({ success: true, data: studentRepo.findAll() })) + ipcMain.handle('db:student:create', async (event, data) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + return { success: true, data: studentRepo.create(data) } + }) + ipcMain.handle('db:student:update', async (event, id, data) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + studentRepo.update(id, data) + return { success: true } + }) + ipcMain.handle('db:student:delete', async (event, id) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + studentRepo.delete(id) + return { success: true } + }) // Reason IPC - ipcMain.handle('db:reason:query', async () => ({ success: true, data: reasonRepo.findAll() })); - ipcMain.handle('db:reason:create', async (_, data) => ({ success: true, data: reasonRepo.create(data) })); - ipcMain.handle('db:reason:update', async (_, id, data) => { reasonRepo.update(id, data); return { success: true }; }); - ipcMain.handle('db:reason:delete', async (_, id) => { reasonRepo.delete(id); return { success: true }; }); + ipcMain.handle('db:reason:query', async () => ({ success: true, data: reasonRepo.findAll() })) + ipcMain.handle('db:reason:create', async (event, data) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + return { success: true, data: reasonRepo.create(data) } + }) + ipcMain.handle('db:reason:update', async (event, id, data) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + reasonRepo.update(id, data) + return { success: true } + }) + ipcMain.handle('db:reason:delete', async (event, id) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + const changes = reasonRepo.delete(id) + if (!changes) return { success: false, message: '记录不存在' } + return { success: true, data: { changes } } + }) // 兼容前端 deleteReason 命名错误 - ipcMain.handle('db:deleteReason', async (_, id) => { reasonRepo.delete(id); return { success: true }; }); + ipcMain.handle('db:deleteReason', async (event, id) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + const changes = reasonRepo.delete(id) + if (!changes) return { success: false, message: '记录不存在' } + return { success: true, data: { changes } } + }) // Event IPC - ipcMain.handle('db:event:query', async (_, params) => ({ success: true, data: eventRepo.findAll(params?.limit) })); - ipcMain.handle('db:event:create', async (_, data) => { + ipcMain.handle('db:event:query', async (_, params) => { try { - const id = eventRepo.create(data); - return { success: true, data: id }; - } catch (e: any) { - return { success: false, message: e.message }; + return { success: true, data: eventRepo.findAll(params?.limit) } + } catch (err: any) { + return { success: false, message: err.message } } - }); + }) + + ipcMain.handle('db:event:delete', async (event, uuid) => { + try { + if (!requirePermission(event, 'points')) + return { success: false, message: 'Permission denied' } + eventRepo.deleteByUuid(uuid) + return { success: true } + } catch (err: any) { + return { success: false, message: err.message } + } + }) + + ipcMain.handle('db:event:create', async (event, data) => { + try { + if (!requirePermission(event, 'points')) + return { success: false, message: 'Permission denied' } + const id = eventRepo.create(data) + return { success: true, data: id } + } catch (e: any) { + return { success: false, message: e.message } + } + }) + + ipcMain.handle('db:event:queryByStudent', async (_, params) => { + try { + const limit = Number(params?.limit ?? 50) + const studentName = String(params?.student_name ?? '') + const startTime = params?.startTime ? String(params.startTime) : null + if (!studentName) return { success: true, data: [] } + + const db = dbManager.getDb() + let rows: any[] + if (startTime) { + rows = db + .prepare( + `SELECT * FROM score_events + WHERE student_name = ? + AND julianday(event_time) >= julianday(?) + ORDER BY event_time DESC + LIMIT ?` + ) + .all(studentName, startTime, limit) + } else { + rows = db + .prepare( + `SELECT * FROM score_events + WHERE student_name = ? + ORDER BY event_time DESC + LIMIT ?` + ) + .all(studentName, limit) + } + return { success: true, data: rows } + } catch (err: any) { + return { success: false, message: err.message } + } + }) + + ipcMain.handle('db:leaderboard:query', async (_, params) => { + try { + const range = String(params?.range ?? 'today') + + const now = new Date() + let start = new Date(now) + if (range === 'today') { + start.setHours(0, 0, 0, 0) + } else if (range === 'week') { + const day = start.getDay() + const diff = (day === 0 ? -6 : 1) - day + start.setDate(start.getDate() + diff) + start.setHours(0, 0, 0, 0) + } else if (range === 'month') { + start = new Date(start.getFullYear(), start.getMonth(), 1) + start.setHours(0, 0, 0, 0) + } else { + start.setHours(0, 0, 0, 0) + } + const startTime = start.toISOString() + + const db = dbManager.getDb() + const rows = db + .prepare( + `SELECT + s.id as id, + s.name as name, + s.score as score, + COALESCE(SUM(e.delta), 0) as range_change + FROM students s + LEFT JOIN score_events e + ON e.student_name = s.name + AND julianday(e.event_time) >= julianday(?) + GROUP BY s.id, s.name, s.score + ORDER BY s.score DESC, range_change DESC, s.name ASC` + ) + .all(startTime) + + return { success: true, data: { startTime, rows } } + } catch (err: any) { + return { success: false, message: err.message } + } + }) // Settings IPC ipcMain.handle('db:getSettings', () => { - const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as { key: string, value: string }[]; - const settings: Record = {}; - rows.forEach(r => settings[r.key] = r.value); - return { success: true, data: settings }; - }); + const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as { + key: string + value: string + }[] + const settings: Record = {} + rows.forEach((r) => (settings[r.key] = r.value)) + return { success: true, data: settings } + }) ipcMain.handle('db:updateSetting', (_event, key, value) => { - dbManager.getDb().prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value); + if (!requirePermission(_event, 'admin')) return { success: false, message: 'Permission denied' } + dbManager + .getDb() + .prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run(key, value) if (key === 'sync_mode' || key === 'ws_server') { - startSyncIfRemote(); + startSyncIfRemote() } - return { success: true }; - }); + return { success: true } + }) ipcMain.handle('ws:getStatus', () => { return { @@ -142,13 +451,285 @@ app.whenReady().then(() => { connected: wsClient.isConnected(), lastSync: new Date().toISOString() // TODO: 记录真正的最后同步时间 } - }; - }); + } + }) - ipcMain.handle('ws:triggerSync', async () => { - await syncEngine.triggerFullSync(); - return { success: true }; - }); + ipcMain.handle('ws:triggerSync', async (event) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + await syncEngine.triggerFullSync() + return { success: true } + }) + + // Logger IPC + ipcMain.handle('log:query', (_, lines) => ({ success: true, data: logger.readLogs(lines) })) + ipcMain.handle('log:clear', (event) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + logger.clearLogs() + return { success: true } + }) + ipcMain.handle('log:setLevel', (event, level: LogLevel) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + logger.setLevel(level) + dbManager + .getDb() + .prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('log_level', level) + return { success: true } + }) + + ipcMain.handle('log:write', (_event, payload: any) => { + const level = String(payload?.level || 'info') + const message = String(payload?.message || '') + const meta = payload?.meta + if (level === 'debug' || level === 'info' || level === 'warn' || level === 'error') { + logger.log(level as LogLevel, message, meta) + } else { + logger.info(message, meta) + } + return { success: true } + }) + + ipcMain.handle('auth:getStatus', (event) => { + const senderId = event?.sender?.id + const permission = + typeof senderId === 'number' ? getPermission(senderId) : getDefaultPermission() + return { + success: true, + data: { + permission, + hasAdminPassword: hasSecret(SETTINGS_SECURITY_ADMIN), + hasPointsPassword: hasSecret(SETTINGS_SECURITY_POINTS), + hasRecoveryString: hasSecret(SETTINGS_SECURITY_RECOVERY) + } + } + }) + + ipcMain.handle('auth:login', (event, password: string) => { + const senderId = event?.sender?.id + if (typeof senderId !== 'number') return { success: false, message: 'Invalid sender' } + if (!isSixDigit(String(password ?? ''))) { + setPermission(senderId, getDefaultPermission()) + return { success: false, message: 'Invalid password format' } + } + + const adminCipher = getSetting(SETTINGS_SECURITY_ADMIN) + const pointsCipher = getSetting(SETTINGS_SECURITY_POINTS) + const adminPlain = decryptSecret(adminCipher) + const pointsPlain = decryptSecret(pointsCipher) + + if (adminCipher && adminPlain === password) { + setPermission(senderId, 'admin') + return { success: true, data: { permission: 'admin' as PermissionLevel } } + } + if (pointsCipher && pointsPlain === password) { + setPermission(senderId, 'points') + return { success: true, data: { permission: 'points' as PermissionLevel } } + } + + setPermission(senderId, getDefaultPermission()) + return { success: false, message: 'Password incorrect' } + }) + + ipcMain.handle('auth:logout', (event) => { + const senderId = event?.sender?.id + if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission()) + return { success: true, data: { permission: getDefaultPermission() } } + }) + + ipcMain.handle( + 'auth:setPasswords', + (event, payload: { adminPassword?: string | null; pointsPassword?: string | null }) => { + const alreadyHasAdmin = hasSecret(SETTINGS_SECURITY_ADMIN) + if (alreadyHasAdmin && !requirePermission(event, 'admin')) + return { success: false, message: 'Permission denied' } + + const adminPasswordRaw = payload?.adminPassword + const pointsPasswordRaw = payload?.pointsPassword + + if (typeof adminPasswordRaw === 'string') { + const trimmed = adminPasswordRaw.trim() + if (trimmed.length === 0) setSetting(SETTINGS_SECURITY_ADMIN, '') + else { + if (!isSixDigit(trimmed)) + return { success: false, message: 'Admin password must be 6 digits' } + setSetting(SETTINGS_SECURITY_ADMIN, encryptSecret(trimmed)) + } + } + + if (typeof pointsPasswordRaw === 'string') { + const trimmed = pointsPasswordRaw.trim() + if (trimmed.length === 0) setSetting(SETTINGS_SECURITY_POINTS, '') + else { + if (!isSixDigit(trimmed)) + return { success: false, message: 'Points password must be 6 digits' } + setSetting(SETTINGS_SECURITY_POINTS, encryptSecret(trimmed)) + } + } + + if (!hasSecret(SETTINGS_SECURITY_RECOVERY)) { + const recovery = crypto.randomBytes(18).toString('base64url') + setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(recovery)) + return { success: true, data: { recoveryString: recovery } } + } + + return { success: true, data: {} } + } + ) + + ipcMain.handle('auth:generateRecovery', (event) => { + if (hasSecret(SETTINGS_SECURITY_ADMIN) && !requirePermission(event, 'admin')) + return { success: false, message: 'Permission denied' } + const recovery = crypto.randomBytes(18).toString('base64url') + setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(recovery)) + return { success: true, data: { recoveryString: recovery } } + }) + + ipcMain.handle('auth:resetByRecovery', (event, recoveryString: string) => { + const cipher = getSetting(SETTINGS_SECURITY_RECOVERY) + const plain = decryptSecret(cipher) + if (!plain || plain !== String(recoveryString ?? '').trim()) + return { success: false, message: 'Recovery string incorrect' } + + setSetting(SETTINGS_SECURITY_ADMIN, '') + setSetting(SETTINGS_SECURITY_POINTS, '') + + const newRecovery = crypto.randomBytes(18).toString('base64url') + setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(newRecovery)) + + const senderId = event?.sender?.id + if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission()) + return { success: true, data: { recoveryString: newRecovery } } + }) + + ipcMain.handle('auth:clearAll', (event) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + setSetting(SETTINGS_SECURITY_ADMIN, '') + setSetting(SETTINGS_SECURITY_POINTS, '') + setSetting(SETTINGS_SECURITY_RECOVERY, '') + const senderId = event?.sender?.id + if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission()) + return { success: true } + }) + + ipcMain.handle('data:exportJson', (event) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + const db = dbManager.getDb() + const students = db + .prepare('SELECT id, name, score, extra_json, created_at, updated_at FROM students') + .all() + const reasons = db + .prepare( + 'SELECT id, content, category, delta, is_system, updated_at, sync_state FROM reasons' + ) + .all() + const events = db + .prepare( + 'SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state, remote_id FROM score_events' + ) + .all() + const settingsRows = db.prepare('SELECT key, value FROM settings').all() as { + key: string + value: string + }[] + const settings = settingsRows.filter((r) => !String(r.key).startsWith('security_')) + return { success: true, data: JSON.stringify({ students, reasons, events, settings }, null, 2) } + }) + + ipcMain.handle('data:importJson', (event, jsonText: string) => { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + let parsed: any + try { + parsed = JSON.parse(String(jsonText ?? '')) + } catch { + return { success: false, message: 'Invalid JSON' } + } + + const students = Array.isArray(parsed?.students) ? parsed.students : [] + const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : [] + const events = Array.isArray(parsed?.events) ? parsed.events : [] + const settings = Array.isArray(parsed?.settings) ? parsed.settings : [] + + const db = dbManager.getDb() + try { + db.transaction(() => { + db.prepare('DELETE FROM score_events').run() + db.prepare('DELETE FROM students').run() + db.prepare('DELETE FROM reasons').run() + db.prepare("DELETE FROM settings WHERE key NOT LIKE 'security_%'").run() + + const insertStudent = db.prepare( + 'INSERT INTO students (name, score, extra_json) VALUES (?, ?, ?)' + ) + for (const s of students) { + const name = String(s?.name ?? '').trim() + if (!name) continue + const score = Number(s?.score ?? 0) + const extraJson = s?.extra_json != null ? String(s.extra_json) : null + insertStudent.run(name, Number.isFinite(score) ? score : 0, extraJson) + } + + const insertReason = db.prepare( + 'INSERT OR REPLACE INTO reasons (content, category, delta, is_system, sync_state) VALUES (?, ?, ?, ?, ?)' + ) + for (const r of reasons) { + const content = String(r?.content ?? '').trim() + if (!content) continue + const category = String(r?.category ?? '其他') + const delta = Number(r?.delta ?? 0) + const isSystem = Number(r?.is_system ?? 0) ? 1 : 0 + const syncState = Number(r?.sync_state ?? 0) ? 1 : 0 + insertReason.run( + content, + category, + Number.isFinite(delta) ? delta : 0, + isSystem, + syncState + ) + } + + const insertEvent = db.prepare( + 'INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state, remote_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ) + for (const e of events) { + const uuid = String(e?.uuid ?? '').trim() + const studentName = String(e?.student_name ?? '').trim() + const reasonContent = String(e?.reason_content ?? '').trim() + if (!uuid || !studentName || !reasonContent) continue + const delta = Number(e?.delta ?? 0) + const valPrev = Number(e?.val_prev ?? 0) + const valCurr = Number(e?.val_curr ?? 0) + const eventTime = String(e?.event_time ?? new Date().toISOString()) + const syncState = Number(e?.sync_state ?? 0) ? 1 : 0 + const remoteId = e?.remote_id != null ? String(e.remote_id) : null + insertEvent.run( + uuid, + studentName, + reasonContent, + Number.isFinite(delta) ? delta : 0, + Number.isFinite(valPrev) ? valPrev : 0, + Number.isFinite(valCurr) ? valCurr : 0, + eventTime, + syncState, + remoteId + ) + } + + const insertSetting = db.prepare( + 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)' + ) + for (const it of settings) { + const key = String(it?.key ?? '').trim() + if (!key || key.startsWith('security_')) continue + insertSetting.run(key, String(it?.value ?? '')) + } + })() + } catch (e: any) { + return { success: false, message: e?.message || 'Import failed' } + } + + startSyncIfRemote() + return { success: true } + }) createWindow() diff --git a/src/main/repos/EventRepository.ts b/src/main/repos/EventRepository.ts index 0c56be3..f959111 100644 --- a/src/main/repos/EventRepository.ts +++ b/src/main/repos/EventRepository.ts @@ -1,70 +1,109 @@ -import { Database } from 'better-sqlite3'; -import { v4 as uuidv4 } from 'uuid'; +import { Database } from 'better-sqlite3' +import { app } from 'electron' +import { v4 as uuidv4 } from 'uuid' export interface ScoreEvent { - id: number; - uuid: string; - student_name: string; - reason_content: string; - delta: number; - val_prev: number; - val_curr: number; - event_time: string; - sync_state: number; - remote_id?: string; + id: number + uuid: string + student_name: string + reason_content: string + delta: number + val_prev: number + val_curr: number + event_time: string + sync_state: number + remote_id?: string } export class EventRepository { constructor(private db: Database) {} findAll(limit = 100) { - return this.db.prepare(` + return this.db + .prepare( + ` SELECT * FROM score_events ORDER BY event_time DESC LIMIT ? - `).all(limit); + ` + ) + .all(limit) } create(event: { student_name: string; reason_content: string; delta: number }) { const lastInsertRowid = this.db.transaction(() => { // 1. Get current score - const student = this.db.prepare('SELECT score FROM students WHERE name = ?').get(event.student_name) as { score: number }; - if (!student) throw new Error('Student not found'); + const student = this.db + .prepare('SELECT score FROM students WHERE name = ?') + .get(event.student_name) as { score: number } + if (!student) throw new Error('Student not found') - const val_prev = student.score; - const val_curr = val_prev + event.delta; - const uuid = uuidv4(); - const event_time = new Date().toISOString(); + const val_prev = student.score + const val_curr = val_prev + event.delta + const uuid = uuidv4() + const event_time = new Date().toISOString() // 2. Insert event - const info = this.db.prepare(` + const info = this.db + .prepare( + ` INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state) VALUES (?, ?, ?, ?, ?, ?, ?, 0) - `).run(uuid, event.student_name, event.reason_content, event.delta, val_prev, val_curr, event_time); + ` + ) + .run( + uuid, + event.student_name, + event.reason_content, + event.delta, + val_prev, + val_curr, + event_time + ) // 3. Update student score - this.db.prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?') - .run(val_curr, event.student_name); + this.db + .prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?') + .run(val_curr, event.student_name) - return info.lastInsertRowid as number; - })(); + return info.lastInsertRowid as number + })() // 触发同步 (如果已连接) - // @ts-ignore - const { app } = require('electron'); if (app) { - app.emit('score-event-created'); + app.emit('score-event-created') } - return lastInsertRowid; + return lastInsertRowid } getUnsynced() { - return this.db.prepare('SELECT * FROM score_events WHERE sync_state = 0').all() as ScoreEvent[]; + return this.db.prepare('SELECT * FROM score_events WHERE sync_state = 0').all() as ScoreEvent[] } markSynced(uuid: string, remote_id?: string) { - this.db.prepare('UPDATE score_events SET sync_state = 1, remote_id = ? WHERE uuid = ?') - .run(remote_id, uuid); + this.db + .prepare('UPDATE score_events SET sync_state = 1, remote_id = ? WHERE uuid = ?') + .run(remote_id, uuid) + } + + deleteByUuid(uuid: string) { + this.db.transaction(() => { + // 1. Get event info + const event = this.db + .prepare('SELECT student_name, delta FROM score_events WHERE uuid = ?') + .get(uuid) as { student_name: string; delta: number } + if (!event) return + + // 2. Revert student score + this.db + .prepare( + 'UPDATE students SET score = score - ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?' + ) + .run(event.delta, event.student_name) + + // 3. Delete event + this.db.prepare('DELETE FROM score_events WHERE uuid = ?').run(uuid) + })() } } diff --git a/src/main/repos/ReasonRepository.ts b/src/main/repos/ReasonRepository.ts index b544751..9a9980a 100644 --- a/src/main/repos/ReasonRepository.ts +++ b/src/main/repos/ReasonRepository.ts @@ -1,42 +1,47 @@ -import { Database } from 'better-sqlite3'; +import { Database } from 'better-sqlite3' export interface Reason { - id: number; - content: string; - category: string; - delta: number; - is_system: number; - sync_state: number; + id: number + content: string + category: string + delta: number + is_system: number + sync_state: number } export class ReasonRepository { constructor(private db: Database) {} findAll() { - return this.db.prepare('SELECT * FROM reasons ORDER BY category ASC, content ASC').all() as Reason[]; + return this.db + .prepare('SELECT * FROM reasons ORDER BY category ASC, content ASC') + .all() as Reason[] } create(reason: Omit) { - const info = this.db.prepare( - 'INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, 0)' - ).run(reason.content, reason.category, reason.delta); - return info.lastInsertRowid as number; + const info = this.db + .prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, 0)') + .run(reason.content, reason.category, reason.delta) + return info.lastInsertRowid as number } update(id: number, reason: Partial) { - const sets: string[] = []; - const vals: any[] = []; + const sets: string[] = [] + const vals: any[] = [] Object.entries(reason).forEach(([key, val]) => { if (key !== 'id') { - sets.push(`${key} = ?`); - vals.push(val); + sets.push(`${key} = ?`) + vals.push(val) } - }); - vals.push(id); - this.db.prepare(`UPDATE reasons SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(...vals); + }) + vals.push(id) + this.db + .prepare(`UPDATE reasons SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`) + .run(...vals) } delete(id: number) { - this.db.prepare('DELETE FROM reasons WHERE id = ? AND is_system = 0').run(id); + const info = this.db.prepare('DELETE FROM reasons WHERE id = ?').run(id) + return info.changes } } diff --git a/src/main/repos/StudentRepository.ts b/src/main/repos/StudentRepository.ts index 84c0187..3734e41 100644 --- a/src/main/repos/StudentRepository.ts +++ b/src/main/repos/StudentRepository.ts @@ -1,43 +1,51 @@ -import { Database } from 'better-sqlite3'; +import { Database } from 'better-sqlite3' export interface Student { - id: number; - name: string; - score: number; - extra_json?: string; + id: number + name: string + score: number + extra_json?: string } export class StudentRepository { constructor(private db: Database) {} findAll() { - return this.db.prepare('SELECT * FROM students ORDER BY score DESC, name ASC').all() as Student[]; + return this.db + .prepare('SELECT * FROM students ORDER BY score DESC, name ASC') + .all() as Student[] } create(student: { name: string }) { - const info = this.db.prepare( - 'INSERT INTO students (name) VALUES (?)' - ).run(student.name); - return info.lastInsertRowid as number; + const info = this.db.prepare('INSERT INTO students (name) VALUES (?)').run(student.name) + return info.lastInsertRowid as number } update(id: number, student: Partial) { - const sets: string[] = []; - const vals: any[] = []; + const sets: string[] = [] + const vals: any[] = [] Object.entries(student).forEach(([key, val]) => { if (key !== 'id') { - sets.push(`${key} = ?`); - vals.push(val); + sets.push(`${key} = ?`) + vals.push(val) } - }); - vals.push(id); - this.db.prepare(`UPDATE students SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(...vals); + }) + vals.push(id) + this.db + .prepare( + `UPDATE students SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + ) + .run(...vals) } delete(id: number) { this.db.transaction(() => { - this.db.prepare('DELETE FROM score_events WHERE student_id = ?').run(id); - this.db.prepare('DELETE FROM students WHERE id = ?').run(id); - })(); + this.db + .prepare( + 'DELETE FROM score_events WHERE student_name IN (SELECT name FROM students WHERE id = ?)' + ) + .run(id) + this.db.prepare('DELETE FROM students WHERE id = ?').run(id) + })() } } diff --git a/src/main/services/LoggerService.ts b/src/main/services/LoggerService.ts new file mode 100644 index 0000000..c0088d7 --- /dev/null +++ b/src/main/services/LoggerService.ts @@ -0,0 +1,77 @@ +import fs from 'fs' +import path from 'path' + +export type LogLevel = 'info' | 'warn' | 'error' | 'debug' + +export class LoggerService { + private logPath: string + private currentLevel: LogLevel = 'info' + + constructor(logDir: string) { + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + this.logPath = path.join(logDir, 'app.log') + } + + setLevel(level: LogLevel) { + this.currentLevel = level + } + + private shouldLog(level: LogLevel): boolean { + const levels: LogLevel[] = ['debug', 'info', 'warn', 'error'] + return levels.indexOf(level) >= levels.indexOf(this.currentLevel) + } + + log(level: LogLevel, message: string, ...args: any[]) { + if (!this.shouldLog(level)) return + + const timestamp = new Date().toISOString() + const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message} ${args.length ? JSON.stringify(args) : ''}\n` + + console.log(formattedMessage.trim()) + + try { + fs.appendFileSync(this.logPath, formattedMessage) + } catch (err) { + console.error('Failed to write to log file', err) + } + } + + info(message: string, ...args: any[]) { + this.log('info', message, ...args) + } + warn(message: string, ...args: any[]) { + this.log('warn', message, ...args) + } + error(message: string, ...args: any[]) { + this.log('error', message, ...args) + } + debug(message: string, ...args: any[]) { + this.log('debug', message, ...args) + } + + getLogPath() { + return this.logPath + } + + clearLogs() { + try { + fs.writeFileSync(this.logPath, '') + } catch (err) { + console.error('Failed to clear logs', err) + } + } + + readLogs(lines: number = 100): string[] { + try { + if (!fs.existsSync(this.logPath)) return [] + const content = fs.readFileSync(this.logPath, 'utf-8') + const allLines = content.split('\n').filter((line) => line.trim().length > 0) + return allLines.slice(-lines) + } catch (err) { + console.error('Failed to read logs', err) + return [] + } + } +} diff --git a/src/main/services/SyncEngine.ts b/src/main/services/SyncEngine.ts index fa3005f..b503e70 100644 --- a/src/main/services/SyncEngine.ts +++ b/src/main/services/SyncEngine.ts @@ -1,55 +1,47 @@ -import { WsClient } from './WsClient'; -import { DbManager } from '../db/DbManager'; -import { StudentRepository } from '../repos/StudentRepository'; -import { EventRepository } from '../repos/EventRepository'; -import { ReasonRepository } from '../repos/ReasonRepository'; +import { WsClient } from './WsClient' +import { DbManager } from '../db/DbManager' export class SyncEngine { - private wsClient: WsClient; - private db: DbManager; - private _studentRepo: StudentRepository; - private _eventRepo: EventRepository; - private _reasonRepo: ReasonRepository; - private isSyncing: boolean = false; + private wsClient: WsClient + private db: DbManager + private isSyncing: boolean = false constructor(wsClient: WsClient, db: DbManager) { - this.wsClient = wsClient; - this.db = db; - this._studentRepo = new StudentRepository(db.getDb()); - this._eventRepo = new EventRepository(db.getDb()); - this._reasonRepo = new ReasonRepository(db.getDb()); + this.wsClient = wsClient + this.db = db - this.init(); + this.init() } private init() { // 监听 WS 事件 this.wsClient.on('connected', () => { - this.syncAll(); - }); + this.syncAll() + }) this.wsClient.on('event', (msg) => { - this.handleRemoteEvent(msg); - }); + this.handleRemoteEvent(msg) + }) this.wsClient.on('reason_sync', (msg) => { - this.handleReasonSync(msg); - }); + this.handleReasonSync(msg) + }) } // 启动同步任务(处理 Outbox) async startOutboxSync() { - if (this.isSyncing) return; - this.isSyncing = true; + if (this.isSyncing) return + this.isSyncing = true try { // 1. 获取未同步的事件 - const unsyncedEvents = this.db.getDb().prepare( - 'SELECT * FROM score_events WHERE sync_state = 0 ORDER BY id ASC' - ).all() as any[]; + const unsyncedEvents = this.db + .getDb() + .prepare('SELECT * FROM score_events WHERE sync_state = 0 ORDER BY id ASC') + .all() as any[] for (const event of unsyncedEvents) { - if (!this.wsClient.isConnected()) break; + if (!this.wsClient.isConnected()) break try { await this.wsClient.send({ @@ -61,88 +53,110 @@ export class SyncEngine { delta: event.delta, event_time: event.event_time } - }); + }) // 标记为已同步 - this.db.getDb().prepare( - 'UPDATE score_events SET sync_state = 1 WHERE id = ?' - ).run(event.id); + this.db + .getDb() + .prepare('UPDATE score_events SET sync_state = 1 WHERE id = ?') + .run(event.id) } catch (e) { - console.error(`[Sync] Failed to sync event ${event.uuid}`, e); + console.error(`[Sync] Failed to sync event ${event.uuid}`, e) } } } finally { - this.isSyncing = false; + this.isSyncing = false } } // 触发全量同步 async triggerFullSync() { - await this.syncAll(); + await this.syncAll() } // 全量对齐 private async syncAll() { - if (!this.wsClient.isConnected()) return; + if (!this.wsClient.isConnected()) return - console.log('[Sync] Starting full sync...'); + console.log('[Sync] Starting full sync...') try { // 1. 同步理由 - const resReasons = await this.wsClient.send({ type: 'query_reasons' }); + const resReasons = await this.wsClient.send({ type: 'query_reasons' }) if (resReasons.data) { - this.handleReasonSync(resReasons); + this.handleReasonSync(resReasons) } // 2. 同步学生分值 (Local-first: 本地值为准,除非服务器有更正) - const _resStudents = await this.wsClient.send({ type: 'query_students' }); + await this.wsClient.send({ type: 'query_students' }) // TODO: 实现更复杂的对齐逻辑 // 3. 处理 Outbox - await this.startOutboxSync(); - - console.log('[Sync] Full sync completed'); + await this.startOutboxSync() + + console.log('[Sync] Full sync completed') } catch (e) { - console.error('[Sync] Full sync failed', e); + console.error('[Sync] Full sync failed', e) } } private handleRemoteEvent(msg: any) { // 远程推送的事件,通常是其他客户端的操作 // 本地需要根据此事件更新分值,但不要再产生新的同步事件(避免循环) - const { data } = msg; - if (!data) return; + const { data } = msg + if (!data) return this.db.getDb().transaction(() => { // 更新学生分值 - this.db.getDb().prepare( - 'UPDATE students SET score = score + ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?' - ).run(data.delta, data.student_name); + this.db + .getDb() + .prepare( + 'UPDATE students SET score = score + ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?' + ) + .run(data.delta, data.student_name) // 记录事件(标记为已同步,防止回环) - this.db.getDb().prepare(` + this.db + .getDb() + .prepare( + ` INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state) SELECT ?, ?, ?, ?, score - ?, score, ?, 1 FROM students WHERE name = ? - `).run(data.uuid, data.student_name, data.reason_content, data.delta, data.delta, data.event_time || new Date().toISOString(), data.student_name); - })(); + ` + ) + .run( + data.uuid, + data.student_name, + data.reason_content, + data.delta, + data.delta, + data.event_time || new Date().toISOString(), + data.student_name + ) + })() } private handleReasonSync(msg: any) { - const reasons = msg.data; - if (!Array.isArray(reasons)) return; + const reasons = msg.data + if (!Array.isArray(reasons)) return this.db.getDb().transaction(() => { for (const r of reasons) { // 如果是系统理由,且 category 匹配,则归入 __config__ 组 (按照协议要求) - const category = r.is_system ? '__config__' : r.category; - - this.db.getDb().prepare(` + const category = r.is_system ? '__config__' : r.category + + this.db + .getDb() + .prepare( + ` INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?) ON CONFLICT(content) DO UPDATE SET category = excluded.category, delta = excluded.delta - `).run(r.content, category, r.delta, r.is_system ? 1 : 0); + ` + ) + .run(r.content, category, r.delta, r.is_system ? 1 : 0) } - })(); + })() } } diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts index 4c8c168..37a553f 100644 --- a/src/main/services/ThemeService.ts +++ b/src/main/services/ThemeService.ts @@ -1,98 +1,104 @@ -import { ipcMain, BrowserWindow } from 'electron'; -import fs from 'fs'; -import path from 'path'; -import chokidar from 'chokidar'; +import { ipcMain, BrowserWindow } from 'electron' +import fs from 'fs' +import path from 'path' +import { FSWatcher, watch } from 'chokidar' export interface ThemeConfig { - name: string; - id: string; - mode: 'light' | 'dark'; + name: string + id: string + mode: 'light' | 'dark' config: { - tdesign: Record; - custom: Record; - }; + tdesign: Record + custom: Record + } } export class ThemeService { - private themeDir: string; - private watcher: chokidar.FSWatcher | null = null; - private currentThemeId: string = 'light-default'; + private themeDir: string + private watcher: FSWatcher | null = null + private currentThemeId: string = 'light-default' + private canSetTheme: ((senderId: number) => boolean) | null = null - constructor(themeDir: string) { - this.themeDir = themeDir; + constructor(themeDir: string, canSetTheme?: (senderId: number) => boolean) { + this.themeDir = themeDir + this.canSetTheme = canSetTheme || null } public init() { - this.setupWatcher(); - this.registerIpc(); + this.setupWatcher() + this.registerIpc() } private setupWatcher() { - if (this.watcher) this.watcher.close(); - - this.watcher = chokidar.watch(this.themeDir, { - ignored: /(^|[\/\\])\../, + if (this.watcher) this.watcher.close() + + this.watcher = watch(this.themeDir, { + ignored: /(^|[/\\])\../, persistent: true - }); + }) this.watcher.on('change', (filePath) => { if (filePath.endsWith('.json')) { - console.log(`Theme file changed: ${filePath}`); - this.notifyThemeUpdate(); + console.log(`Theme file changed: ${filePath}`) + this.notifyThemeUpdate() } - }); + }) } private registerIpc() { ipcMain.handle('theme:list', async () => { - return { success: true, data: this.getThemeList() }; - }); + return { success: true, data: this.getThemeList() } + }) ipcMain.handle('theme:current', async () => { - const theme = this.getThemeById(this.currentThemeId); - return { success: true, data: theme }; - }); + const theme = this.getThemeById(this.currentThemeId) + return { success: true, data: theme } + }) - ipcMain.handle('theme:set', async (_, themeId: string) => { - this.currentThemeId = themeId; - this.notifyThemeUpdate(); - return { success: true }; - }); + ipcMain.handle('theme:set', async (event, themeId: string) => { + const senderId = event?.sender?.id + if (this.canSetTheme && typeof senderId === 'number') { + if (!this.canSetTheme(senderId)) return { success: false, message: 'Permission denied' } + } + this.currentThemeId = themeId + this.notifyThemeUpdate() + return { success: true } + }) } private getThemeList(): ThemeConfig[] { try { - if (!fs.existsSync(this.themeDir)) return []; - const files = fs.readdirSync(this.themeDir); + if (!fs.existsSync(this.themeDir)) return [] + const files = fs.readdirSync(this.themeDir) return files - .filter(f => f.endsWith('.json')) - .map(f => { + .filter((f) => f.endsWith('.json')) + .map((f) => { try { - const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8'); - return JSON.parse(content) as ThemeConfig; - } catch (e) { - return null; + const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8') + return JSON.parse(content) as ThemeConfig + } catch { + return null } }) - .filter((t): t is ThemeConfig => t !== null); + .filter((t): t is ThemeConfig => t !== null) } catch (e) { - console.error('Failed to read themes:', e); - return []; + console.error('Failed to read themes:', e) + return [] } } private getThemeById(id: string): ThemeConfig | null { - const list = this.getThemeList(); - return list.find(t => t.id === id) || list[0] || null; + const list = this.getThemeList() + return list.find((t) => t.id === id) || list[0] || null } private notifyThemeUpdate() { - const theme = this.getThemeById(this.currentThemeId); - if (!theme) return; + const theme = this.getThemeById(this.currentThemeId) + if (!theme) return - const windows = BrowserWindow.getAllWindows(); + const windows = BrowserWindow.getAllWindows() for (const win of windows) { - win.webContents.send('theme:updated', theme); + win.webContents.send('theme:updated', theme) } } } diff --git a/src/main/services/WsClient.ts b/src/main/services/WsClient.ts index 41231c8..cb7908e 100644 --- a/src/main/services/WsClient.ts +++ b/src/main/services/WsClient.ts @@ -1,135 +1,142 @@ -import WebSocket from 'ws'; -import { v4 as uuidv4 } from 'uuid'; -import { EventEmitter } from 'events'; +import WebSocket from 'ws' +import { v4 as uuidv4 } from 'uuid' +import { EventEmitter } from 'events' export class WsClient extends EventEmitter { - private ws: WebSocket | null = null; - private url: string = ''; - private reconnectTimer: NodeJS.Timeout | null = null; - private heartbeatTimer: NodeJS.Timeout | null = null; - private pendingRequests: Map = new Map(); - private isManualClose: boolean = false; + private ws: WebSocket | null = null + private url: string = '' + private reconnectTimer: NodeJS.Timeout | null = null + private heartbeatTimer: NodeJS.Timeout | null = null + private pendingRequests: Map< + string, + { + resolve: (value: any) => void + reject: (reason?: any) => void + timeout: NodeJS.Timeout + } + > = new Map() + private isManualClose: boolean = false constructor() { - super(); + super() } connect(url: string) { - this.url = url; - this.isManualClose = false; - this._connect(); + this.url = url + this.isManualClose = false + this._connect() } private _connect() { if (this.ws) { - this.ws.terminate(); + this.ws.terminate() } - console.log(`[WS] Connecting to ${this.url}...`); - this.ws = new WebSocket(this.url); + console.log(`[WS] Connecting to ${this.url}...`) + this.ws = new WebSocket(this.url) this.ws.on('open', () => { - console.log('[WS] Connected'); - this.emit('connected'); - this.startHeartbeat(); + console.log('[WS] Connected') + this.emit('connected') + this.startHeartbeat() if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null } - }); + }) this.ws.on('message', (data) => { try { - const msg = JSON.parse(data.toString()); - this.handleMessage(msg); + const msg = JSON.parse(data.toString()) + this.handleMessage(msg) } catch (e) { - console.error('[WS] Parse error', e); + console.error('[WS] Parse error', e) } - }); + }) this.ws.on('close', () => { - console.log('[WS] Closed'); - this.stopHeartbeat(); - this.emit('disconnected'); + console.log('[WS] Closed') + this.stopHeartbeat() + this.emit('disconnected') if (!this.isManualClose) { - this.reconnect(); + this.reconnect() } - }); + }) this.ws.on('error', (err) => { - console.error('[WS] Error', err); - }); + console.error('[WS] Error', err) + }) } private handleMessage(msg: any) { // 1. 处理请求响应 (seq 匹配) if (msg.seq && this.pendingRequests.has(msg.seq)) { - const { resolve, timeout } = this.pendingRequests.get(msg.seq)!; - clearTimeout(timeout); - this.pendingRequests.delete(msg.seq); - resolve(msg); - return; + const { resolve, timeout } = this.pendingRequests.get(msg.seq)! + clearTimeout(timeout) + this.pendingRequests.delete(msg.seq) + resolve(msg) + return } // 2. 处理服务器主动推送 (事件) if (msg.type === 'event' || msg.type === 'correction') { - this.emit('event', msg); + this.emit('event', msg) } else if (msg.type === 'reason_sync') { - this.emit('reason_sync', msg); + this.emit('reason_sync', msg) } } private reconnect() { - if (this.reconnectTimer) return; + if (this.reconnectTimer) return this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this._connect(); - }, 5000); + this.reconnectTimer = null + this._connect() + }, 5000) } private startHeartbeat() { - this.stopHeartbeat(); + this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { - this.send({ type: 'heartbeat' }).catch(() => {}); - }, 30000); + this.send({ type: 'heartbeat' }).catch(() => {}) + }, 30000) } private stopHeartbeat() { if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null } } send(data: any, timeoutMs: number = 5000): Promise { return new Promise((resolve, reject) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - return reject(new Error('WebSocket not connected')); + return reject(new Error('WebSocket not connected')) } - const seq = uuidv4(); - const payload = { ...data, seq }; + const seq = uuidv4() + const payload = { ...data, seq } const timeout = setTimeout(() => { - this.pendingRequests.delete(seq); - reject(new Error('Request timeout')); - }, timeoutMs); + this.pendingRequests.delete(seq) + reject(new Error('Request timeout')) + }, timeoutMs) - this.pendingRequests.set(seq, { resolve, reject, timeout }); - this.ws.send(JSON.stringify(payload)); - }); + this.pendingRequests.set(seq, { resolve, reject, timeout }) + this.ws.send(JSON.stringify(payload)) + }) } close() { - this.isManualClose = true; - this.stopHeartbeat(); + this.isManualClose = true + this.stopHeartbeat() if (this.ws) { - this.ws.close(); - this.ws = null; + this.ws.close() + this.ws = null } } isConnected() { - return this.ws?.readyState === WebSocket.OPEN; + return this.ws?.readyState === WebSocket.OPEN } } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 333da0e..beaae0a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,7 +1,9 @@ import { ElectronApi } from './types' +import { ElectronAPI } from '@electron-toolkit/preload' declare global { interface Window { + electron: ElectronAPI api: ElectronApi } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0afb58d..4890059 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' +import { electronAPI } from '@electron-toolkit/preload' import { ThemeConfig } from './types' const api = { @@ -22,21 +23,47 @@ const api = { queryReasons: () => ipcRenderer.invoke('db:reason:query'), createReason: (data: any) => ipcRenderer.invoke('db:reason:create', data), updateReason: (id: number, data: any) => ipcRenderer.invoke('db:reason:update', id, data), - deleteReason: (id: number) => ipcRenderer.invoke('db:deleteReason', id), + deleteReason: (id: number) => ipcRenderer.invoke('db:reason:delete', id), // DB - Event queryEvents: (params: any) => ipcRenderer.invoke('db:event:query', params), createEvent: (data: any) => ipcRenderer.invoke('db:event:create', data), + deleteEvent: (uuid: string) => ipcRenderer.invoke('db:event:delete', uuid), + queryEventsByStudent: (params: any) => ipcRenderer.invoke('db:event:queryByStudent', params), + queryLeaderboard: (params: any) => ipcRenderer.invoke('db:leaderboard:query', params), // Settings & Sync getSettings: () => ipcRenderer.invoke('db:getSettings'), updateSetting: (key: string, value: string) => ipcRenderer.invoke('db:updateSetting', key, value), getSyncStatus: () => ipcRenderer.invoke('ws:getStatus'), triggerSync: () => ipcRenderer.invoke('ws:triggerSync'), + + // Auth & Security + authGetStatus: () => ipcRenderer.invoke('auth:getStatus'), + authLogin: (password: string) => ipcRenderer.invoke('auth:login', password), + authLogout: () => ipcRenderer.invoke('auth:logout'), + authSetPasswords: (payload: { adminPassword?: string | null; pointsPassword?: string | null }) => + ipcRenderer.invoke('auth:setPasswords', payload), + authGenerateRecovery: () => ipcRenderer.invoke('auth:generateRecovery'), + authResetByRecovery: (recoveryString: string) => + ipcRenderer.invoke('auth:resetByRecovery', recoveryString), + authClearAll: () => ipcRenderer.invoke('auth:clearAll'), + + // Data import/export + exportDataJson: () => ipcRenderer.invoke('data:exportJson'), + importDataJson: (jsonText: string) => ipcRenderer.invoke('data:importJson', jsonText), + + // Logger + queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines), + clearLogs: () => ipcRenderer.invoke('log:clear'), + setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level), + writeLog: (payload: { level: string; message: string; meta?: any }) => + ipcRenderer.invoke('log:write', payload) } if (process.contextIsolated) { try { + contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) } catch (error) { console.error(error) diff --git a/src/preload/types.ts b/src/preload/types.ts index a412a85..8bf8a1a 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -1,45 +1,91 @@ export interface IpcResponse { - success: boolean; - data?: T; - message?: string; + success: boolean + data?: T + message?: string } export interface ThemeConfig { - name: string; - id: string; - mode: 'light' | 'dark'; + name: string + id: string + mode: 'light' | 'dark' config: { - tdesign: Record; - custom: Record; - }; + tdesign: Record + custom: Record + } } export interface ElectronApi { // Theme - getThemes: () => Promise>; - getCurrentTheme: () => Promise>; - setTheme: (themeId: string) => Promise>; - onThemeChanged: (callback: (theme: ThemeConfig) => void) => () => void; + getThemes: () => Promise> + getCurrentTheme: () => Promise> + setTheme: (themeId: string) => Promise> + onThemeChanged: (callback: (theme: ThemeConfig) => void) => () => void // DB - Student - queryStudents: (params?: any) => Promise>; - createStudent: (data: { name: string }) => Promise>; - updateStudent: (id: number, data: any) => Promise>; - deleteStudent: (id: number) => Promise>; + queryStudents: (params?: any) => Promise> + createStudent: (data: { name: string }) => Promise> + updateStudent: (id: number, data: any) => Promise> + deleteStudent: (id: number) => Promise> // DB - Reason - queryReasons: () => Promise>; - createReason: (data: any) => Promise>; - updateReason: (id: number, data: any) => Promise>; - deleteReason: (id: number) => Promise>; - + queryReasons: () => Promise> + createReason: (data: any) => Promise> + updateReason: (id: number, data: any) => Promise> + deleteReason: (id: number) => Promise> + // DB - Event - queryEvents: (params?: any) => Promise>; - createEvent: (data: { student_name: string; reason_content: string; delta: number }) => Promise>; + queryEvents: (params?: any) => Promise> + createEvent: (data: { + student_name: string + reason_content: string + delta: number + }) => Promise> + deleteEvent: (uuid: string) => Promise> + queryEventsByStudent: (params: { + student_name: string + limit?: number + startTime?: string | null + }) => Promise> + queryLeaderboard: (params: { + range: 'today' | 'week' | 'month' + }) => Promise> // Settings & Sync - getSettings: () => Promise>>; - updateSetting: (key: string, value: string) => Promise>; - getSyncStatus: () => Promise>; - triggerSync: () => Promise>; + getSettings: () => Promise>> + updateSetting: (key: string, value: string) => Promise> + getSyncStatus: () => Promise> + triggerSync: () => Promise> + + // Auth & Security + authGetStatus: () => Promise< + IpcResponse<{ + permission: 'admin' | 'points' | 'view' + hasAdminPassword: boolean + hasPointsPassword: boolean + hasRecoveryString: boolean + }> + > + authLogin: (password: string) => Promise> + authLogout: () => Promise> + authSetPasswords: (payload: { + adminPassword?: string | null + pointsPassword?: string | null + }) => Promise> + authGenerateRecovery: () => Promise> + authResetByRecovery: (recoveryString: string) => Promise> + authClearAll: () => Promise> + + // Data import/export + exportDataJson: () => Promise> + importDataJson: (jsonText: string) => Promise> + + // Logger + queryLogs: (lines?: number) => Promise> + clearLogs: () => Promise> + setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') => Promise> + writeLog: (payload: { + level: 'debug' | 'info' | 'warn' | 'error' + message: string + meta?: any + }) => Promise> } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 51f0b26..7c43a93 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,22 +1,31 @@ -import { Layout, Menu, Space } from 'tdesign-react' +import { Layout, Menu, Space, Dialog, Input, Button, Tag, MessagePlugin } from 'tdesign-react' import { useState, useEffect } from 'react' -import { UserIcon, SettingIcon, HistoryIcon, RootListIcon } from 'tdesign-icons-react' +import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react' import { StudentManager } from './components/StudentManager' import { Settings } from './components/Settings' -import { EventHistory } from './components/EventHistory' import { ReasonManager } from './components/ReasonManager' +import { ScoreManager } from './components/ScoreManager' +import { Leaderboard } from './components/Leaderboard' import { Wizard } from './components/Wizard' -import { ThemeProvider, useTheme } from './contexts/ThemeContext' +import { ThemeProvider } from './contexts/ThemeContext' +import WavyLines from './assets/wavy-lines.svg' const { Header, Content, Aside } = Layout function MainContent(): React.JSX.Element { - const [activeMenu, setActiveMenu] = useState('students') + const [activeMenu, setActiveMenu] = useState('score') const [wizardVisible, setWizardVisible] = useState(false) - const { currentTheme } = useTheme() + const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view') + const [hasAnyPassword, setHasAnyPassword] = useState(false) + const [authVisible, setAuthVisible] = useState(false) + const [authPassword, setAuthPassword] = useState('') + const [authLoading, setAuthLoading] = useState(false) + const [appearanceBackground, setAppearanceBackground] = useState<'none' | 'wavy-lines'>('none') + const [appearanceBlur, setAppearanceBlur] = useState(0) useEffect(() => { const checkWizard = async () => { + if (!(window as any).api) return const res = await (window as any).api.getSettings() if (res.success && res.data && res.data.is_wizard_completed !== '1') { setWizardVisible(true) @@ -25,24 +34,115 @@ function MainContent(): React.JSX.Element { checkWizard() }, []) - const renderContent = () => { - switch (activeMenu) { - case 'students': - return - case 'history': - return - case 'reasons': - return - case 'settings': - return - default: - return + useEffect(() => { + const loadAuthAndSettings = async () => { + if (!(window as any).api) return + const authRes = await (window as any).api.authGetStatus() + if (authRes?.success && authRes.data) { + setPermission(authRes.data.permission) + const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword) + setHasAnyPassword(anyPwd) + if (anyPwd && authRes.data.permission === 'view') setAuthVisible(true) + } + const settingsRes = await (window as any).api.getSettings() + if (settingsRes?.success && settingsRes.data) { + const bg = settingsRes.data.appearance_background + const blur = Number(settingsRes.data.appearance_background_blur || 0) + setAppearanceBackground(bg === 'wavy-lines' ? 'wavy-lines' : 'none') + setAppearanceBlur(Number.isFinite(blur) ? blur : 0) + } + } + + loadAuthAndSettings() + + const onSettingsUpdated = (e: any) => { + const key = e?.detail?.key + const value = e?.detail?.value + if (key === 'appearance_background') { + setAppearanceBackground(value === 'wavy-lines' ? 'wavy-lines' : 'none') + } + if (key === 'appearance_background_blur') { + const blur = Number(value || 0) + setAppearanceBlur(Number.isFinite(blur) ? blur : 0) + } + } + window.addEventListener('ss:settings-updated', onSettingsUpdated as any) + return () => window.removeEventListener('ss:settings-updated', onSettingsUpdated as any) + }, []) + + const login = async () => { + if (!(window as any).api) return + setAuthLoading(true) + const res = await (window as any).api.authLogin(authPassword) + setAuthLoading(false) + if (res.success && res.data) { + setPermission(res.data.permission) + setAuthVisible(false) + setAuthPassword('') + MessagePlugin.success('权限已解锁') + } else { + MessagePlugin.error(res.message || '密码错误') } } + const logout = async () => { + if (!(window as any).api) return + const res = await (window as any).api.authLogout() + if (res?.success && res.data) { + setPermission(res.data.permission) + MessagePlugin.success('已切换为只读') + } + } + + const renderContent = () => { + switch (activeMenu) { + case 'students': + return + case 'score': + return + case 'leaderboard': + return + case 'reasons': + return + case 'settings': + return + default: + return + } + } + + const permissionTag = ( + + {permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'} + + ) + return ( -