diff --git a/.gitignore b/.gitignore index caed81c..1ab4fe2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ out .DS_Store .eslintcache *.log* +logs build db.sqlite diff --git a/electron-builder.yml b/electron-builder.yml index a0697c7..a770c96 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,5 @@ -appId: com.electron.app -productName: secscore +appId: com.secscore.app +productName: SecScore directories: buildResources: build files: @@ -11,13 +11,24 @@ files: - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' asarUnpack: - resources/** +afterPack: ./scripts/afterPack.cjs +extraFiles: + - from: themes + to: themes +extraResources: + - from: resources + to: assets win: - executableName: secscore + target: + - zip + executableName: SecScore + icon: resources/SecScore_logo.ico nsis: artifactName: ${name}-${version}-setup.${ext} shortcutName: ${productName} uninstallDisplayName: ${productName} createDesktopShortcut: always + allowToChangeInstallationDirectory: true mac: entitlementsInherit: build/entitlements.mac.plist extendInfo: diff --git a/package.json b/package.json index 01fdf0f..8a08fdb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "better-sqlite3": "^12.6.0", "chokidar": "^5.0.0", "electron-updater": "^6.3.9", + "pinyin-pro": "^3.27.0", "tdesign-react": "^1.16.3", "uuid": "^13.0.0", "ws": "^8.19.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 190ff7e..79a9252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: electron-updater: specifier: ^6.3.9 version: 6.7.3 + pinyin-pro: + specifier: ^3.27.0 + version: 3.27.0 tdesign-react: specifier: ^1.16.3 version: 1.16.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2351,6 +2354,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pinyin-pro@3.27.0: + resolution: {integrity: sha512-Osdgjwe7Rm17N2paDMM47yW+jUIUH3+0RGo8QP39ZTLpTaJVDK0T58hOLaMQJbcMmAebVuK2ePunTEVEx1clNQ==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -5514,6 +5520,8 @@ snapshots: picomatch@4.0.3: {} + pinyin-pro@3.27.0: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 diff --git a/resources/SecScore.svg b/resources/SecScore.svg new file mode 100644 index 0000000..39a5ac6 --- /dev/null +++ b/resources/SecScore.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/SecScore_logo.ico b/resources/SecScore_logo.ico new file mode 100644 index 0000000..3b58071 Binary files /dev/null and b/resources/SecScore_logo.ico differ diff --git a/resources/icon.png b/resources/icon.png deleted file mode 100644 index cf9e8b2..0000000 Binary files a/resources/icon.png and /dev/null differ diff --git a/scripts/afterPack.cjs b/scripts/afterPack.cjs new file mode 100644 index 0000000..049d355 --- /dev/null +++ b/scripts/afterPack.cjs @@ -0,0 +1,10 @@ +const fs = require('fs') +const path = require('path') + +const afterPack = async (context) => { + const appOutDir = context.appOutDir + fs.mkdirSync(path.join(appOutDir, 'data'), { recursive: true }) +} + +module.exports = afterPack +module.exports.default = afterPack diff --git a/src/main/db/DbManager.ts b/src/main/db/DbManager.ts index 15bda42..684f063 100644 --- a/src/main/db/DbManager.ts +++ b/src/main/db/DbManager.ts @@ -51,6 +51,24 @@ export class DbManager { ) `) + this.db.exec(` + CREATE TABLE IF NOT EXISTS settlements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + + const scoreEventColumns = this.db.prepare(`PRAGMA table_info(score_events)`).all() as { + name: string + }[] + const hasSettlementId = scoreEventColumns.some((c) => c.name === 'settlement_id') + if (!hasSettlementId) { + this.db.exec(`ALTER TABLE score_events ADD COLUMN settlement_id INTEGER`) + this.db.exec(`CREATE INDEX IF NOT EXISTS idx_score_events_settlement_id ON score_events(settlement_id)`) + } + // 建立系统设置表 this.db.exec(` CREATE TABLE IF NOT EXISTS settings ( diff --git a/src/main/index.ts b/src/main/index.ts index 2cb8d38..bbbb36f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,15 +1,16 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron' -import { join } from 'path' +import { join, dirname } 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 icon from '../../resources/SecScore_logo.ico?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' import { EventRepository } from './repos/EventRepository' +import { SettlementRepository } from './repos/SettlementRepository' import { WsClient } from './services/WsClient' import { SyncEngine } from './services/SyncEngine' @@ -18,9 +19,10 @@ function createWindow(): void { const mainWindow = new BrowserWindow({ width: 900, height: 670, + title: 'SecScore', show: false, autoHideMenuBar: true, - ...(process.platform === 'linux' ? { icon } : {}), + icon, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false @@ -59,22 +61,31 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) - // 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 }) + const appRoot = is.dev ? process.cwd() : dirname(process.execPath) + + const ensureWritableDir = (preferred: string, fallback: string) => { + try { + if (!fs.existsSync(preferred)) fs.mkdirSync(preferred, { recursive: true }) + return preferred + } catch { + if (!fs.existsSync(fallback)) fs.mkdirSync(fallback, { recursive: true }) + return fallback + } } + const dataRoot = is.dev + ? process.cwd() + : ensureWritableDir(join(appRoot, 'data'), join(app.getPath('userData'), 'secscore-data')) + // 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 }) - } + const themeDir = is.dev + ? join(process.cwd(), 'themes') + : ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes')) + if (!is.dev) { try { const existing = fs.readdirSync(themeDir).filter((f) => f.toLowerCase().endsWith('.json')) @@ -143,6 +154,7 @@ app.whenReady().then(() => { const studentRepo = new StudentRepository(dbManager.getDb()) const reasonRepo = new ReasonRepository(dbManager.getDb()) const eventRepo = new EventRepository(dbManager.getDb()) + const settlementRepo = new SettlementRepository(dbManager.getDb()) const SETTINGS_SECURITY_ADMIN = 'security_admin_password' const SETTINGS_SECURITY_POINTS = 'security_points_password' @@ -356,6 +368,7 @@ app.whenReady().then(() => { .prepare( `SELECT * FROM score_events WHERE student_name = ? + AND settlement_id IS NULL AND julianday(event_time) >= julianday(?) ORDER BY event_time DESC LIMIT ?` @@ -366,6 +379,7 @@ app.whenReady().then(() => { .prepare( `SELECT * FROM score_events WHERE student_name = ? + AND settlement_id IS NULL ORDER BY event_time DESC LIMIT ?` ) @@ -409,6 +423,7 @@ app.whenReady().then(() => { FROM students s LEFT JOIN score_events e ON e.student_name = s.name + AND e.settlement_id IS NULL 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` @@ -421,6 +436,34 @@ app.whenReady().then(() => { } }) + ipcMain.handle('db:settlement:query', async () => { + try { + return { success: true, data: settlementRepo.findAll() } + } catch (err: any) { + return { success: false, message: err.message } + } + }) + + ipcMain.handle('db:settlement:create', async (event) => { + try { + if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } + const data = settlementRepo.settleNow() + return { success: true, data } + } catch (err: any) { + return { success: false, message: err.message } + } + }) + + ipcMain.handle('db:settlement:leaderboard', async (_, params) => { + try { + const settlementId = Number(params?.settlement_id) + if (!Number.isFinite(settlementId)) return { success: false, message: 'Invalid settlement_id' } + return { success: true, data: settlementRepo.getLeaderboard(settlementId) } + } 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 { @@ -624,15 +667,21 @@ app.whenReady().then(() => { .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' + 'SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id, sync_state, remote_id FROM score_events' ) .all() + const settlements = db + .prepare('SELECT id, start_time, end_time, created_at FROM settlements ORDER BY id ASC') + .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) } + return { + success: true, + data: JSON.stringify({ students, reasons, events, settlements, settings }, null, 2) + } }) ipcMain.handle('data:importJson', (event, jsonText: string) => { @@ -647,12 +696,14 @@ app.whenReady().then(() => { 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 settlements = Array.isArray(parsed?.settlements) ? parsed.settlements : [] 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 settlements').run() db.prepare('DELETE FROM students').run() db.prepare('DELETE FROM reasons').run() db.prepare("DELETE FROM settings WHERE key NOT LIKE 'security_%'").run() @@ -688,8 +739,19 @@ app.whenReady().then(() => { } 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, settlement_id, sync_state, remote_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ) + const insertSettlement = db.prepare( + 'INSERT INTO settlements (id, start_time, end_time, created_at) VALUES (?, ?, ?, ?)' + ) + for (const s of settlements) { + const id = Number(s?.id) + const startTime = String(s?.start_time ?? '').trim() + const endTime = String(s?.end_time ?? '').trim() + const createdAt = String(s?.created_at ?? new Date().toISOString()) + if (!Number.isFinite(id) || !startTime || !endTime) continue + insertSettlement.run(id, startTime, endTime, createdAt) + } for (const e of events) { const uuid = String(e?.uuid ?? '').trim() const studentName = String(e?.student_name ?? '').trim() @@ -699,6 +761,11 @@ app.whenReady().then(() => { const valPrev = Number(e?.val_prev ?? 0) const valCurr = Number(e?.val_curr ?? 0) const eventTime = String(e?.event_time ?? new Date().toISOString()) + const settlementIdRaw = e?.settlement_id + const settlementId = + settlementIdRaw === null || settlementIdRaw === undefined + ? null + : Number(settlementIdRaw) const syncState = Number(e?.sync_state ?? 0) ? 1 : 0 const remoteId = e?.remote_id != null ? String(e.remote_id) : null insertEvent.run( @@ -709,6 +776,7 @@ app.whenReady().then(() => { Number.isFinite(valPrev) ? valPrev : 0, Number.isFinite(valCurr) ? valCurr : 0, eventTime, + Number.isFinite(settlementId as any) ? settlementId : null, syncState, remoteId ) diff --git a/src/main/repos/EventRepository.ts b/src/main/repos/EventRepository.ts index f959111..c269a3a 100644 --- a/src/main/repos/EventRepository.ts +++ b/src/main/repos/EventRepository.ts @@ -12,6 +12,7 @@ export interface ScoreEvent { val_curr: number event_time: string sync_state: number + settlement_id?: number | null remote_id?: string } @@ -23,6 +24,7 @@ export class EventRepository { .prepare( ` SELECT * FROM score_events + WHERE settlement_id IS NULL ORDER BY event_time DESC LIMIT ? ` @@ -91,9 +93,12 @@ export class EventRepository { 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 } + .prepare('SELECT student_name, delta, settlement_id FROM score_events WHERE uuid = ?') + .get(uuid) as { student_name: string; delta: number; settlement_id: number | null } if (!event) return + if (event.settlement_id !== null && event.settlement_id !== undefined) { + throw new Error('该记录已结算,无法撤销') + } // 2. Revert student score this.db diff --git a/src/main/repos/SettlementRepository.ts b/src/main/repos/SettlementRepository.ts new file mode 100644 index 0000000..6963903 --- /dev/null +++ b/src/main/repos/SettlementRepository.ts @@ -0,0 +1,102 @@ +import { Database } from 'better-sqlite3' + +export interface SettlementSummary { + id: number + start_time: string + end_time: string + event_count: number +} + +export interface SettlementLeaderboardRow { + name: string + score: number +} + +export class SettlementRepository { + constructor(private db: Database) {} + + findAll(): SettlementSummary[] { + const rows = this.db + .prepare( + ` + SELECT + s.id as id, + s.start_time as start_time, + s.end_time as end_time, + ( + SELECT COUNT(1) + FROM score_events e + WHERE e.settlement_id = s.id + ) as event_count + FROM settlements s + ORDER BY julianday(s.end_time) DESC + ` + ) + .all() as SettlementSummary[] + return rows + } + + settleNow() { + return this.db.transaction(() => { + const unassigned = this.db + .prepare(`SELECT COUNT(1) as count FROM score_events WHERE settlement_id IS NULL`) + .get() as { count: number } + + const eventCount = Number(unassigned?.count ?? 0) + if (eventCount <= 0) { + throw new Error('暂无可结算记录') + } + + const endTime = new Date().toISOString() + const lastSettlement = this.db + .prepare(`SELECT end_time FROM settlements ORDER BY julianday(end_time) DESC LIMIT 1`) + .get() as { end_time: string } | undefined + + const minEvent = this.db + .prepare(`SELECT MIN(event_time) as min_time FROM score_events WHERE settlement_id IS NULL`) + .get() as { min_time: string } | undefined + + const startTime = lastSettlement?.end_time || minEvent?.min_time || endTime + + const info = this.db + .prepare(`INSERT INTO settlements (start_time, end_time) VALUES (?, ?)`) + .run(startTime, endTime) + const settlementId = info.lastInsertRowid as number + + this.db + .prepare(`UPDATE score_events SET settlement_id = ? WHERE settlement_id IS NULL`) + .run(settlementId) + + this.db.prepare(`UPDATE students SET score = 0, updated_at = CURRENT_TIMESTAMP`).run() + + return { settlementId, startTime, endTime, eventCount } + })() + } + + getLeaderboard(settlementId: number) { + const settlement = this.db + .prepare(`SELECT id, start_time, end_time FROM settlements WHERE id = ?`) + .get(settlementId) as { id: number; start_time: string; end_time: string } | undefined + + if (!settlement) { + throw new Error('结算记录不存在') + } + + const rows = this.db + .prepare( + ` + SELECT + e.student_name as name, + COALESCE(SUM(e.delta), 0) as score + FROM score_events e + WHERE e.settlement_id = ? + GROUP BY e.student_name + ORDER BY score DESC, name ASC + ` + ) + .all(settlementId) as SettlementLeaderboardRow[] + + return { settlement, rows } + } +} + diff --git a/src/preload/index.ts b/src/preload/index.ts index 4890059..fbfe185 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,12 @@ const api = { queryEventsByStudent: (params: any) => ipcRenderer.invoke('db:event:queryByStudent', params), queryLeaderboard: (params: any) => ipcRenderer.invoke('db:leaderboard:query', params), + // Settlement + querySettlements: () => ipcRenderer.invoke('db:settlement:query'), + createSettlement: () => ipcRenderer.invoke('db:settlement:create'), + querySettlementLeaderboard: (params: any) => + ipcRenderer.invoke('db:settlement:leaderboard', params), + // Settings & Sync getSettings: () => ipcRenderer.invoke('db:getSettings'), updateSetting: (key: string, value: string) => ipcRenderer.invoke('db:updateSetting', key, value), diff --git a/src/preload/types.ts b/src/preload/types.ts index 8bf8a1a..412a558 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -50,6 +50,18 @@ export interface ElectronApi { range: 'today' | 'week' | 'month' }) => Promise> + // Settlement + querySettlements: () => + Promise> + createSettlement: () => + Promise> + querySettlementLeaderboard: (params: { settlement_id: number }) => Promise< + IpcResponse<{ + settlement: { id: number; start_time: string; end_time: string } + rows: { name: string; score: number }[] + }> + > + // Settings & Sync getSettings: () => Promise>> updateSetting: (key: string, value: string) => Promise> diff --git a/src/renderer/index.html b/src/renderer/index.html index e198e05..d47e868 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - Electron + SecScore ('none') - const [appearanceBlur, setAppearanceBlur] = useState(0) useEffect(() => { const checkWizard = async () => { @@ -44,30 +42,9 @@ function MainContent(): React.JSX.Element { 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 () => { @@ -102,6 +79,8 @@ function MainContent(): React.JSX.Element { return case 'leaderboard': return + case 'settlements': + return case 'reasons': return case 'settings': @@ -122,30 +101,23 @@ function MainContent(): React.JSX.Element { return ( - {appearanceBackground !== 'none' && ( -
0 ? `blur(${appearanceBlur}px)` : undefined, - transform: appearanceBlur > 0 ? 'scale(1.05)' : undefined - }} - /> - )}