mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 添加结算功能并优化界面
- 新增结算历史页面,支持查看历史结算记录和排行榜 - 实现结算功能,可将当前积分记录归档并清零学生分数 - 优化学生搜索功能,支持拼音匹配 - 更新应用图标和品牌标识 - 改进主题系统,支持自定义侧边栏文本颜色 - 重构积分管理表单,优化用户体验 - 修复若干已知问题并改进代码结构
This commit is contained in:
@@ -4,6 +4,7 @@ out
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
logs
|
||||||
|
|
||||||
build
|
build
|
||||||
db.sqlite
|
db.sqlite
|
||||||
|
|||||||
+14
-3
@@ -1,5 +1,5 @@
|
|||||||
appId: com.electron.app
|
appId: com.secscore.app
|
||||||
productName: secscore
|
productName: SecScore
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
files:
|
files:
|
||||||
@@ -11,13 +11,24 @@ files:
|
|||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
|
afterPack: ./scripts/afterPack.cjs
|
||||||
|
extraFiles:
|
||||||
|
- from: themes
|
||||||
|
to: themes
|
||||||
|
extraResources:
|
||||||
|
- from: resources
|
||||||
|
to: assets
|
||||||
win:
|
win:
|
||||||
executableName: secscore
|
target:
|
||||||
|
- zip
|
||||||
|
executableName: SecScore
|
||||||
|
icon: resources/SecScore_logo.ico
|
||||||
nsis:
|
nsis:
|
||||||
artifactName: ${name}-${version}-setup.${ext}
|
artifactName: ${name}-${version}-setup.${ext}
|
||||||
shortcutName: ${productName}
|
shortcutName: ${productName}
|
||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
mac:
|
mac:
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
extendInfo:
|
extendInfo:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
|
"pinyin-pro": "^3.27.0",
|
||||||
"tdesign-react": "^1.16.3",
|
"tdesign-react": "^1.16.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
|
|||||||
Generated
+8
@@ -23,6 +23,9 @@ importers:
|
|||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.3.9
|
specifier: ^6.3.9
|
||||||
version: 6.7.3
|
version: 6.7.3
|
||||||
|
pinyin-pro:
|
||||||
|
specifier: ^3.27.0
|
||||||
|
version: 3.27.0
|
||||||
tdesign-react:
|
tdesign-react:
|
||||||
specifier: ^1.16.3
|
specifier: ^1.16.3
|
||||||
version: 1.16.3(react-dom@19.2.3(react@19.2.3))(react@19.2.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==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pinyin-pro@3.27.0:
|
||||||
|
resolution: {integrity: sha512-Osdgjwe7Rm17N2paDMM47yW+jUIUH3+0RGo8QP39ZTLpTaJVDK0T58hOLaMQJbcMmAebVuK2ePunTEVEx1clNQ==}
|
||||||
|
|
||||||
plist@3.1.0:
|
plist@3.1.0:
|
||||||
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
|
||||||
engines: {node: '>=10.4.0'}
|
engines: {node: '>=10.4.0'}
|
||||||
@@ -5514,6 +5520,8 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
pinyin-pro@3.27.0: {}
|
||||||
|
|
||||||
plist@3.1.0:
|
plist@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@xmldom/xmldom': 0.8.11
|
'@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(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
|||||||
+83
-15
@@ -1,15 +1,16 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||||
import { join } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
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 { ThemeService } from './services/ThemeService'
|
||||||
import { LoggerService, LogLevel } from './services/LoggerService'
|
import { LoggerService, LogLevel } from './services/LoggerService'
|
||||||
import { DbManager } from './db/DbManager'
|
import { DbManager } from './db/DbManager'
|
||||||
import { StudentRepository } from './repos/StudentRepository'
|
import { StudentRepository } from './repos/StudentRepository'
|
||||||
import { ReasonRepository } from './repos/ReasonRepository'
|
import { ReasonRepository } from './repos/ReasonRepository'
|
||||||
import { EventRepository } from './repos/EventRepository'
|
import { EventRepository } from './repos/EventRepository'
|
||||||
|
import { SettlementRepository } from './repos/SettlementRepository'
|
||||||
import { WsClient } from './services/WsClient'
|
import { WsClient } from './services/WsClient'
|
||||||
import { SyncEngine } from './services/SyncEngine'
|
import { SyncEngine } from './services/SyncEngine'
|
||||||
|
|
||||||
@@ -18,9 +19,10 @@ function createWindow(): void {
|
|||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 900,
|
||||||
height: 670,
|
height: 670,
|
||||||
|
title: 'SecScore',
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
...(process.platform === 'linux' ? { icon } : {}),
|
icon,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
sandbox: false
|
sandbox: false
|
||||||
@@ -59,22 +61,31 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Base data directory (production 使用单独目录)
|
const appRoot = is.dev ? process.cwd() : dirname(process.execPath)
|
||||||
const dataRoot = is.dev ? process.cwd() : join(app.getPath('userData'), 'secscore-data')
|
|
||||||
if (!fs.existsSync(dataRoot)) {
|
const ensureWritableDir = (preferred: string, fallback: string) => {
|
||||||
fs.mkdirSync(dataRoot, { recursive: true })
|
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
|
// Initialize Logger
|
||||||
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
|
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
|
||||||
const logger = new LoggerService(logDir)
|
const logger = new LoggerService(logDir)
|
||||||
logger.info('Application starting...')
|
logger.info('Application starting...')
|
||||||
|
|
||||||
// Initialize Themes (prod: 使用单独目录并从内置模板拷贝)
|
const themeDir = is.dev
|
||||||
const themeDir = is.dev ? join(process.cwd(), 'themes') : join(dataRoot, 'themes')
|
? join(process.cwd(), 'themes')
|
||||||
if (!fs.existsSync(themeDir)) {
|
: ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes'))
|
||||||
fs.mkdirSync(themeDir, { recursive: true })
|
|
||||||
}
|
|
||||||
if (!is.dev) {
|
if (!is.dev) {
|
||||||
try {
|
try {
|
||||||
const existing = fs.readdirSync(themeDir).filter((f) => f.toLowerCase().endsWith('.json'))
|
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 studentRepo = new StudentRepository(dbManager.getDb())
|
||||||
const reasonRepo = new ReasonRepository(dbManager.getDb())
|
const reasonRepo = new ReasonRepository(dbManager.getDb())
|
||||||
const eventRepo = new EventRepository(dbManager.getDb())
|
const eventRepo = new EventRepository(dbManager.getDb())
|
||||||
|
const settlementRepo = new SettlementRepository(dbManager.getDb())
|
||||||
|
|
||||||
const SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
const SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
||||||
const SETTINGS_SECURITY_POINTS = 'security_points_password'
|
const SETTINGS_SECURITY_POINTS = 'security_points_password'
|
||||||
@@ -356,6 +368,7 @@ app.whenReady().then(() => {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM score_events
|
`SELECT * FROM score_events
|
||||||
WHERE student_name = ?
|
WHERE student_name = ?
|
||||||
|
AND settlement_id IS NULL
|
||||||
AND julianday(event_time) >= julianday(?)
|
AND julianday(event_time) >= julianday(?)
|
||||||
ORDER BY event_time DESC
|
ORDER BY event_time DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
@@ -366,6 +379,7 @@ app.whenReady().then(() => {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM score_events
|
`SELECT * FROM score_events
|
||||||
WHERE student_name = ?
|
WHERE student_name = ?
|
||||||
|
AND settlement_id IS NULL
|
||||||
ORDER BY event_time DESC
|
ORDER BY event_time DESC
|
||||||
LIMIT ?`
|
LIMIT ?`
|
||||||
)
|
)
|
||||||
@@ -409,6 +423,7 @@ app.whenReady().then(() => {
|
|||||||
FROM students s
|
FROM students s
|
||||||
LEFT JOIN score_events e
|
LEFT JOIN score_events e
|
||||||
ON e.student_name = s.name
|
ON e.student_name = s.name
|
||||||
|
AND e.settlement_id IS NULL
|
||||||
AND julianday(e.event_time) >= julianday(?)
|
AND julianday(e.event_time) >= julianday(?)
|
||||||
GROUP BY s.id, s.name, s.score
|
GROUP BY s.id, s.name, s.score
|
||||||
ORDER BY s.score DESC, range_change DESC, s.name ASC`
|
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
|
// Settings IPC
|
||||||
ipcMain.handle('db:getSettings', () => {
|
ipcMain.handle('db:getSettings', () => {
|
||||||
const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as {
|
const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as {
|
||||||
@@ -624,15 +667,21 @@ app.whenReady().then(() => {
|
|||||||
.all()
|
.all()
|
||||||
const events = db
|
const events = db
|
||||||
.prepare(
|
.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()
|
.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 {
|
const settingsRows = db.prepare('SELECT key, value FROM settings').all() as {
|
||||||
key: string
|
key: string
|
||||||
value: string
|
value: string
|
||||||
}[]
|
}[]
|
||||||
const settings = settingsRows.filter((r) => !String(r.key).startsWith('security_'))
|
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) => {
|
ipcMain.handle('data:importJson', (event, jsonText: string) => {
|
||||||
@@ -647,12 +696,14 @@ app.whenReady().then(() => {
|
|||||||
const students = Array.isArray(parsed?.students) ? parsed.students : []
|
const students = Array.isArray(parsed?.students) ? parsed.students : []
|
||||||
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
|
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
|
||||||
const events = Array.isArray(parsed?.events) ? parsed.events : []
|
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 settings = Array.isArray(parsed?.settings) ? parsed.settings : []
|
||||||
|
|
||||||
const db = dbManager.getDb()
|
const db = dbManager.getDb()
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare('DELETE FROM score_events').run()
|
db.prepare('DELETE FROM score_events').run()
|
||||||
|
db.prepare('DELETE FROM settlements').run()
|
||||||
db.prepare('DELETE FROM students').run()
|
db.prepare('DELETE FROM students').run()
|
||||||
db.prepare('DELETE FROM reasons').run()
|
db.prepare('DELETE FROM reasons').run()
|
||||||
db.prepare("DELETE FROM settings WHERE key NOT LIKE 'security_%'").run()
|
db.prepare("DELETE FROM settings WHERE key NOT LIKE 'security_%'").run()
|
||||||
@@ -688,8 +739,19 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertEvent = db.prepare(
|
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) {
|
for (const e of events) {
|
||||||
const uuid = String(e?.uuid ?? '').trim()
|
const uuid = String(e?.uuid ?? '').trim()
|
||||||
const studentName = String(e?.student_name ?? '').trim()
|
const studentName = String(e?.student_name ?? '').trim()
|
||||||
@@ -699,6 +761,11 @@ app.whenReady().then(() => {
|
|||||||
const valPrev = Number(e?.val_prev ?? 0)
|
const valPrev = Number(e?.val_prev ?? 0)
|
||||||
const valCurr = Number(e?.val_curr ?? 0)
|
const valCurr = Number(e?.val_curr ?? 0)
|
||||||
const eventTime = String(e?.event_time ?? new Date().toISOString())
|
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 syncState = Number(e?.sync_state ?? 0) ? 1 : 0
|
||||||
const remoteId = e?.remote_id != null ? String(e.remote_id) : null
|
const remoteId = e?.remote_id != null ? String(e.remote_id) : null
|
||||||
insertEvent.run(
|
insertEvent.run(
|
||||||
@@ -709,6 +776,7 @@ app.whenReady().then(() => {
|
|||||||
Number.isFinite(valPrev) ? valPrev : 0,
|
Number.isFinite(valPrev) ? valPrev : 0,
|
||||||
Number.isFinite(valCurr) ? valCurr : 0,
|
Number.isFinite(valCurr) ? valCurr : 0,
|
||||||
eventTime,
|
eventTime,
|
||||||
|
Number.isFinite(settlementId as any) ? settlementId : null,
|
||||||
syncState,
|
syncState,
|
||||||
remoteId
|
remoteId
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ScoreEvent {
|
|||||||
val_curr: number
|
val_curr: number
|
||||||
event_time: string
|
event_time: string
|
||||||
sync_state: number
|
sync_state: number
|
||||||
|
settlement_id?: number | null
|
||||||
remote_id?: string
|
remote_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export class EventRepository {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT * FROM score_events
|
SELECT * FROM score_events
|
||||||
|
WHERE settlement_id IS NULL
|
||||||
ORDER BY event_time DESC
|
ORDER BY event_time DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
@@ -91,9 +93,12 @@ export class EventRepository {
|
|||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
// 1. Get event info
|
// 1. Get event info
|
||||||
const event = this.db
|
const event = this.db
|
||||||
.prepare('SELECT student_name, delta FROM score_events WHERE uuid = ?')
|
.prepare('SELECT student_name, delta, settlement_id FROM score_events WHERE uuid = ?')
|
||||||
.get(uuid) as { student_name: string; delta: number }
|
.get(uuid) as { student_name: string; delta: number; settlement_id: number | null }
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
if (event.settlement_id !== null && event.settlement_id !== undefined) {
|
||||||
|
throw new Error('该记录已结算,无法撤销')
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Revert student score
|
// 2. Revert student score
|
||||||
this.db
|
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),
|
queryEventsByStudent: (params: any) => ipcRenderer.invoke('db:event:queryByStudent', params),
|
||||||
queryLeaderboard: (params: any) => ipcRenderer.invoke('db:leaderboard:query', 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
|
// Settings & Sync
|
||||||
getSettings: () => ipcRenderer.invoke('db:getSettings'),
|
getSettings: () => ipcRenderer.invoke('db:getSettings'),
|
||||||
updateSetting: (key: string, value: string) => ipcRenderer.invoke('db:updateSetting', key, value),
|
updateSetting: (key: string, value: string) => ipcRenderer.invoke('db:updateSetting', key, value),
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export interface ElectronApi {
|
|||||||
range: 'today' | 'week' | 'month'
|
range: 'today' | 'week' | 'month'
|
||||||
}) => Promise<IpcResponse<{ startTime: string; rows: any[] }>>
|
}) => 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
|
// Settings & Sync
|
||||||
getSettings: () => Promise<IpcResponse<Record<string, string>>>
|
getSettings: () => Promise<IpcResponse<Record<string, string>>>
|
||||||
updateSetting: (key: string, value: string) => Promise<IpcResponse<void>>
|
updateSetting: (key: string, value: string) => Promise<IpcResponse<void>>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Electron</title>
|
<title>SecScore</title>
|
||||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
|
|||||||
+16
-41
@@ -6,9 +6,9 @@ import { Settings } from './components/Settings'
|
|||||||
import { ReasonManager } from './components/ReasonManager'
|
import { ReasonManager } from './components/ReasonManager'
|
||||||
import { ScoreManager } from './components/ScoreManager'
|
import { ScoreManager } from './components/ScoreManager'
|
||||||
import { Leaderboard } from './components/Leaderboard'
|
import { Leaderboard } from './components/Leaderboard'
|
||||||
|
import { SettlementHistory } from './components/SettlementHistory'
|
||||||
import { Wizard } from './components/Wizard'
|
import { Wizard } from './components/Wizard'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
import WavyLines from './assets/wavy-lines.svg'
|
|
||||||
|
|
||||||
const { Header, Content, Aside } = Layout
|
const { Header, Content, Aside } = Layout
|
||||||
|
|
||||||
@@ -20,8 +20,6 @@ function MainContent(): React.JSX.Element {
|
|||||||
const [authVisible, setAuthVisible] = useState(false)
|
const [authVisible, setAuthVisible] = useState(false)
|
||||||
const [authPassword, setAuthPassword] = useState('')
|
const [authPassword, setAuthPassword] = useState('')
|
||||||
const [authLoading, setAuthLoading] = useState(false)
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
const [appearanceBackground, setAppearanceBackground] = useState<'none' | 'wavy-lines'>('none')
|
|
||||||
const [appearanceBlur, setAppearanceBlur] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkWizard = async () => {
|
const checkWizard = async () => {
|
||||||
@@ -44,30 +42,9 @@ function MainContent(): React.JSX.Element {
|
|||||||
setHasAnyPassword(anyPwd)
|
setHasAnyPassword(anyPwd)
|
||||||
if (anyPwd && authRes.data.permission === 'view') setAuthVisible(true)
|
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()
|
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 () => {
|
const login = async () => {
|
||||||
@@ -102,6 +79,8 @@ function MainContent(): React.JSX.Element {
|
|||||||
return <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
|
return <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
|
||||||
case 'leaderboard':
|
case 'leaderboard':
|
||||||
return <Leaderboard />
|
return <Leaderboard />
|
||||||
|
case 'settlements':
|
||||||
|
return <SettlementHistory />
|
||||||
case 'reasons':
|
case 'reasons':
|
||||||
return <ReasonManager canEdit={permission === 'admin'} />
|
return <ReasonManager canEdit={permission === 'admin'} />
|
||||||
case 'settings':
|
case 'settings':
|
||||||
@@ -122,30 +101,23 @@ function MainContent(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', backgroundColor: 'var(--ss-bg-color)' }}>
|
<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
|
<Aside
|
||||||
|
className="ss-sidebar"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--ss-sidebar-bg)',
|
backgroundColor: 'var(--ss-sidebar-bg)',
|
||||||
borderRight: '1px solid var(--ss-border-color)'
|
borderRight: '1px solid var(--ss-border-color)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||||
<h2 style={{ color: 'var(--ss-text-main)', margin: 0 }}>SecScore</h2>
|
<h2 style={{ color: 'var(--ss-sidebar-text, var(--ss-text-main))', margin: 0 }}>SecScore</h2>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>教育积分管理</div>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--ss-sidebar-text, var(--ss-text-main))'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
教育积分管理
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
<Menu
|
||||||
value={activeMenu}
|
value={activeMenu}
|
||||||
@@ -161,6 +133,9 @@ function MainContent(): React.JSX.Element {
|
|||||||
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
|
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
|
||||||
排行榜
|
排行榜
|
||||||
</Menu.MenuItem>
|
</Menu.MenuItem>
|
||||||
|
<Menu.MenuItem value="settlements" icon={<HistoryIcon />}>
|
||||||
|
结算历史
|
||||||
|
</Menu.MenuItem>
|
||||||
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
|
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
|
||||||
理由管理
|
理由管理
|
||||||
</Menu.MenuItem>
|
</Menu.MenuItem>
|
||||||
|
|||||||
@@ -9,3 +9,43 @@ body,
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
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
|
return
|
||||||
}
|
}
|
||||||
const values = form.getFieldsValue?.(true) as any
|
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({
|
const res = await (window as any).api.createReason({
|
||||||
...values,
|
...values,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
delta: Number(values.delta)
|
delta: Number(values.delta)
|
||||||
})
|
})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
|||||||
@@ -15,6 +15,44 @@ import {
|
|||||||
Popconfirm
|
Popconfirm
|
||||||
} from 'tdesign-react'
|
} from 'tdesign-react'
|
||||||
import { RollbackIcon } from 'tdesign-icons-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 {
|
interface Student {
|
||||||
id: number
|
id: number
|
||||||
@@ -91,13 +129,28 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const values = form.getFieldsValue(true) as any
|
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('请填写完整信息')
|
MessagePlugin.warning('请填写完整信息')
|
||||||
return
|
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)
|
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({
|
const res = await (window as any).api.createEvent({
|
||||||
student_name: values.student_name,
|
student_name: values.student_name,
|
||||||
@@ -107,7 +160,12 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
MessagePlugin.success('积分提交成功')
|
MessagePlugin.success('积分提交成功')
|
||||||
form.setFieldsValue({ delta: undefined, reason_content: '', reason_id: undefined })
|
form.setFieldsValue({
|
||||||
|
delta: undefined,
|
||||||
|
reason_content: '',
|
||||||
|
reason_id: undefined,
|
||||||
|
type: 'add'
|
||||||
|
})
|
||||||
fetchData()
|
fetchData()
|
||||||
emitDataUpdated('events')
|
emitDataUpdated('events')
|
||||||
} else {
|
} else {
|
||||||
@@ -184,11 +242,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<Select
|
<Select
|
||||||
filterable
|
filterable
|
||||||
placeholder="请选择或搜索学生"
|
placeholder="请选择或搜索学生"
|
||||||
|
filter={(filterWords, option) =>
|
||||||
|
matchStudentName(getOptionLabel(option), filterWords)
|
||||||
|
}
|
||||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
<Form.FormItem label="分数" name="delta">
|
<Form.FormItem label="分数">
|
||||||
<Space>
|
<Space>
|
||||||
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
|
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
|
||||||
<Radio.Group variant="default-filled">
|
<Radio.Group variant="default-filled">
|
||||||
@@ -196,7 +257,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<Radio.Button value="subtract">扣分</Radio.Button>
|
<Radio.Button value="subtract">扣分</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.FormItem>
|
</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>
|
</Space>
|
||||||
</Form.FormItem>
|
</Form.FormItem>
|
||||||
|
|
||||||
@@ -208,6 +271,17 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
if (!Number.isFinite(id)) return
|
if (!Number.isFinite(id)) return
|
||||||
const reason = reasons.find((r) => r.id === id)
|
const reason = reasons.find((r) => r.id === id)
|
||||||
if (!reason) return
|
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({
|
form.setFieldsValue({
|
||||||
reason_content: reason.content,
|
reason_content: reason.content,
|
||||||
delta: Math.abs(reason.delta),
|
delta: Math.abs(reason.delta),
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import {
|
|||||||
Form,
|
Form,
|
||||||
Select,
|
Select,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
|
||||||
Switch,
|
Switch,
|
||||||
Button,
|
Button,
|
||||||
Space,
|
Space,
|
||||||
Divider,
|
Divider,
|
||||||
Tag,
|
Tag,
|
||||||
Dialog,
|
Dialog,
|
||||||
MessagePlugin
|
MessagePlugin,
|
||||||
|
DialogPlugin
|
||||||
} from 'tdesign-react'
|
} from 'tdesign-react'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
|||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const [settleLoading, setSettleLoading] = useState(false)
|
||||||
|
|
||||||
const canAdmin = permission === 'admin'
|
const canAdmin = permission === 'admin'
|
||||||
|
|
||||||
const permissionTag = useMemo(() => {
|
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 } }))
|
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 () => {
|
const loadAll = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.getSettings()
|
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 (
|
return (
|
||||||
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
|
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
<div
|
<div
|
||||||
@@ -265,29 +299,6 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.FormItem>
|
</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>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
@@ -396,6 +407,30 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
|||||||
</Tabs.TabPanel>
|
</Tabs.TabPanel>
|
||||||
|
|
||||||
<Tabs.TabPanel value="data" label="数据管理">
|
<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
|
<Card
|
||||||
title="同步设置 (远程模式)"
|
title="同步设置 (远程模式)"
|
||||||
style={{
|
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('请输入姓名')
|
MessagePlugin.warning('请输入姓名')
|
||||||
return
|
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) {
|
if (res.success) {
|
||||||
MessagePlugin.success('添加成功')
|
MessagePlugin.success('添加成功')
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
import { ThemeConfig } from '../../../preload/types'
|
import { ThemeConfig } from '../../../preload/types'
|
||||||
|
import { generateColorMap } from '../utils/color'
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
currentTheme: ThemeConfig | null
|
currentTheme: ThemeConfig | null
|
||||||
@@ -21,7 +22,12 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
root.setAttribute('theme-mode', theme.mode)
|
root.setAttribute('theme-mode', theme.mode)
|
||||||
|
|
||||||
// 2. 设置 TDesign 品牌色
|
// 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.warningColor) root.style.setProperty('--td-warning-color', tdesign.warningColor)
|
||||||
if (tdesign.errorColor) root.style.setProperty('--td-error-color', tdesign.errorColor)
|
if (tdesign.errorColor) root.style.setProperty('--td-error-color', tdesign.errorColor)
|
||||||
if (tdesign.successColor) root.style.setProperty('--td-success-color', tdesign.successColor)
|
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-border-color": "#333333",
|
||||||
"--ss-header-bg": "#1e1e1e",
|
"--ss-header-bg": "#1e1e1e",
|
||||||
"--ss-sidebar-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