mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: 添加结算功能并优化界面
- 新增结算历史页面,支持查看历史结算记录和排行榜 - 实现结算功能,可将当前积分记录归档并清零学生分数 - 优化学生搜索功能,支持拼音匹配 - 更新应用图标和品牌标识 - 改进主题系统,支持自定义侧边栏文本颜色 - 重构积分管理表单,优化用户体验 - 修复若干已知问题并改进代码结构
This commit is contained in:
@@ -4,6 +4,7 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
logs
|
||||
|
||||
build
|
||||
db.sqlite
|
||||
|
||||
+14
-3
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
|
||||
<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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user