feat: 添加结算功能并优化界面

- 新增结算历史页面,支持查看历史结算记录和排行榜
- 实现结算功能,可将当前积分记录归档并清零学生分数
- 优化学生搜索功能,支持拼音匹配
- 更新应用图标和品牌标识
- 改进主题系统,支持自定义侧边栏文本颜色
- 重构积分管理表单,优化用户体验
- 修复若干已知问题并改进代码结构
This commit is contained in:
Fox_block
2026-01-16 18:57:06 +08:00
parent f27e2f21bb
commit b41139b941
25 changed files with 828 additions and 95 deletions
+1
View File
@@ -4,6 +4,7 @@ out
.DS_Store
.eslintcache
*.log*
logs
build
db.sqlite
+14 -3
View File
@@ -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:
+1
View File
@@ -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"
+8
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW (OEM 版本) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="57.5732mm" height="57.5732mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 5757.32 5757.32"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#FEFEFE;stroke-width:20;stroke-miterlimit:22.9256}
.str1 {stroke:#2387ED;stroke-width:20;stroke-miterlimit:22.9256}
.str2 {stroke:#2789ED;stroke-width:20;stroke-miterlimit:22.9256}
.fil0 {fill:#FEFEFE}
.fil1 {fill:#2389EC}
.fil4 {fill:#75BDF7}
.fil2 {fill:#FFDA31}
.fil3 {fill:#FFF270}
]]>
</style>
</defs>
<g id="圖層_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_1556526623248">
<rect class="fil0 str0" x="27.11" y="26.34" width="5675.33" height="5687.62" rx="1140.46" ry="1142.93"/>
<circle class="fil1 str1" cx="3484.74" cy="2200.34" r="920.68"/>
<circle class="fil0 str0" cx="3951.4" cy="3983.83" r="1288.52"/>
<g>
<circle class="fil2" cx="3951.4" cy="3983.83" r="1033.53"/>
<path class="fil3" d="M4139.76 3707.08c-14.94,-25.96 -43.54,-77.79 -66.66,-124.1 -23.11,-46.3 -40.75,-87.08 -61.13,-131.88 -20.37,-44.8 -43.48,-93.63 -73.05,-71.06 -29.57,22.56 -65.6,116.49 -95.71,187.21 -30.1,70.71 -54.3,118.2 -66.4,141.95 -12.1,23.74 -12.1,23.74 -12.1,23.74 0,0 0,0 -36.04,5.26 -36.03,5.25 -108.1,15.76 -187.6,26.12 -79.5,10.37 -166.42,20.58 -177.47,52.05 -11.04,31.46 53.79,84.19 113.27,132.93 59.48,48.74 113.6,93.5 140.66,115.88 27.06,22.38 27.06,22.38 27.06,22.38 0,0 0,0 -4.8,29.82 -4.81,29.82 -14.42,89.45 -29.87,166.02 -15.45,76.57 -36.73,170.06 -13.79,197.73 22.93,27.66 90.08,-10.5 157.94,-50.54 67.86,-40.04 136.43,-81.95 170.71,-102.91 34.28,-20.96 34.28,-20.96 34.28,-20.96 0,0 0,0 29.08,17.71 29.07,17.71 87.22,53.13 150.46,92.03 63.23,38.89 131.56,81.25 159.24,64.16 27.68,-17.1 14.73,-93.67 3.9,-169.09 -10.82,-75.42 -19.51,-149.7 -23.86,-186.84 -4.35,-37.13 -4.35,-37.13 58.7,-87.16 63.04,-50.02 189.13,-150.07 193.62,-207.02 4.48,-56.94 -112.64,-70.78 -173.29,-77.95 -60.65,-7.17 -64.83,-7.68 -79.61,-9.01 -14.78,-1.33 -40.17,-3.48 -61.94,-5.33 -21.77,-1.86 -39.91,-3.4 -49.64,-4.23 -9.72,-0.82 -11.01,-0.94 -25.96,-26.91z"/>
</g>
<ellipse class="fil0" cx="2140.85" cy="2197.59" rx="1194.99" ry="1178.43"/>
<circle class="fil4" cx="2124.15" cy="2207.61" r="923.37"/>
<path class="fil1 str2" d="M2788.24 3393.56c-11.75,24.43 -34.95,73.29 -54.62,123.82 -19.67,50.52 -35.81,102.71 -53.14,186.31 -17.34,83.6 -35.86,198.63 -29.23,317.47 6.63,118.84 38.41,241.5 66,328.24 27.59,86.75 50.98,137.57 62.68,162.99 11.7,25.41 11.7,25.41 11.7,25.41 0,0 0,0 -278.03,0 -278.03,0 -834.08,0 -1112.11,0 -278.02,0 -278.02,0 -291.55,0.23 -13.53,0.23 -40.59,0.69 -75.21,-7.93 -34.61,-8.62 -76.78,-26.31 -107.12,-64.94 -30.33,-38.64 -48.83,-98.21 -57.28,-137.12 -8.46,-38.92 -6.87,-57.19 -6.07,-82.24 0.79,-25.05 0.79,-56.9 0.79,-80.43 0,-23.54 0,-38.77 1.5,-69.12 1.5,-30.35 4.5,-75.83 9.34,-112.5 4.84,-36.68 11.52,-64.54 44.17,-133.73 32.66,-69.18 91.29,-179.68 171.72,-260.68 80.45,-81 182.69,-132.49 273.98,-164.28 91.29,-31.78 171.63,-43.86 218.71,-49.89 47.09,-6.04 60.94,-6.04 74.09,-6.04 13.15,0 25.61,0 212.92,0 187.31,0 549.47,0 730.87,0 181.4,0 182.04,0 182.71,0 0.67,0 1.37,0 3.04,0 1.68,0 4.31,0 6.31,0 2.01,0 3.37,0 3.98,0 0.62,0 0.48,0 0.72,0 0.24,0 0.86,0 1.02,0 0.16,0 -0.14,0 -11.89,24.43z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+10
View File
@@ -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
+18
View File
@@ -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 (
+83 -15
View File
@@ -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
)
+7 -2
View File
@@ -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
+102
View File
@@ -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 }
}
}
+6
View File
@@ -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),
+12
View File
@@ -50,6 +50,18 @@ export interface ElectronApi {
range: 'today' | 'week' | 'month'
}) => Promise<IpcResponse<{ startTime: string; rows: any[] }>>
// Settlement
querySettlements: () =>
Promise<IpcResponse<{ id: number; start_time: string; end_time: string; event_count: number }[]>>
createSettlement: () =>
Promise<IpcResponse<{ settlementId: number; startTime: string; endTime: string; eventCount: number }>>
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<IpcResponse<Record<string, string>>>
updateSetting: (key: string, value: string) => Promise<IpcResponse<void>>
+1 -1
View File
@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>SecScore</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
+16 -41
View File
@@ -6,9 +6,9 @@ import { Settings } from './components/Settings'
import { ReasonManager } from './components/ReasonManager'
import { ScoreManager } from './components/ScoreManager'
import { Leaderboard } from './components/Leaderboard'
import { SettlementHistory } from './components/SettlementHistory'
import { Wizard } from './components/Wizard'
import { ThemeProvider } from './contexts/ThemeContext'
import WavyLines from './assets/wavy-lines.svg'
const { Header, Content, Aside } = Layout
@@ -20,8 +20,6 @@ function MainContent(): React.JSX.Element {
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 () => {
@@ -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 <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
case 'leaderboard':
return <Leaderboard />
case 'settlements':
return <SettlementHistory />
case 'reasons':
return <ReasonManager canEdit={permission === 'admin'} />
case 'settings':
@@ -122,30 +101,23 @@ function MainContent(): React.JSX.Element {
return (
<Layout style={{ height: '100vh', backgroundColor: 'var(--ss-bg-color)' }}>
{appearanceBackground !== 'none' && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: -1,
backgroundImage: `url(${WavyLines})`,
backgroundRepeat: 'repeat',
backgroundSize: '600px auto',
opacity: 0.35,
filter: appearanceBlur > 0 ? `blur(${appearanceBlur}px)` : undefined,
transform: appearanceBlur > 0 ? 'scale(1.05)' : undefined
}}
/>
)}
<Aside
className="ss-sidebar"
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)'
}}
>
<div style={{ padding: '24px', textAlign: 'center' }}>
<h2 style={{ color: 'var(--ss-text-main)', margin: 0 }}>SecScore</h2>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></div>
<h2 style={{ color: 'var(--ss-sidebar-text, var(--ss-text-main))', margin: 0 }}>SecScore</h2>
<div
style={{
fontSize: '12px',
color: 'var(--ss-sidebar-text, var(--ss-text-main))'
}}
>
</div>
</div>
<Menu
value={activeMenu}
@@ -161,6 +133,9 @@ function MainContent(): React.JSX.Element {
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
+40
View File
@@ -9,3 +9,43 @@ body,
padding: 0;
overflow: hidden;
}
.ss-sidebar {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item,
.ss-sidebar .t-menu__item--active,
.ss-sidebar .t-menu__item-icon {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
.ss-sidebar .t-menu__item:hover {
color: var(--ss-sidebar-text, var(--ss-text-main));
}
@media (pointer: coarse) {
.ss-sidebar .t-menu__item {
min-height: 48px;
font-size: 16px;
}
.t-button {
min-height: 44px;
}
.t-input,
.t-input__inner,
.t-select-input,
.t-input-number,
.t-input-number__input {
min-height: 44px;
font-size: 16px;
}
.t-table th,
.t-table td {
padding-top: 14px;
padding-bottom: 14px;
}
}
@@ -57,8 +57,18 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return
}
const values = form.getFieldsValue?.(true) as any
const content = values.content?.trim()
const category = values.category?.trim() || '其他'
if (data.some((r) => r.content === content && r.category === category)) {
MessagePlugin.warning('该分类下已存在相同理由')
return
}
const res = await (window as any).api.createReason({
...values,
content,
category,
delta: Number(values.delta)
})
if (res.success) {
+78 -4
View File
@@ -15,6 +15,44 @@ import {
Popconfirm
} from 'tdesign-react'
import { RollbackIcon } from 'tdesign-icons-react'
import { match } from 'pinyin-pro'
const normalizeSearch = (input: unknown) =>
String(input ?? '')
.trim()
.toLowerCase()
const getOptionLabel = (option: unknown) => {
if (option && typeof option === 'object') {
const anyOption = option as any
return String(anyOption.label ?? anyOption.text ?? anyOption.value ?? '')
}
return String(option ?? '')
}
const matchStudentName = (name: string, keyword: string) => {
const q0 = normalizeSearch(keyword)
if (!q0) return true
const nameLower = String(name).toLowerCase()
if (nameLower.includes(q0)) return true
const q1 = q0.replace(/\s+/g, '')
if (q1 && nameLower.replace(/\s+/g, '').includes(q1)) return true
try {
const m0 = match(name, q0)
if (Array.isArray(m0)) return true
if (q1 && q1 !== q0) {
const m1 = match(name, q1)
if (Array.isArray(m1)) return true
}
} catch {
return false
}
return false
}
interface Student {
id: number
@@ -91,13 +129,28 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return
}
const values = form.getFieldsValue(true) as any
if (!values.student_name || !values.delta || !values.reason_content) {
if (!values.student_name || !values.reason_content) {
MessagePlugin.warning('请填写完整信息')
return
}
const deltaInput = Number(values.delta)
const hasDeltaInput = Number.isFinite(deltaInput) && deltaInput > 0
const reasonId = Number(values.reason_id)
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
if (!hasDeltaInput && !selectedReason) {
MessagePlugin.warning('请填写分值或选择预设理由')
return
}
setSubmitLoading(true)
const delta = values.type === 'subtract' ? -Math.abs(values.delta) : Math.abs(values.delta)
const delta = hasDeltaInput
? values.type === 'subtract'
? -Math.abs(deltaInput)
: Math.abs(deltaInput)
: Number(selectedReason?.delta ?? 0)
const res = await (window as any).api.createEvent({
student_name: values.student_name,
@@ -107,7 +160,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
if (res.success) {
MessagePlugin.success('积分提交成功')
form.setFieldsValue({ delta: undefined, reason_content: '', reason_id: undefined })
form.setFieldsValue({
delta: undefined,
reason_content: '',
reason_id: undefined,
type: 'add'
})
fetchData()
emitDataUpdated('events')
} else {
@@ -184,11 +242,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Select
filterable
placeholder="请选择或搜索学生"
filter={(filterWords, option) =>
matchStudentName(getOptionLabel(option), filterWords)
}
options={students.map((s) => ({ label: s.name, value: s.name }))}
/>
</Form.FormItem>
<Form.FormItem label="分数" name="delta">
<Form.FormItem label="分数">
<Space>
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
<Radio.Group variant="default-filled">
@@ -196,7 +257,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Radio.Button value="subtract"></Radio.Button>
</Radio.Group>
</Form.FormItem>
<Form.FormItem name="delta" style={{ marginBottom: 0 }}>
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
</Form.FormItem>
</Space>
</Form.FormItem>
@@ -208,6 +271,17 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
if (!Number.isFinite(id)) return
const reason = reasons.find((r) => r.id === id)
if (!reason) return
const currentDelta = Number(form.getFieldValue('delta'))
const hasCurrentDelta = Number.isFinite(currentDelta) && currentDelta > 0
if (hasCurrentDelta) {
form.setFieldsValue({
reason_content: reason.content
})
return
}
form.setFieldsValue({
reason_content: reason.content,
delta: Math.abs(reason.delta),
+60 -25
View File
@@ -5,14 +5,14 @@ import {
Form,
Select,
Input,
InputNumber,
Switch,
Button,
Space,
Divider,
Tag,
Dialog,
MessagePlugin
MessagePlugin,
DialogPlugin
} from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext'
@@ -52,6 +52,8 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
const importInputRef = useRef<HTMLInputElement | null>(null)
const [settleLoading, setSettleLoading] = useState(false)
const canAdmin = permission === 'admin'
const permissionTag = useMemo(() => {
@@ -69,6 +71,10 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
window.dispatchEvent(new CustomEvent('ss:settings-updated', { detail: { key, value } }))
}
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
const loadAll = async () => {
if (!(window as any).api) return
const res = await (window as any).api.getSettings()
@@ -236,6 +242,34 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
}
}
const confirmSettlement = () => {
if (!(window as any).api) return
const dialog = DialogPlugin.confirm({
header: '确认结算并重新开始?',
body: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div></div>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
</div>
</div>
),
confirmBtn: '结算',
onConfirm: async () => {
setSettleLoading(true)
const res = await (window as any).api.createSettlement()
setSettleLoading(false)
if (res.success && res.data) {
MessagePlugin.success('结算成功,已重新开始积分')
emitDataUpdated('all')
dialog.hide()
} else {
MessagePlugin.error(res.message || '结算失败')
}
}
})
}
return (
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<div
@@ -265,29 +299,6 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
))}
</Select>
</Form.FormItem>
<Form.FormItem label="背景样式">
<Select
value={settings.appearance_background || 'none'}
onChange={(v) => handleUpdateSetting('appearance_background', String(v))}
style={{ width: '320px' }}
>
<Select.Option value="none" label="无背景" />
<Select.Option value="wavy-lines" label="波浪线" />
</Select>
</Form.FormItem>
<Form.FormItem label="背景虚化(px)">
<InputNumber
min={0}
max={20}
value={Number(settings.appearance_background_blur || 0)}
onChange={(v) =>
handleUpdateSetting('appearance_background_blur', String(v || 0))
}
style={{ width: '160px' }}
/>
</Form.FormItem>
</Form>
</Card>
</Tabs.TabPanel>
@@ -396,6 +407,30 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
</Tabs.TabPanel>
<Tabs.TabPanel value="data" label="数据管理">
<Card
title="结算"
style={{
backgroundColor: 'var(--ss-card-bg)',
color: 'var(--ss-text-main)',
marginBottom: '16px'
}}
>
<Space align="center">
<Button
theme="danger"
variant="outline"
disabled={!canAdmin}
loading={settleLoading}
onClick={confirmSettlement}
>
</Button>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
</div>
</Space>
</Card>
<Card
title="同步设置 (远程模式)"
style={{
@@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, MessagePlugin, Space, Table } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
interface SettlementSummary {
id: number
start_time: string
end_time: string
event_count: number
}
interface SettlementLeaderboardRow {
name: string
score: number
}
export const SettlementHistory: React.FC = () => {
const [settlements, setSettlements] = useState<SettlementSummary[]>([])
const [loading, setLoading] = useState(false)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [selectedSettlement, setSelectedSettlement] = useState<
{ id: number; start_time: string; end_time: string } | null
>(null)
const [rows, setRows] = useState<SettlementLeaderboardRow[]>([])
const [detailLoading, setDetailLoading] = useState(false)
const formatRange = (s: { start_time: string; end_time: string }) => {
const start = new Date(s.start_time).toLocaleString()
const end = new Date(s.end_time).toLocaleString()
return `${start} - ${end}`
}
const fetchSettlements = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
const res = await (window as any).api.querySettlements()
setLoading(false)
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
return
}
setSettlements(res.data || [])
}, [])
useEffect(() => {
fetchSettlements()
}, [fetchSettlements])
useEffect(() => {
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === 'events' || category === 'all') fetchSettlements()
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchSettlements])
const openSettlement = async (id: number) => {
if (!(window as any).api) return
setSelectedId(id)
setDetailLoading(true)
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
setDetailLoading(false)
if (!res.success || !res.data) {
MessagePlugin.error(res.message || '查询失败')
return
}
setSelectedSettlement(res.data.settlement)
setRows(res.data.rows || [])
}
const columns: PrimaryTableCol<SettlementLeaderboardRow>[] = useMemo(
() => [
{
colKey: 'rank',
title: '排名',
width: 70,
align: 'center',
cell: ({ rowIndex }) => <span style={{ fontWeight: 'bold' }}>{rowIndex + 1}</span>
},
{ colKey: 'name', title: '姓名', width: 160 },
{
colKey: 'score',
title: '阶段积分',
width: 120,
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
}
],
[]
)
if (selectedId !== null && selectedSettlement) {
return (
<div style={{ padding: '24px' }}>
<Space style={{ marginBottom: '16px' }}>
<Button
variant="outline"
onClick={() => {
setSelectedId(null)
setSelectedSettlement(null)
setRows([])
}}
>
</Button>
<div style={{ color: 'var(--ss-text-main)', fontWeight: 700 }}></div>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
{formatRange(selectedSettlement)}
</div>
</Space>
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={rows}
columns={columns}
rowKey="name"
loading={detailLoading}
bordered
hover
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
</div>
)
}
return (
<div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
{settlements.map((s) => (
<Card key={s.id} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontWeight: 700, marginBottom: '8px' }}> #{s.id}</div>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}>
{formatRange(s)}
</div>
<Space>
<Button theme="primary" onClick={() => openSettlement(s.id)}>
</Button>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
: {s.event_count}
</div>
</Space>
</Card>
))}
</div>
{!loading && settlements.length === 0 && (
<div style={{ marginTop: '16px', color: 'var(--ss-text-secondary)' }}></div>
)}
</div>
)
}
@@ -55,7 +55,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
MessagePlugin.warning('请输入姓名')
return
}
const res = await (window as any).api.createStudent(values)
const name = values.name.trim()
if (data.some((s) => s.name === name)) {
MessagePlugin.warning('学生姓名已存在')
return
}
const res = await (window as any).api.createStudent({ ...values, name })
if (res.success) {
MessagePlugin.success('添加成功')
setVisible(false)
+7 -1
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { ThemeConfig } from '../../../preload/types'
import { generateColorMap } from '../utils/color'
interface ThemeContextType {
currentTheme: ThemeConfig | null
@@ -21,7 +22,12 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
root.setAttribute('theme-mode', theme.mode)
// 2. 设置 TDesign 品牌色
if (tdesign.brandColor) root.style.setProperty('--td-brand-color', tdesign.brandColor)
if (tdesign.brandColor) {
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
Object.entries(colorMap).forEach(([key, value]) => {
root.style.setProperty(key, value)
})
}
if (tdesign.warningColor) root.style.setProperty('--td-warning-color', tdesign.warningColor)
if (tdesign.errorColor) root.style.setProperty('--td-error-color', tdesign.errorColor)
if (tdesign.successColor) root.style.setProperty('--td-success-color', tdesign.successColor)
+151
View File
@@ -0,0 +1,151 @@
function hexToRgb(hex: string): [number, number, number] {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16)
]
: [0, 0, 0]
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const v = max
const d = max - min
s = max === 0 ? 0 : d / max
if (max !== min) {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return [h * 360, s, v]
}
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
let r = 0,
g = 0,
b = 0
const i = Math.floor(h / 60)
const f = h / 60 - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
switch (i % 6) {
case 0:
r = v
g = t
b = p
break
case 1:
r = q
g = v
b = p
break
case 2:
r = p
g = v
b = t
break
case 3:
r = p
g = q
b = v
break
case 4:
r = t
g = p
b = v
break
case 5:
r = v
g = p
b = q
break
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
}
function componentToHex(c: number): string {
const hex = c.toString(16)
return hex.length === 1 ? '0' + hex : hex
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b)
}
export function generateColorMap(
brandColor: string,
mode: 'light' | 'dark'
): Record<string, string> {
const [r, g, b] = hexToRgb(brandColor)
const [h, s, v] = rgbToHsv(r, g, b)
const colors: Record<string, string> = {}
const palette: string[] = []
for (let i = 1; i <= 10; i++) {
let newS = s
let newV = v
if (i === 6) {
palette.push(brandColor)
continue
}
if (mode === 'light') {
if (i < 6) {
newS = s * Math.pow(0.6, (6 - i) / 5)
newV = v + (1 - v) * ((6 - i) / 5) * 0.9
} else {
newS = s + (1 - s) * ((i - 6) / 4) * 0.2
newV = v * Math.pow(0.85, (i - 6) / 4)
}
} else {
if (i < 6) {
newS = s * Math.pow(0.8, (6 - i) / 5)
newV = v * Math.pow(0.9, (6 - i) / 5)
} else {
newS = s + (1 - s) * ((i - 6) / 4) * 0.4
newV = v + (1 - v) * ((i - 6) / 4) * 0.5
}
}
newS = Math.max(0, Math.min(1, newS))
newV = Math.max(0, Math.min(1, newV))
const [nr, ng, nb] = hsvToRgb(h, newS, newV)
palette.push(rgbToHex(nr, ng, nb))
}
palette.forEach((color, index) => {
colors[`--td-brand-color-${index + 1}`] = color
})
colors['--td-brand-color'] = brandColor
colors['--td-brand-color-light'] = palette[0]
colors['--td-brand-color-focus'] = palette[1]
colors['--td-brand-color-disabled'] = palette[2]
colors['--td-brand-color-hover'] = palette[4]
colors['--td-brand-color-active'] = palette[6]
return colors
}
+2 -1
View File
@@ -17,7 +17,8 @@
"--ss-border-color": "#333333",
"--ss-header-bg": "#1e1e1e",
"--ss-sidebar-bg": "#1e1e1e",
"--ss-item-hover": "#2c2c2c"
"--ss-item-hover": "#2c2c2c",
"--ss-sidebar-text": "#ffffff"
}
}
}