mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
refactor: 移除WebSocket同步功能及相关代码
feat(theme): 增强侧边栏选中项样式配置 style: 优化主题变量和样式覆盖逻辑 chore: 清理未使用的依赖和配置文件
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Context as BaseContext } from '../shared/kernel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
export class MainContext extends BaseContext {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
handle(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise<any> | any
|
||||
) {
|
||||
ipcMain.handle(channel, listener)
|
||||
this.effect(() => ipcMain.removeHandler(channel))
|
||||
}
|
||||
|
||||
ipcOn(channel: string, listener: (event: Electron.IpcMainEvent, ...args: any[]) => void) {
|
||||
ipcMain.on(channel, listener)
|
||||
this.effect(() => ipcMain.removeListener(channel, listener))
|
||||
}
|
||||
}
|
||||
+36
-107
@@ -1,121 +1,50 @@
|
||||
import BetterSqlite3 from 'better-sqlite3'
|
||||
import { Context, Service } from '../../shared/kernel'
|
||||
import { DataSource } from 'typeorm'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
StudentEntity
|
||||
} from './entities'
|
||||
import { migrations } from './migrations'
|
||||
|
||||
export class DbManager {
|
||||
private db: BetterSqlite3.Database
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
db: DbManager
|
||||
}
|
||||
}
|
||||
|
||||
constructor(dbPath: string) {
|
||||
export class DbManager extends Service {
|
||||
public readonly dataSource: DataSource
|
||||
|
||||
constructor(ctx: Context, dbPath: string) {
|
||||
super(ctx, 'db')
|
||||
const dbDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
this.db = new BetterSqlite3(dbPath)
|
||||
this.init()
|
||||
this.dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: dbPath,
|
||||
entities: [StudentEntity, ReasonEntity, ScoreEventEntity, SettlementEntity, SettingEntity],
|
||||
migrations,
|
||||
synchronize: false,
|
||||
logging: false
|
||||
})
|
||||
}
|
||||
|
||||
private init() {
|
||||
// 开启外键支持
|
||||
this.db.pragma('foreign_keys = ON')
|
||||
|
||||
// 执行 Migration (简单实现)
|
||||
this.migrate()
|
||||
async initialize() {
|
||||
if (this.dataSource.isInitialized) return
|
||||
await this.dataSource.initialize()
|
||||
await this.dataSource.query('PRAGMA foreign_keys = ON')
|
||||
await this.dataSource.runMigrations()
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
// 建立学生表 - 仅保留姓名
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
score INTEGER DEFAULT 0,
|
||||
extra_json TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// 建立积分流水表
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS score_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
student_name TEXT NOT NULL,
|
||||
reason_content TEXT NOT NULL,
|
||||
delta INTEGER NOT NULL,
|
||||
val_prev INTEGER NOT NULL,
|
||||
val_curr INTEGER NOT NULL,
|
||||
event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
sync_state INTEGER DEFAULT 0,
|
||||
remote_id TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.exec(`
|
||||
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 (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// 初始设置
|
||||
const setSetting = this.db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)')
|
||||
setSetting.run('ws_server', 'ws://localhost:8080')
|
||||
setSetting.run('sync_mode', 'local') // local | remote
|
||||
setSetting.run('is_wizard_completed', '0') // 0: 未完成, 1: 已完成
|
||||
setSetting.run('log_level', 'info') // debug | info | warn | error
|
||||
|
||||
// 建立积分理由分类/预设表
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reasons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL UNIQUE,
|
||||
category TEXT DEFAULT '其他',
|
||||
delta INTEGER NOT NULL,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
sync_state INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
|
||||
// 初始数据种子
|
||||
const reasonCount = this.db.prepare('SELECT count(*) as count FROM reasons').get() as {
|
||||
count: number
|
||||
}
|
||||
if (reasonCount.count === 0) {
|
||||
const insert = this.db.prepare(
|
||||
'INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
insert.run('课堂表现优秀', '学习', 2, 1)
|
||||
insert.run('作业未交', '学习', -2, 1)
|
||||
insert.run('帮助同学', '品德', 5, 1)
|
||||
insert.run('打架斗殴', '纪律', -10, 1)
|
||||
insert.run('作业优秀', '学习', 2, 1)
|
||||
insert.run('课堂积极', '学习', 1, 1)
|
||||
insert.run('迟到', '纪律', -1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
public getDb(): BetterSqlite3.Database {
|
||||
return this.db
|
||||
async dispose() {
|
||||
if (!this.dataSource.isInitialized) return
|
||||
await this.dataSource.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { DataSource } from 'typeorm'
|
||||
import {
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
StudentEntity
|
||||
} from '../entities'
|
||||
|
||||
type exportBundle = {
|
||||
students: StudentEntity[]
|
||||
reasons: ReasonEntity[]
|
||||
events: ScoreEventEntity[]
|
||||
settlements: SettlementEntity[]
|
||||
settings: SettingEntity[]
|
||||
}
|
||||
|
||||
type importResult = { success: true } | { success: false; message: string }
|
||||
|
||||
export class DataBackupRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async exportJson(): Promise<string> {
|
||||
const bundle = await this.exportBundle()
|
||||
return JSON.stringify(bundle, null, 2)
|
||||
}
|
||||
|
||||
private async exportBundle(): Promise<exportBundle> {
|
||||
const students = await this.dataSource.getRepository(StudentEntity).find()
|
||||
const reasons = await this.dataSource.getRepository(ReasonEntity).find()
|
||||
const events = await this.dataSource.getRepository(ScoreEventEntity).find()
|
||||
const settlements = await this.dataSource
|
||||
.getRepository(SettlementEntity)
|
||||
.find({ order: { id: 'ASC' } })
|
||||
const settings = await this.dataSource
|
||||
.getRepository(SettingEntity)
|
||||
.createQueryBuilder('s')
|
||||
.where("s.key NOT LIKE 'security_%'")
|
||||
.getMany()
|
||||
return { students, reasons, events, settlements, settings }
|
||||
}
|
||||
|
||||
async importJson(jsonText: string): Promise<importResult> {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(String(jsonText ?? ''))
|
||||
} catch {
|
||||
return { success: false, message: 'Invalid JSON' }
|
||||
}
|
||||
|
||||
const students = Array.isArray(parsed?.students) ? parsed.students : []
|
||||
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
|
||||
const events = Array.isArray(parsed?.events) ? parsed.events : []
|
||||
const settlements = Array.isArray(parsed?.settlements) ? parsed.settlements : []
|
||||
const settings = Array.isArray(parsed?.settings) ? parsed.settings : []
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.clear(ScoreEventEntity)
|
||||
await manager.clear(SettlementEntity)
|
||||
await manager.clear(StudentEntity)
|
||||
await manager.clear(ReasonEntity)
|
||||
await manager
|
||||
.getRepository(SettingEntity)
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where("key NOT LIKE 'security_%'")
|
||||
.execute()
|
||||
|
||||
const insertStudents: Partial<StudentEntity>[] = []
|
||||
for (const s of students) {
|
||||
const name = String(s?.name ?? '').trim()
|
||||
if (!name) continue
|
||||
const score = Number(s?.score ?? 0)
|
||||
const extraJson = s?.extra_json != null ? String(s.extra_json) : null
|
||||
const createdAt = s?.created_at != null ? String(s.created_at) : new Date().toISOString()
|
||||
const updatedAt = s?.updated_at != null ? String(s.updated_at) : new Date().toISOString()
|
||||
insertStudents.push({
|
||||
name,
|
||||
score: Number.isFinite(score) ? score : 0,
|
||||
extra_json: extraJson,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt
|
||||
})
|
||||
}
|
||||
if (insertStudents.length) {
|
||||
await manager.getRepository(StudentEntity).insert(insertStudents)
|
||||
}
|
||||
|
||||
const insertReasons: Partial<ReasonEntity>[] = []
|
||||
for (const r of reasons) {
|
||||
const content = String(r?.content ?? '').trim()
|
||||
if (!content) continue
|
||||
const category = String(r?.category ?? '其他')
|
||||
const delta = Number(r?.delta ?? 0)
|
||||
const isSystem = Number(r?.is_system ?? 0) ? 1 : 0
|
||||
const updatedAt = r?.updated_at != null ? String(r.updated_at) : new Date().toISOString()
|
||||
insertReasons.push({
|
||||
content,
|
||||
category,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
is_system: isSystem,
|
||||
updated_at: updatedAt
|
||||
})
|
||||
}
|
||||
if (insertReasons.length) {
|
||||
await manager.getRepository(ReasonEntity).insert(insertReasons)
|
||||
}
|
||||
|
||||
const insertSettlements: Partial<SettlementEntity>[] = []
|
||||
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
|
||||
insertSettlements.push({
|
||||
id,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
created_at: createdAt
|
||||
})
|
||||
}
|
||||
if (insertSettlements.length) {
|
||||
await manager.getRepository(SettlementEntity).insert(insertSettlements)
|
||||
}
|
||||
|
||||
const insertEvents: Partial<ScoreEventEntity>[] = []
|
||||
for (const e of events) {
|
||||
const uuid = String(e?.uuid ?? '').trim()
|
||||
const studentName = String(e?.student_name ?? '').trim()
|
||||
const reasonContent = String(e?.reason_content ?? '').trim()
|
||||
if (!uuid || !studentName || !reasonContent) continue
|
||||
const delta = Number(e?.delta ?? 0)
|
||||
const valPrev = Number(e?.val_prev ?? 0)
|
||||
const valCurr = Number(e?.val_curr ?? 0)
|
||||
const eventTime = String(e?.event_time ?? new Date().toISOString())
|
||||
const settlementIdRaw = e?.settlement_id
|
||||
const settlementId =
|
||||
settlementIdRaw === null || settlementIdRaw === undefined
|
||||
? null
|
||||
: Number(settlementIdRaw)
|
||||
insertEvents.push({
|
||||
uuid,
|
||||
student_name: studentName,
|
||||
reason_content: reasonContent,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
val_prev: Number.isFinite(valPrev) ? valPrev : 0,
|
||||
val_curr: Number.isFinite(valCurr) ? valCurr : 0,
|
||||
event_time: eventTime,
|
||||
settlement_id: Number.isFinite(settlementId as any) ? (settlementId as any) : null
|
||||
})
|
||||
}
|
||||
if (insertEvents.length) {
|
||||
await manager.getRepository(ScoreEventEntity).insert(insertEvents)
|
||||
}
|
||||
|
||||
for (const it of settings) {
|
||||
const key = String(it?.key ?? '').trim()
|
||||
if (!key || key.startsWith('security_')) continue
|
||||
await manager.getRepository(SettingEntity).save({ key, value: String(it?.value ?? '') })
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e?.message || 'Import failed' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { StudentEntity } from './entities/StudentEntity'
|
||||
export { ReasonEntity } from './entities/ReasonEntity'
|
||||
export { ScoreEventEntity } from './entities/ScoreEventEntity'
|
||||
export { SettlementEntity } from './entities/SettlementEntity'
|
||||
export { SettingEntity } from './entities/SettingEntity'
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'reasons' })
|
||||
export class ReasonEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
content!: string
|
||||
|
||||
@Column({ type: 'text', default: '其他' })
|
||||
category!: string
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
delta!: number
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
is_system!: number
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'score_events' })
|
||||
export class ScoreEventEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
uuid!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
student_name!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
reason_content!: string
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
delta!: number
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
val_prev!: number
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
val_curr!: number
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
event_time!: string
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
settlement_id!: number | null
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'settings' })
|
||||
export class SettingEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
key!: string
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
value!: string | null
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'settlements' })
|
||||
export class SettlementEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text' })
|
||||
start_time!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
end_time!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'students' })
|
||||
export class StudentEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
score!: number
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
extra_json!: string | null
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { StudentEntity } from './StudentEntity'
|
||||
export { ReasonEntity } from './ReasonEntity'
|
||||
export { ScoreEventEntity } from './ScoreEventEntity'
|
||||
export { SettlementEntity } from './SettlementEntity'
|
||||
export { SettingEntity } from './SettingEntity'
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class InitSchema2026011800000 implements MigrationInterface {
|
||||
name = 'InitSchema2026011800000'
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('students'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "students" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"score" integer NOT NULL DEFAULT (0),
|
||||
"extra_json" text,
|
||||
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('reasons'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "reasons" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"category" text NOT NULL DEFAULT ('其他'),
|
||||
"delta" integer NOT NULL,
|
||||
"is_system" integer NOT NULL DEFAULT (0),
|
||||
"updated_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('settlements'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "settlements" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"start_time" text NOT NULL,
|
||||
"end_time" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('score_events'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "score_events" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"uuid" text NOT NULL,
|
||||
"student_name" text NOT NULL,
|
||||
"reason_content" text NOT NULL,
|
||||
"delta" integer NOT NULL,
|
||||
"val_prev" integer NOT NULL,
|
||||
"val_curr" integer NOT NULL,
|
||||
"event_time" text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"settlement_id" integer,
|
||||
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
|
||||
)
|
||||
`)
|
||||
}
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_score_events_settlement_id" ON "score_events" ("settlement_id")`
|
||||
)
|
||||
|
||||
if (!(await queryRunner.hasTable('settings'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "settings" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" text
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
|
||||
('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP),
|
||||
('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP),
|
||||
('纪律良好','纪律',1,1,CURRENT_TIMESTAMP),
|
||||
('帮助同学','品德',2,1,CURRENT_TIMESTAMP),
|
||||
('迟到','纪律',-1,1,CURRENT_TIMESTAMP),
|
||||
('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP)
|
||||
`)
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "score_events"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "settlements"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "students"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "reasons"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "settings"`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InitSchema2026011800000 } from './InitSchema2026011800000'
|
||||
|
||||
export const migrations = [InitSchema2026011800000]
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
configureHostDelegate,
|
||||
hostApplicationContext,
|
||||
hostBuilderContext,
|
||||
hostedService,
|
||||
middleware,
|
||||
serviceToken
|
||||
} from './types'
|
||||
import { ServiceProvider } from './serviceCollection'
|
||||
|
||||
export class HostApplication {
|
||||
private hostedInstances: hostedService[] = []
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
private readonly context: hostBuilderContext,
|
||||
private readonly provider: ServiceProvider,
|
||||
private readonly configureDelegates: configureHostDelegate[],
|
||||
private readonly middleware: middleware[],
|
||||
private readonly hostedTokens: serviceToken<hostedService>[]
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return
|
||||
|
||||
const appCtx = this.createApplicationContext()
|
||||
for (const configure of this.configureDelegates) {
|
||||
await configure(this.context, appCtx)
|
||||
}
|
||||
|
||||
await this.dispatch(0, appCtx, async () => {
|
||||
await this.bootstrapHostedServices()
|
||||
})
|
||||
|
||||
this.started = true
|
||||
await this.context.lifetime.notifyStarted()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return
|
||||
await this.context.lifetime.notifyStopping()
|
||||
await this.disposeHostedServices()
|
||||
await this.context.lifetime.notifyStopped()
|
||||
this.started = false
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.stop()
|
||||
await this.provider.dispose()
|
||||
}
|
||||
|
||||
get services(): ServiceProvider {
|
||||
return this.provider
|
||||
}
|
||||
|
||||
get hostContext(): hostBuilderContext {
|
||||
return this.context
|
||||
}
|
||||
|
||||
private createApplicationContext(): hostApplicationContext {
|
||||
return { services: this.provider, host: this.context }
|
||||
}
|
||||
|
||||
private async bootstrapHostedServices() {
|
||||
for (const token of this.hostedTokens) {
|
||||
const service = this.provider.get(token)
|
||||
this.hostedInstances.push(service)
|
||||
await service.start()
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeHostedServices() {
|
||||
while (this.hostedInstances.length) {
|
||||
const service = this.hostedInstances.pop()
|
||||
if (!service) continue
|
||||
await service.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(
|
||||
index: number,
|
||||
appCtx: hostApplicationContext,
|
||||
terminal: () => Promise<void>
|
||||
) {
|
||||
const middleware = this.middleware[index]
|
||||
if (!middleware) {
|
||||
await terminal()
|
||||
return
|
||||
}
|
||||
await middleware(appCtx, () => this.dispatch(index + 1, appCtx, terminal))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ServiceCollection } from './serviceCollection'
|
||||
import { HostApplication } from './hostApplication'
|
||||
import {
|
||||
type appRuntimeContext,
|
||||
type configureHostDelegate,
|
||||
type configureServicesDelegate,
|
||||
type hostBuilderContext,
|
||||
type hostBuilderSettings,
|
||||
type hostedService,
|
||||
type middleware,
|
||||
type serviceToken
|
||||
} from './types'
|
||||
|
||||
export class SecScoreHostBuilder {
|
||||
private readonly serviceCollection: ServiceCollection
|
||||
private readonly configureServicesDelegates: configureServicesDelegate[] = []
|
||||
private readonly configureDelegates: configureHostDelegate[] = []
|
||||
private readonly middleware: middleware[] = []
|
||||
private readonly hostedServices: serviceToken<hostedService>[] = []
|
||||
private readonly builderContext: hostBuilderContext
|
||||
|
||||
constructor(runtimeCtx: appRuntimeContext, settings: hostBuilderSettings = {}) {
|
||||
this.serviceCollection = new ServiceCollection(runtimeCtx)
|
||||
this.builderContext = {
|
||||
ctx: runtimeCtx,
|
||||
environmentName: settings.environment ?? process.env.SECSCORE_ENV ?? 'Production',
|
||||
properties: new Map(Object.entries(settings.properties ?? {})),
|
||||
lifetime: this.serviceCollection.getLifetime()
|
||||
}
|
||||
}
|
||||
|
||||
get context(): hostBuilderContext {
|
||||
return this.builderContext
|
||||
}
|
||||
|
||||
configureServices(callback: configureServicesDelegate): this {
|
||||
this.configureServicesDelegates.push(callback)
|
||||
return this
|
||||
}
|
||||
|
||||
configure(callback: configureHostDelegate): this {
|
||||
this.configureDelegates.push(callback)
|
||||
return this
|
||||
}
|
||||
|
||||
use(middleware: middleware): this {
|
||||
this.middleware.push(middleware)
|
||||
return this
|
||||
}
|
||||
|
||||
addHostedService(token: serviceToken<hostedService>): this {
|
||||
this.hostedServices.push(token)
|
||||
return this
|
||||
}
|
||||
|
||||
async build(): Promise<SecScoreHost> {
|
||||
for (const configure of this.configureServicesDelegates) {
|
||||
await configure(this.builderContext, this.serviceCollection)
|
||||
}
|
||||
|
||||
const provider = this.serviceCollection.buildServiceProvider()
|
||||
const application = new HostApplication(
|
||||
this.builderContext,
|
||||
provider,
|
||||
this.configureDelegates,
|
||||
this.middleware,
|
||||
this.hostedServices
|
||||
)
|
||||
return new SecScoreHost(application)
|
||||
}
|
||||
}
|
||||
|
||||
export class SecScoreHost {
|
||||
constructor(private readonly app: HostApplication) {}
|
||||
|
||||
get services() {
|
||||
return this.app.services
|
||||
}
|
||||
|
||||
get hostContext() {
|
||||
return this.app.hostContext
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.app.start()
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.app.stop()
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.app.dispose()
|
||||
}
|
||||
|
||||
async run() {
|
||||
await this.start()
|
||||
return async () => {
|
||||
await this.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostBuilder(ctx: appRuntimeContext, settings?: hostBuilderSettings) {
|
||||
return new SecScoreHostBuilder(ctx, settings)
|
||||
}
|
||||
|
||||
export const Host = {
|
||||
createApplicationBuilder: createHostBuilder
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { SecScoreHostBuilder, SecScoreHost, createHostBuilder, Host } from './hostBuilder'
|
||||
export { ServiceCollection, ServiceProvider } from './serviceCollection'
|
||||
export {
|
||||
HostApplicationLifetimeToken,
|
||||
AppConfigToken,
|
||||
LoggerToken,
|
||||
DbManagerToken,
|
||||
SettingsStoreToken,
|
||||
SecurityServiceToken,
|
||||
PermissionServiceToken,
|
||||
StudentRepositoryToken,
|
||||
ReasonRepositoryToken,
|
||||
EventRepositoryToken,
|
||||
SettlementRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken
|
||||
} from './tokens'
|
||||
export type {
|
||||
appRuntimeContext,
|
||||
hostedService,
|
||||
middleware,
|
||||
hostBuilderSettings,
|
||||
hostBuilderContext,
|
||||
configureServicesDelegate,
|
||||
configureHostDelegate,
|
||||
hostApplicationLifetime,
|
||||
hostApplicationContext,
|
||||
serviceToken
|
||||
} from './types'
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { awaitable, disposer, hostApplicationLifetime } from './types'
|
||||
|
||||
type loggerLike = { error: (...args: any[]) => void }
|
||||
|
||||
// 默认的应用生命周期实现,管理启动/停止事件
|
||||
export class DefaultHostApplicationLifetime implements hostApplicationLifetime {
|
||||
constructor(private readonly logger: loggerLike = console) {}
|
||||
private readonly started = new Set<() => awaitable<void>>() // 启动事件处理器
|
||||
private readonly stopping = new Set<() => awaitable<void>>() // 停止事件处理器
|
||||
private readonly stopped = new Set<() => awaitable<void>>() // 已停止事件处理器
|
||||
|
||||
// 注册启动事件处理器
|
||||
onStarted(handler: () => awaitable<void>): disposer {
|
||||
this.started.add(handler)
|
||||
return () => {
|
||||
this.started.delete(handler)
|
||||
} // 返回清理函数
|
||||
}
|
||||
|
||||
// 注册停止事件处理器
|
||||
onStopping(handler: () => awaitable<void>): disposer {
|
||||
this.stopping.add(handler)
|
||||
return () => {
|
||||
this.stopping.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册已停止事件处理器
|
||||
onStopped(handler: () => awaitable<void>): disposer {
|
||||
this.stopped.add(handler)
|
||||
return () => {
|
||||
this.stopped.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知所有启动事件处理器
|
||||
async notifyStarted() {
|
||||
await this.dispatch(this.started)
|
||||
}
|
||||
|
||||
// 通知所有停止事件处理器
|
||||
async notifyStopping() {
|
||||
await this.dispatch(this.stopping)
|
||||
}
|
||||
|
||||
// 通知所有已停止事件处理器
|
||||
async notifyStopped() {
|
||||
await this.dispatch(this.stopped)
|
||||
}
|
||||
|
||||
// 执行事件处理器列表
|
||||
private async dispatch(targets: Set<() => awaitable<void>>) {
|
||||
for (const handler of Array.from(targets)) {
|
||||
try {
|
||||
await handler()
|
||||
} catch (error) {
|
||||
this.logger.error('[HostLifetime] handler failed', error as Error) // 记录错误但不中断
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { HostApplicationLifetimeToken } from './tokens'
|
||||
import { DefaultHostApplicationLifetime } from './lifetime'
|
||||
import type {
|
||||
appRuntimeContext,
|
||||
disposer,
|
||||
injectableClass,
|
||||
serviceDescriptor,
|
||||
serviceFactory,
|
||||
serviceFactoryOrValue,
|
||||
serviceLifetime,
|
||||
serviceToken
|
||||
} from './types'
|
||||
|
||||
// 检查值是否为构造函数
|
||||
function isConstructor<T>(value: serviceFactoryOrValue<T>): value is injectableClass<T> {
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
!!(value as any).prototype &&
|
||||
(value as any).prototype.constructor === value
|
||||
)
|
||||
}
|
||||
|
||||
// 服务集合类,管理所有注册的服务描述符
|
||||
export class ServiceCollection {
|
||||
private readonly descriptors = new Map<serviceToken, serviceDescriptor>() // 服务描述符映射
|
||||
private readonly hostLifetime: DefaultHostApplicationLifetime // 应用生命周期管理器
|
||||
|
||||
constructor(private readonly ctx: appRuntimeContext) {
|
||||
this.hostLifetime = new DefaultHostApplicationLifetime(this.ctx.logger ?? console)
|
||||
this.addSingleton(HostApplicationLifetimeToken, () => this.hostLifetime)
|
||||
}
|
||||
|
||||
// 获取生命周期管理器
|
||||
getLifetime() {
|
||||
return this.hostLifetime
|
||||
}
|
||||
|
||||
// 添加单例服务
|
||||
addSingleton<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'singleton', impl)
|
||||
}
|
||||
|
||||
// 添加作用域服务
|
||||
addScoped<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'scoped', impl)
|
||||
}
|
||||
|
||||
// 添加瞬时服务
|
||||
addTransient<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'transient', impl)
|
||||
}
|
||||
|
||||
// 尝试添加单例服务(如果不存在)
|
||||
tryAddSingleton<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
if (!this.descriptors.has(token)) {
|
||||
this.addSingleton(token, impl)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
// 检查服务是否已注册
|
||||
has(token: serviceToken): boolean {
|
||||
return this.descriptors.has(token)
|
||||
}
|
||||
|
||||
// 清空所有服务
|
||||
clear(): void {
|
||||
this.descriptors.clear()
|
||||
}
|
||||
|
||||
// 构建服务提供者
|
||||
buildServiceProvider(): ServiceProvider {
|
||||
return new ServiceProvider(this.ctx, new Map(this.descriptors))
|
||||
}
|
||||
|
||||
// 注册服务
|
||||
private register<T>(
|
||||
token: serviceToken<T>,
|
||||
lifetime: serviceLifetime,
|
||||
impl: serviceFactoryOrValue<T>
|
||||
): this {
|
||||
const descriptor: serviceDescriptor = {
|
||||
token,
|
||||
lifetime,
|
||||
factory: this.normalizeFactory(impl) // 标准化工厂函数
|
||||
}
|
||||
this.descriptors.set(token, descriptor)
|
||||
return this
|
||||
}
|
||||
|
||||
// 标准化工厂函数
|
||||
private normalizeFactory<T>(impl: serviceFactoryOrValue<T>): serviceFactory<T> {
|
||||
if (isConstructor(impl)) {
|
||||
return (provider) => this.instantiateClass(impl, provider) // 构造函数,实例化类
|
||||
}
|
||||
|
||||
if (typeof impl === 'function') {
|
||||
return impl as serviceFactory<T> // 已经是工厂函数
|
||||
}
|
||||
|
||||
return () => impl // 直接值,返回常量
|
||||
}
|
||||
|
||||
// 实例化类,注入依赖
|
||||
private instantiateClass<T>(Ctor: injectableClass<T>, provider: ServiceProvider): T {
|
||||
const deps = [...(Ctor.inject ?? [])].map((token) => provider.get(token)) // 获取依赖
|
||||
return new Ctor(...deps) // 构造实例
|
||||
}
|
||||
}
|
||||
|
||||
// 服务提供者类,负责解析和提供服务实例
|
||||
export class ServiceProvider {
|
||||
private readonly singletonCache: Map<serviceToken, unknown> // 单例缓存
|
||||
private readonly scopedCache = new Map<serviceToken, unknown>() // 作用域缓存
|
||||
private readonly singletonCleanup: disposer[] // 单例清理函数
|
||||
private readonly scopedCleanup: disposer[] = [] // 作用域清理函数
|
||||
private readonly root: ServiceProvider | null // 根提供者
|
||||
|
||||
constructor(
|
||||
private readonly ctx: appRuntimeContext,
|
||||
private readonly descriptors: Map<serviceToken, serviceDescriptor>,
|
||||
root: ServiceProvider | null = null,
|
||||
private readonly isScope = false // 是否为作用域提供者
|
||||
) {
|
||||
this.root = root
|
||||
this.singletonCache = root ? root.singletonCache : new Map() // 共享单例缓存
|
||||
this.singletonCleanup = root ? root.singletonCleanup : []
|
||||
}
|
||||
|
||||
// 获取服务实例
|
||||
get<T>(token: serviceToken<T>): T {
|
||||
const descriptor = this.descriptors.get(token) as serviceDescriptor<T> | undefined
|
||||
if (!descriptor) {
|
||||
throw new Error(`Service not registered for token: ${token.toString?.() ?? String(token)}`)
|
||||
}
|
||||
|
||||
switch (descriptor.lifetime) {
|
||||
case 'singleton':
|
||||
return this.resolveSingleton(descriptor)
|
||||
case 'scoped':
|
||||
return this.resolveScoped(descriptor)
|
||||
case 'transient':
|
||||
default:
|
||||
return this.instantiate(descriptor) // 每次都新实例
|
||||
}
|
||||
}
|
||||
|
||||
// 创建作用域提供者
|
||||
createScope(): ServiceProvider {
|
||||
return new ServiceProvider(this.ctx, this.descriptors, this.root ?? this, true)
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
async dispose(): Promise<void> {
|
||||
await this.flushCleanup(this.scopedCleanup) // 先清理作用域
|
||||
if (!this.isScope) {
|
||||
await this.flushCleanup(this.singletonCleanup) // 再清理单例
|
||||
this.singletonCache.clear()
|
||||
}
|
||||
this.scopedCache.clear()
|
||||
}
|
||||
|
||||
// 解析单例服务
|
||||
private resolveSingleton<T>(descriptor: serviceDescriptor<T>): T {
|
||||
const cacheOwner = this.root ?? this
|
||||
if (cacheOwner.singletonCache.has(descriptor.token)) {
|
||||
return cacheOwner.singletonCache.get(descriptor.token) as T
|
||||
}
|
||||
const instance = this.instantiate(descriptor)
|
||||
cacheOwner.singletonCache.set(descriptor.token, instance)
|
||||
const disposer = this.extractDisposer(instance) // 提取清理函数
|
||||
if (disposer) cacheOwner.singletonCleanup.push(disposer)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 解析作用域服务
|
||||
private resolveScoped<T>(descriptor: serviceDescriptor<T>): T {
|
||||
if (this.scopedCache.has(descriptor.token)) {
|
||||
return this.scopedCache.get(descriptor.token) as T
|
||||
}
|
||||
const instance = this.instantiate(descriptor)
|
||||
this.scopedCache.set(descriptor.token, instance)
|
||||
const disposer = this.extractDisposer(instance)
|
||||
if (disposer) this.scopedCleanup.push(disposer)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 实例化服务
|
||||
private instantiate<T>(descriptor: serviceDescriptor<T>): T {
|
||||
return descriptor.factory(this)
|
||||
}
|
||||
|
||||
// 提取实例的清理函数
|
||||
private extractDisposer(instance: unknown): disposer | null {
|
||||
if (!instance) return null
|
||||
if (typeof (instance as any).dispose === 'function') {
|
||||
return () => (instance as any).dispose()
|
||||
}
|
||||
if (typeof (instance as any).destroy === 'function') {
|
||||
return () => (instance as any).destroy()
|
||||
}
|
||||
if (typeof (instance as any).stop === 'function') {
|
||||
return () => (instance as any).stop()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 执行清理函数列表
|
||||
private async flushCleanup(cleanup: disposer[]) {
|
||||
while (cleanup.length) {
|
||||
const disposer = cleanup.pop()
|
||||
if (!disposer) continue
|
||||
await disposer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const HostApplicationLifetimeToken = Symbol.for('secscore.hosting.lifetime')
|
||||
|
||||
export const AppConfigToken = Symbol.for('secscore.app.config')
|
||||
export const LoggerToken = Symbol.for('secscore.logger')
|
||||
export const DbManagerToken = Symbol.for('secscore.dbManager')
|
||||
export const SettingsStoreToken = Symbol.for('secscore.settingsStore')
|
||||
export const SecurityServiceToken = Symbol.for('secscore.securityService')
|
||||
export const PermissionServiceToken = Symbol.for('secscore.permissionService')
|
||||
export const StudentRepositoryToken = Symbol.for('secscore.studentRepository')
|
||||
export const ReasonRepositoryToken = Symbol.for('secscore.reasonRepository')
|
||||
export const EventRepositoryToken = Symbol.for('secscore.eventRepository')
|
||||
export const SettlementRepositoryToken = Symbol.for('secscore.settlementRepository')
|
||||
export const ThemeServiceToken = Symbol.for('secscore.themeService')
|
||||
export const WindowManagerToken = Symbol.for('secscore.windowManager')
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ServiceCollection, ServiceProvider } from './serviceCollection'
|
||||
|
||||
export type awaitable<T> = T | Promise<T>
|
||||
export type disposer = () => awaitable<void>
|
||||
|
||||
export type serviceToken<T = unknown> = string | symbol | (new (...args: any[]) => T)
|
||||
|
||||
export interface injectableClass<T = unknown> {
|
||||
new (...args: any[]): T
|
||||
inject?: readonly serviceToken[]
|
||||
}
|
||||
|
||||
export type serviceFactory<T> = (provider: ServiceProvider) => T
|
||||
export type serviceFactoryOrValue<T> = serviceFactory<T> | injectableClass<T> | T
|
||||
|
||||
export type serviceLifetime = 'singleton' | 'scoped' | 'transient'
|
||||
|
||||
export interface serviceDescriptor<T = unknown> {
|
||||
token: serviceToken<T>
|
||||
lifetime: serviceLifetime
|
||||
factory: serviceFactory<T>
|
||||
}
|
||||
|
||||
export interface hostedService {
|
||||
start(): awaitable<void>
|
||||
stop(): awaitable<void>
|
||||
}
|
||||
|
||||
export interface hostBuilderSettings {
|
||||
environment?: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface hostApplicationLifetime {
|
||||
onStarted(handler: () => awaitable<void>): disposer
|
||||
onStopping(handler: () => awaitable<void>): disposer
|
||||
onStopped(handler: () => awaitable<void>): disposer
|
||||
notifyStarted(): Promise<void>
|
||||
notifyStopping(): Promise<void>
|
||||
notifyStopped(): Promise<void>
|
||||
}
|
||||
|
||||
export interface appRuntimeContext {
|
||||
logger?: { error: (...args: any[]) => void }
|
||||
}
|
||||
|
||||
export interface hostBuilderContext {
|
||||
ctx: appRuntimeContext
|
||||
environmentName: string
|
||||
properties: Map<string | symbol, unknown>
|
||||
lifetime: hostApplicationLifetime
|
||||
}
|
||||
|
||||
export interface hostApplicationContext {
|
||||
services: ServiceProvider
|
||||
host: hostBuilderContext
|
||||
}
|
||||
|
||||
export type configureServicesDelegate = (
|
||||
context: hostBuilderContext,
|
||||
services: ServiceCollection
|
||||
) => awaitable<void>
|
||||
|
||||
export type configureHostDelegate = (
|
||||
context: hostBuilderContext,
|
||||
app: hostApplicationContext
|
||||
) => awaitable<void>
|
||||
|
||||
export type middleware = (app: hostApplicationContext, next: () => Promise<void>) => awaitable<void>
|
||||
|
||||
export type { ServiceCollection, ServiceProvider }
|
||||
+214
-763
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
import { Database } from 'better-sqlite3'
|
||||
import { app } from 'electron'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { IsNull } from 'typeorm'
|
||||
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
export interface ScoreEvent {
|
||||
export interface scoreEvent {
|
||||
id: number
|
||||
uuid: string
|
||||
student_name: string
|
||||
@@ -11,104 +13,207 @@ export interface ScoreEvent {
|
||||
val_prev: number
|
||||
val_curr: number
|
||||
event_time: string
|
||||
sync_state: number
|
||||
settlement_id?: number | null
|
||||
remote_id?: string
|
||||
}
|
||||
|
||||
export class EventRepository {
|
||||
constructor(private db: Database) {}
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
events: EventRepository
|
||||
}
|
||||
}
|
||||
|
||||
findAll(limit = 100) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM score_events
|
||||
WHERE settlement_id IS NULL
|
||||
ORDER BY event_time DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(limit)
|
||||
export class EventRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'events')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
create(event: { student_name: string; reason_content: string; delta: number }) {
|
||||
const lastInsertRowid = this.db.transaction(() => {
|
||||
// 1. Get current score
|
||||
const student = this.db
|
||||
.prepare('SELECT score FROM students WHERE name = ?')
|
||||
.get(event.student_name) as { score: number }
|
||||
if (!student) throw new Error('Student not found')
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
const val_prev = student.score
|
||||
const val_curr = val_prev + event.delta
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:event:query', async (_, params) => {
|
||||
try {
|
||||
return { success: true, data: await this.findAll(params?.limit) }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:delete', async (event, uuid) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.deleteByUuid(uuid)
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:create', async (event, data) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const id = await this.create(data)
|
||||
return { success: true, data: id }
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:queryByStudent', async (_, params) => {
|
||||
try {
|
||||
const limit = Number(params?.limit ?? 50)
|
||||
const studentName = String(params?.student_name ?? '')
|
||||
const startTime = params?.startTime ? String(params.startTime) : null
|
||||
if (!studentName) return { success: true, data: [] }
|
||||
const rows = await this.queryByStudent(studentName, startTime, limit)
|
||||
return { success: true, data: rows }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:leaderboard:query', async (_, params) => {
|
||||
try {
|
||||
const range = String(params?.range ?? 'today')
|
||||
const data = await this.queryLeaderboard(range)
|
||||
return { success: true, data }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(limit = 100) {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
|
||||
return await repo.find({
|
||||
where: { settlement_id: IsNull() },
|
||||
order: { event_time: 'DESC' },
|
||||
take: limit
|
||||
})
|
||||
}
|
||||
|
||||
async create(event: { student_name: string; reason_content: string; delta: number }) {
|
||||
const ds = this.ctx.db.dataSource
|
||||
return await ds.transaction(async (manager) => {
|
||||
const studentName = String(event?.student_name ?? '').trim()
|
||||
const reasonContent = String(event?.reason_content ?? '').trim()
|
||||
const delta = Number(event?.delta ?? 0)
|
||||
|
||||
const studentRepo = manager.getRepository(StudentEntity)
|
||||
const existingStudent = await studentRepo.findOne({ where: { name: studentName } })
|
||||
if (!existingStudent) throw new Error('Student not found')
|
||||
|
||||
const val_prev = Number(existingStudent.score ?? 0)
|
||||
const val_curr = val_prev + (Number.isFinite(delta) ? delta : 0)
|
||||
const uuid = uuidv4()
|
||||
const event_time = new Date().toISOString()
|
||||
|
||||
// 2. Insert event
|
||||
const info = this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
||||
`
|
||||
)
|
||||
.run(
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const saved = await eventsRepo.save(
|
||||
eventsRepo.create({
|
||||
uuid,
|
||||
event.student_name,
|
||||
event.reason_content,
|
||||
event.delta,
|
||||
student_name: studentName,
|
||||
reason_content: reasonContent,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
val_prev,
|
||||
val_curr,
|
||||
event_time
|
||||
)
|
||||
event_time,
|
||||
settlement_id: null
|
||||
})
|
||||
)
|
||||
|
||||
// 3. Update student score
|
||||
this.db
|
||||
.prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?')
|
||||
.run(val_curr, event.student_name)
|
||||
await studentRepo.update(
|
||||
{ id: existingStudent.id },
|
||||
{ score: val_curr, updated_at: new Date().toISOString() }
|
||||
)
|
||||
|
||||
return info.lastInsertRowid as number
|
||||
})()
|
||||
|
||||
// 触发同步 (如果已连接)
|
||||
if (app) {
|
||||
app.emit('score-event-created')
|
||||
}
|
||||
|
||||
return lastInsertRowid
|
||||
return saved.id
|
||||
})
|
||||
}
|
||||
|
||||
getUnsynced() {
|
||||
return this.db.prepare('SELECT * FROM score_events WHERE sync_state = 0').all() as ScoreEvent[]
|
||||
}
|
||||
|
||||
markSynced(uuid: string, remote_id?: string) {
|
||||
this.db
|
||||
.prepare('UPDATE score_events SET sync_state = 1, remote_id = ? WHERE uuid = ?')
|
||||
.run(remote_id, uuid)
|
||||
}
|
||||
|
||||
deleteByUuid(uuid: string) {
|
||||
this.db.transaction(() => {
|
||||
// 1. Get event info
|
||||
const event = this.db
|
||||
.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) {
|
||||
async deleteByUuid(uuid: string) {
|
||||
const ds = this.ctx.db.dataSource
|
||||
await ds.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const ev = await eventsRepo.findOne({ where: { uuid: String(uuid ?? '').trim() } })
|
||||
if (!ev) return
|
||||
if (ev.settlement_id !== null && ev.settlement_id !== undefined) {
|
||||
throw new Error('该记录已结算,无法撤销')
|
||||
}
|
||||
|
||||
// 2. Revert student score
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE students SET score = score - ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
|
||||
const studentRepo = manager.getRepository(StudentEntity)
|
||||
const student = await studentRepo.findOne({ where: { name: ev.student_name } })
|
||||
if (student) {
|
||||
await studentRepo.update(
|
||||
{ id: student.id },
|
||||
{
|
||||
score: Number(student.score ?? 0) - Number(ev.delta ?? 0),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
)
|
||||
.run(event.delta, event.student_name)
|
||||
}
|
||||
|
||||
// 3. Delete event
|
||||
this.db.prepare('DELETE FROM score_events WHERE uuid = ?').run(uuid)
|
||||
})()
|
||||
await eventsRepo.delete({ uuid: ev.uuid })
|
||||
})
|
||||
}
|
||||
|
||||
async queryByStudent(studentName: string, startTime: string | null, limit: number) {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
|
||||
const qb = repo
|
||||
.createQueryBuilder('e')
|
||||
.where('e.student_name = :studentName', { studentName })
|
||||
.andWhere('e.settlement_id IS NULL')
|
||||
.orderBy('e.event_time', 'DESC')
|
||||
.limit(limit)
|
||||
if (startTime) {
|
||||
qb.andWhere('julianday(e.event_time) >= julianday(:startTime)', { startTime })
|
||||
}
|
||||
return await qb.getMany()
|
||||
}
|
||||
|
||||
async queryLeaderboard(range: string) {
|
||||
const now = new Date()
|
||||
let start = new Date(now)
|
||||
if (range === 'today') {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else if (range === 'week') {
|
||||
const day = start.getDay()
|
||||
const diff = (day === 0 ? -6 : 1) - day
|
||||
start.setDate(start.getDate() + diff)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else if (range === 'month') {
|
||||
start = new Date(start.getFullYear(), start.getMonth(), 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
}
|
||||
const startTime = start.toISOString()
|
||||
|
||||
const qb = this.ctx.db.dataSource
|
||||
.getRepository(StudentEntity)
|
||||
.createQueryBuilder('s')
|
||||
.leftJoin(
|
||||
ScoreEventEntity,
|
||||
'e',
|
||||
'e.student_name = s.name AND e.settlement_id IS NULL AND julianday(e.event_time) >= julianday(:startTime)',
|
||||
{ startTime }
|
||||
)
|
||||
.select('s.id', 'id')
|
||||
.addSelect('s.name', 'name')
|
||||
.addSelect('s.score', 'score')
|
||||
.addSelect('COALESCE(SUM(e.delta), 0)', 'range_change')
|
||||
.groupBy('s.id')
|
||||
.addGroupBy('s.name')
|
||||
.addGroupBy('s.score')
|
||||
.orderBy('s.score', 'DESC')
|
||||
.addOrderBy('range_change', 'DESC')
|
||||
.addOrderBy('s.name', 'ASC')
|
||||
|
||||
const rows = await qb.getRawMany()
|
||||
return { startTime, rows }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,94 @@
|
||||
import { Database } from 'better-sqlite3'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { ReasonEntity } from '../db/entities'
|
||||
|
||||
export interface Reason {
|
||||
export interface reason {
|
||||
id: number
|
||||
content: string
|
||||
category: string
|
||||
delta: number
|
||||
is_system: number
|
||||
sync_state: number
|
||||
}
|
||||
|
||||
export class ReasonRepository {
|
||||
constructor(private db: Database) {}
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
reasons: ReasonRepository
|
||||
}
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM reasons ORDER BY category ASC, content ASC')
|
||||
.all() as Reason[]
|
||||
export class ReasonRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'reasons')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
create(reason: Omit<Reason, 'id' | 'sync_state' | 'is_system'>) {
|
||||
const info = this.db
|
||||
.prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, 0)')
|
||||
.run(reason.content, reason.category, reason.delta)
|
||||
return info.lastInsertRowid as number
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
update(id: number, reason: Partial<Reason>) {
|
||||
const sets: string[] = []
|
||||
const vals: any[] = []
|
||||
Object.entries(reason).forEach(([key, val]) => {
|
||||
if (key !== 'id') {
|
||||
sets.push(`${key} = ?`)
|
||||
vals.push(val)
|
||||
}
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:reason:query', async () => ({
|
||||
success: true,
|
||||
data: await this.findAll()
|
||||
}))
|
||||
this.mainCtx.handle('db:reason:create', async (event, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: await this.create(data) }
|
||||
})
|
||||
this.mainCtx.handle('db:reason:update', async (event, id, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.update(id, data)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('db:reason:delete', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const changes = await this.delete(id)
|
||||
if (!changes) return { success: false, message: '记录不存在' }
|
||||
return { success: true, data: { changes } }
|
||||
})
|
||||
// 兼容前端 deleteReason 命名错误
|
||||
this.mainCtx.handle('db:deleteReason', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const changes = await this.delete(id)
|
||||
if (!changes) return { success: false, message: '记录不存在' }
|
||||
return { success: true, data: { changes } }
|
||||
})
|
||||
vals.push(id)
|
||||
this.db
|
||||
.prepare(`UPDATE reasons SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
|
||||
.run(...vals)
|
||||
}
|
||||
|
||||
delete(id: number) {
|
||||
const info = this.db.prepare('DELETE FROM reasons WHERE id = ?').run(id)
|
||||
return info.changes
|
||||
async findAll(): Promise<reason[]> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
|
||||
return (await repo.find({ order: { category: 'ASC', content: 'ASC' } })) as any
|
||||
}
|
||||
|
||||
async create(reason: Omit<reason, 'id' | 'is_system'>): Promise<number> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
|
||||
const created = repo.create({
|
||||
content: String(reason?.content ?? '').trim(),
|
||||
category: String(reason?.category ?? '其他'),
|
||||
delta: Number(reason?.delta ?? 0),
|
||||
is_system: 0,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
const saved = await repo.save(created)
|
||||
return saved.id
|
||||
}
|
||||
|
||||
async update(id: number, reason: Partial<reason>): Promise<void> {
|
||||
const next: any = {}
|
||||
for (const [key, val] of Object.entries(reason)) {
|
||||
if (key === 'id') continue
|
||||
next[key] = val
|
||||
}
|
||||
next.updated_at = new Date().toISOString()
|
||||
await this.ctx.db.dataSource.getRepository(ReasonEntity).update(id, next)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<number> {
|
||||
const result = await this.ctx.db.dataSource.getRepository(ReasonEntity).delete(id)
|
||||
return Number(result.affected ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +1,172 @@
|
||||
import { Database } from 'better-sqlite3'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { IsNull } from 'typeorm'
|
||||
import { ScoreEventEntity, SettlementEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
export interface SettlementSummary {
|
||||
export interface settlementSummary {
|
||||
id: number
|
||||
start_time: string
|
||||
end_time: string
|
||||
event_count: number
|
||||
}
|
||||
|
||||
export interface SettlementLeaderboardRow {
|
||||
export interface settlementLeaderboardRow {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export class SettlementRepository {
|
||||
constructor(private db: Database) {}
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
settlements: SettlementRepository
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
export class SettlementRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'settlements')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
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 }
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
const eventCount = Number(unassigned?.count ?? 0)
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:settlement:query', async () => {
|
||||
try {
|
||||
return { success: true, data: await this.findAll() }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:settlement:create', async (event) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const data = await this.settleNow()
|
||||
return { success: true, data }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.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: await this.getLeaderboard(settlementId) }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(): Promise<settlementSummary[]> {
|
||||
const qb = this.ctx.db.dataSource
|
||||
.getRepository(SettlementEntity)
|
||||
.createQueryBuilder('s')
|
||||
.select('s.id', 'id')
|
||||
.addSelect('s.start_time', 'start_time')
|
||||
.addSelect('s.end_time', 'end_time')
|
||||
.addSelect((subQb) => {
|
||||
return subQb
|
||||
.select('COUNT(1)', 'cnt')
|
||||
.from(ScoreEventEntity, 'e')
|
||||
.where('e.settlement_id = s.id')
|
||||
}, 'event_count')
|
||||
.orderBy('julianday(s.end_time)', 'DESC')
|
||||
|
||||
const rows = await qb.getRawMany()
|
||||
return rows.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
start_time: String(r.start_time),
|
||||
end_time: String(r.end_time),
|
||||
event_count: Number(r.event_count ?? 0)
|
||||
}))
|
||||
}
|
||||
|
||||
async settleNow() {
|
||||
const ds = this.ctx.db.dataSource
|
||||
return await ds.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const unassignedCount = await eventsRepo.count({ where: { settlement_id: IsNull() } })
|
||||
const eventCount = Number(unassignedCount ?? 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 settlementsRepo = manager.getRepository(SettlementEntity)
|
||||
const lastSettlement = await settlementsRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.end_time', 'end_time')
|
||||
.orderBy('julianday(s.end_time)', 'DESC')
|
||||
.limit(1)
|
||||
.getRawOne<{ end_time?: string }>()
|
||||
|
||||
const startTime = lastSettlement?.end_time || minEvent?.min_time || endTime
|
||||
const minEvent = await eventsRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('MIN(e.event_time)', 'min_time')
|
||||
.where('e.settlement_id IS NULL')
|
||||
.getRawOne<{ min_time?: string }>()
|
||||
|
||||
const info = this.db
|
||||
.prepare(`INSERT INTO settlements (start_time, end_time) VALUES (?, ?)`)
|
||||
.run(startTime, endTime)
|
||||
const settlementId = info.lastInsertRowid as number
|
||||
const startTime = String(lastSettlement?.end_time || minEvent?.min_time || endTime)
|
||||
|
||||
this.db
|
||||
.prepare(`UPDATE score_events SET settlement_id = ? WHERE settlement_id IS NULL`)
|
||||
.run(settlementId)
|
||||
const created = await settlementsRepo.save(
|
||||
settlementsRepo.create({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
)
|
||||
const settlementId = created.id
|
||||
|
||||
this.db.prepare(`UPDATE students SET score = 0, updated_at = CURRENT_TIMESTAMP`).run()
|
||||
await eventsRepo
|
||||
.createQueryBuilder()
|
||||
.update(ScoreEventEntity)
|
||||
.set({ settlement_id: settlementId })
|
||||
.where('settlement_id IS NULL')
|
||||
.execute()
|
||||
|
||||
await manager
|
||||
.getRepository(StudentEntity)
|
||||
.createQueryBuilder()
|
||||
.update(StudentEntity)
|
||||
.set({ score: 0, updated_at: new Date().toISOString() })
|
||||
.execute()
|
||||
|
||||
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
|
||||
|
||||
async getLeaderboard(settlementId: number) {
|
||||
const settlementsRepo = this.ctx.db.dataSource.getRepository(SettlementEntity)
|
||||
const settlement = await settlementsRepo.findOne({ where: { id: settlementId } })
|
||||
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[]
|
||||
const rows = await this.ctx.db.dataSource
|
||||
.getRepository(ScoreEventEntity)
|
||||
.createQueryBuilder('e')
|
||||
.select('e.student_name', 'name')
|
||||
.addSelect('COALESCE(SUM(e.delta), 0)', 'score')
|
||||
.where('e.settlement_id = :settlementId', { settlementId })
|
||||
.groupBy('e.student_name')
|
||||
.orderBy('score', 'DESC')
|
||||
.addOrderBy('name', 'ASC')
|
||||
.getRawMany<settlementLeaderboardRow>()
|
||||
|
||||
return { settlement, rows }
|
||||
return {
|
||||
settlement: {
|
||||
id: settlement.id,
|
||||
start_time: settlement.start_time,
|
||||
end_time: settlement.end_time
|
||||
},
|
||||
rows: rows.map((r: any) => ({ name: String(r.name), score: Number(r.score ?? 0) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +1,90 @@
|
||||
import { Database } from 'better-sqlite3'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
export interface Student {
|
||||
export interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
extra_json?: string
|
||||
}
|
||||
|
||||
export class StudentRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
findAll() {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM students ORDER BY score DESC, name ASC')
|
||||
.all() as Student[]
|
||||
}
|
||||
|
||||
create(student: { name: string }) {
|
||||
const info = this.db.prepare('INSERT INTO students (name) VALUES (?)').run(student.name)
|
||||
return info.lastInsertRowid as number
|
||||
}
|
||||
|
||||
update(id: number, student: Partial<Student>) {
|
||||
const sets: string[] = []
|
||||
const vals: any[] = []
|
||||
Object.entries(student).forEach(([key, val]) => {
|
||||
if (key !== 'id') {
|
||||
sets.push(`${key} = ?`)
|
||||
vals.push(val)
|
||||
}
|
||||
})
|
||||
vals.push(id)
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE students SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`
|
||||
)
|
||||
.run(...vals)
|
||||
}
|
||||
|
||||
delete(id: number) {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
'DELETE FROM score_events WHERE student_name IN (SELECT name FROM students WHERE id = ?)'
|
||||
)
|
||||
.run(id)
|
||||
this.db.prepare('DELETE FROM students WHERE id = ?').run(id)
|
||||
})()
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
students: StudentRepository
|
||||
}
|
||||
}
|
||||
|
||||
export class StudentRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'students')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:student:query', async () => ({
|
||||
success: true,
|
||||
data: await this.findAll()
|
||||
}))
|
||||
this.mainCtx.handle('db:student:create', async (event, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: await this.create(data) }
|
||||
})
|
||||
this.mainCtx.handle('db:student:update', async (event, id, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.update(id, data)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('db:student:delete', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.delete(id)
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(): Promise<student[]> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
|
||||
return (await repo.find({ order: { score: 'DESC', name: 'ASC' } })) as any
|
||||
}
|
||||
|
||||
async create(student: { name: string }): Promise<number> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
|
||||
const created = repo.create({
|
||||
name: String(student?.name ?? '').trim(),
|
||||
score: 0,
|
||||
extra_json: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
const saved = await repo.save(created)
|
||||
return saved.id
|
||||
}
|
||||
|
||||
async update(id: number, student: Partial<student>): Promise<void> {
|
||||
const next: any = {}
|
||||
for (const [key, val] of Object.entries(student)) {
|
||||
if (key === 'id') continue
|
||||
next[key] = val
|
||||
}
|
||||
next.updated_at = new Date().toISOString()
|
||||
await this.ctx.db.dataSource.getRepository(StudentEntity).update(id, next)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
const ds = this.ctx.db.dataSource
|
||||
await ds.transaction(async (manager) => {
|
||||
const studentsRepo = manager.getRepository(StudentEntity)
|
||||
const studentRow = await studentsRepo.findOne({ where: { id } })
|
||||
if (!studentRow) return
|
||||
await manager.getRepository(ScoreEventEntity).delete({ student_name: studentRow.name })
|
||||
await studentsRepo.delete({ id })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { permissionLevel } from './PermissionService'
|
||||
import crypto from 'crypto'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
auth: AuthService
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService extends Service {
|
||||
private SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
||||
private SETTINGS_SECURITY_POINTS = 'security_points_password'
|
||||
private SETTINGS_SECURITY_RECOVERY = 'security_recovery_string'
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'auth')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
const ctx = this.mainCtx
|
||||
|
||||
ctx.handle('auth:getStatus', (event) => {
|
||||
const senderId = event?.sender?.id
|
||||
const permission =
|
||||
typeof senderId === 'number'
|
||||
? ctx.permissions.getPermission(senderId)
|
||||
: ctx.permissions.getDefaultPermission()
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
permission,
|
||||
hasAdminPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN),
|
||||
hasPointsPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_POINTS),
|
||||
hasRecoveryString: ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.handle('auth:login', async (event, password: string) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId !== 'number') return { success: false, message: 'Invalid sender' }
|
||||
if (!ctx.security.isSixDigit(String(password ?? ''))) {
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: false, message: 'Invalid password format' }
|
||||
}
|
||||
|
||||
const adminCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_ADMIN)
|
||||
const pointsCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_POINTS)
|
||||
const adminPlain = await ctx.security.decryptSecret(adminCipher)
|
||||
const pointsPlain = await ctx.security.decryptSecret(pointsCipher)
|
||||
|
||||
if (adminCipher && adminPlain === password) {
|
||||
ctx.permissions.setPermission(senderId, 'admin')
|
||||
return { success: true, data: { permission: 'admin' as permissionLevel } }
|
||||
}
|
||||
if (pointsCipher && pointsPlain === password) {
|
||||
ctx.permissions.setPermission(senderId, 'points')
|
||||
return { success: true, data: { permission: 'points' as permissionLevel } }
|
||||
}
|
||||
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: false, message: 'Password incorrect' }
|
||||
})
|
||||
|
||||
ctx.handle('auth:logout', (event) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true, data: { permission: ctx.permissions.getDefaultPermission() } }
|
||||
})
|
||||
|
||||
ctx.handle(
|
||||
'auth:setPasswords',
|
||||
async (event, payload: { adminPassword?: string | null; pointsPassword?: string | null }) => {
|
||||
const alreadyHasAdmin = ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN)
|
||||
if (alreadyHasAdmin && !ctx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const adminPasswordRaw = payload?.adminPassword
|
||||
const pointsPasswordRaw = payload?.pointsPassword
|
||||
|
||||
if (typeof adminPasswordRaw === 'string') {
|
||||
const trimmed = adminPasswordRaw.trim()
|
||||
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
else {
|
||||
if (!ctx.security.isSixDigit(trimmed))
|
||||
return { success: false, message: 'Admin password must be 6 digits' }
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_ADMIN,
|
||||
await ctx.security.encryptSecret(trimmed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof pointsPasswordRaw === 'string') {
|
||||
const trimmed = pointsPasswordRaw.trim()
|
||||
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
else {
|
||||
if (!ctx.security.isSixDigit(trimmed))
|
||||
return { success: false, message: 'Points password must be 6 digits' }
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_POINTS,
|
||||
await ctx.security.encryptSecret(trimmed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)) {
|
||||
const recovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(recovery)
|
||||
)
|
||||
return { success: true, data: { recoveryString: recovery } }
|
||||
}
|
||||
|
||||
return { success: true, data: {} }
|
||||
}
|
||||
)
|
||||
|
||||
ctx.handle('auth:generateRecovery', async (event) => {
|
||||
if (
|
||||
ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN) &&
|
||||
!ctx.permissions.requirePermission(event, 'admin')
|
||||
)
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const recovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(recovery)
|
||||
)
|
||||
return { success: true, data: { recoveryString: recovery } }
|
||||
})
|
||||
|
||||
ctx.handle('auth:resetByRecovery', async (event, recoveryString: string) => {
|
||||
const cipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_RECOVERY)
|
||||
const plain = await ctx.security.decryptSecret(cipher)
|
||||
if (!plain || plain !== String(recoveryString ?? '').trim())
|
||||
return { success: false, message: 'Recovery string incorrect' }
|
||||
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
|
||||
const newRecovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(newRecovery)
|
||||
)
|
||||
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true, data: { recoveryString: newRecovery } }
|
||||
})
|
||||
|
||||
ctx.handle('auth:clearAll', async (event) => {
|
||||
if (!ctx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_RECOVERY, '')
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { DataBackupRepository } from '../db/backup/DataBackupRepository'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
data: DataService
|
||||
}
|
||||
}
|
||||
|
||||
export class DataService extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'data')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('data:exportJson', async (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const backup = new DataBackupRepository(this.mainCtx.db.dataSource)
|
||||
return {
|
||||
success: true,
|
||||
data: await backup.exportJson()
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('data:importJson', async (event, jsonText: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const backup = new DataBackupRepository(this.mainCtx.db.dataSource)
|
||||
const result = await backup.importJson(jsonText)
|
||||
if (!result.success) return result
|
||||
|
||||
await this.mainCtx.settings.reloadFromDb()
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,184 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import * as winston from 'winston'
|
||||
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
export type logLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
|
||||
export class LoggerService {
|
||||
private logPath: string
|
||||
private currentLevel: LogLevel = 'info'
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
logger: LoggerService
|
||||
}
|
||||
}
|
||||
|
||||
constructor(logDir: string) {
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
this.logPath = path.join(logDir, 'app.log')
|
||||
export class LoggerService extends Service {
|
||||
private logDir: string
|
||||
private currentLevel: logLevel = 'info'
|
||||
private winstonLogger: winston.Logger
|
||||
|
||||
constructor(ctx: MainContext, logDir: string) {
|
||||
super(ctx, 'logger')
|
||||
this.logDir = logDir
|
||||
fs.mkdirSync(this.logDir, { recursive: true })
|
||||
this.winstonLogger = this.createLogger()
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
setLevel(level: LogLevel) {
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('log:query', (_, lines) => ({ success: true, data: this.readLogs(lines) }))
|
||||
this.mainCtx.handle('log:clear', (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
this.clearLogs()
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('log:setLevel', async (event, level: logLevel) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.mainCtx.settings.setValue('log_level', level as any)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('log:write', (_event, payload: any) => {
|
||||
const level = String(payload?.level || 'info')
|
||||
const message = String(payload?.message || '')
|
||||
const meta = payload?.meta
|
||||
if (level === 'debug' || level === 'info' || level === 'warn' || level === 'error') {
|
||||
this.log(level as logLevel, message, { source: 'renderer', meta })
|
||||
} else {
|
||||
this.info(message, { source: 'renderer', meta })
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
setLevel(level: logLevel) {
|
||||
this.currentLevel = level
|
||||
this.winstonLogger.level = level
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
const levels: LogLevel[] = ['debug', 'info', 'warn', 'error']
|
||||
return levels.indexOf(level) >= levels.indexOf(this.currentLevel)
|
||||
}
|
||||
|
||||
log(level: LogLevel, message: string, ...args: any[]) {
|
||||
if (!this.shouldLog(level)) return
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message} ${args.length ? JSON.stringify(args) : ''}\n`
|
||||
|
||||
console.log(formattedMessage.trim())
|
||||
|
||||
try {
|
||||
fs.appendFileSync(this.logPath, formattedMessage)
|
||||
} catch (err) {
|
||||
console.error('Failed to write to log file', err)
|
||||
}
|
||||
log(level: logLevel, message: string, meta?: any) {
|
||||
this.winstonLogger.log(level, message, meta ?? {})
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
this.log('info', message, ...args)
|
||||
this.log('info', message, args.length ? { args } : undefined)
|
||||
}
|
||||
warn(message: string, ...args: any[]) {
|
||||
this.log('warn', message, ...args)
|
||||
this.log('warn', message, args.length ? { args } : undefined)
|
||||
}
|
||||
error(message: string, ...args: any[]) {
|
||||
this.log('error', message, ...args)
|
||||
this.log('error', message, args.length ? { args } : undefined)
|
||||
}
|
||||
debug(message: string, ...args: any[]) {
|
||||
this.log('debug', message, ...args)
|
||||
this.log('debug', message, args.length ? { args } : undefined)
|
||||
}
|
||||
|
||||
getLogPath() {
|
||||
return this.logPath
|
||||
private createLogger(): winston.Logger {
|
||||
const timestampFormat = winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' })
|
||||
const withErrors = winston.format.errors({ stack: true })
|
||||
|
||||
const safeJsonStringify = (value: unknown) => {
|
||||
const seen = new WeakSet<object>()
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (!v || typeof v !== 'object') return v
|
||||
if (seen.has(v)) return '[Circular]'
|
||||
seen.add(v)
|
||||
return v
|
||||
})
|
||||
}
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
timestampFormat,
|
||||
withErrors,
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, stack, ...rest } = info as any
|
||||
const metaText = rest && Object.keys(rest).length ? ` ${safeJsonStringify(rest)}` : ''
|
||||
const stackText = stack ? `\n${String(stack)}` : ''
|
||||
return `${timestamp} ${level} ${String(message)}${metaText}${stackText}`
|
||||
})
|
||||
)
|
||||
|
||||
const fileFormat = winston.format.combine(
|
||||
timestampFormat,
|
||||
withErrors,
|
||||
winston.format.printf((info) => safeJsonStringify(info))
|
||||
)
|
||||
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
filename: path.join(this.logDir, 'secscore-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxFiles: '30d',
|
||||
maxSize: '20m'
|
||||
})
|
||||
rotateTransport.format = fileFormat
|
||||
|
||||
return winston.createLogger({
|
||||
level: this.currentLevel,
|
||||
defaultMeta: { source: 'main', pid: process.pid },
|
||||
transports: [new winston.transports.Console({ format: consoleFormat }), rotateTransport]
|
||||
})
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
try {
|
||||
fs.writeFileSync(this.logPath, '')
|
||||
const files = this.getLogFiles()
|
||||
for (const f of files) {
|
||||
try {
|
||||
fs.unlinkSync(f)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to clear logs', err)
|
||||
this.winstonLogger.log('error', 'Failed to clear logs', {
|
||||
meta: err instanceof Error ? { message: err.message, stack: err.stack } : { err }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
readLogs(lines: number = 100): string[] {
|
||||
try {
|
||||
if (!fs.existsSync(this.logPath)) return []
|
||||
const content = fs.readFileSync(this.logPath, 'utf-8')
|
||||
const allLines = content.split('\n').filter((line) => line.trim().length > 0)
|
||||
return allLines.slice(-lines)
|
||||
const files = this.getLogFiles()
|
||||
const picked: string[] = []
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const filePath = files[i]
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const fileLines = content.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
for (let j = fileLines.length - 1; j >= 0 && picked.length < lines; j--) {
|
||||
picked.push(fileLines[j])
|
||||
}
|
||||
if (picked.length >= lines) break
|
||||
}
|
||||
return picked.reverse()
|
||||
} catch (err) {
|
||||
console.error('Failed to read logs', err)
|
||||
this.winstonLogger.log('error', 'Failed to read logs', {
|
||||
meta: err instanceof Error ? { message: err.message, stack: err.stack } : { err }
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getLogFiles(): string[] {
|
||||
if (!fs.existsSync(this.logDir)) return []
|
||||
const entries = fs.readdirSync(this.logDir)
|
||||
const files = entries.filter((f) => f.endsWith('.log')).map((f) => path.join(this.logDir, f))
|
||||
|
||||
files.sort((a, b) => {
|
||||
try {
|
||||
const sa = fs.statSync(a)
|
||||
const sb = fs.statSync(b)
|
||||
return sa.mtimeMs - sb.mtimeMs
|
||||
} catch {
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
})
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Context, Service } from '../../shared/kernel'
|
||||
|
||||
export type permissionLevel = 'admin' | 'points' | 'view'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
permissions: PermissionService
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionService extends Service {
|
||||
public permissionRank: Record<permissionLevel, number> = { view: 0, points: 1, admin: 2 }
|
||||
private permissionsBySenderId = new Map<number, permissionLevel>()
|
||||
private SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
||||
private SETTINGS_SECURITY_POINTS = 'security_points_password'
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'permissions')
|
||||
}
|
||||
|
||||
shouldProtect() {
|
||||
return (
|
||||
this.ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN) ||
|
||||
this.ctx.security.hasSecret(this.SETTINGS_SECURITY_POINTS)
|
||||
)
|
||||
}
|
||||
|
||||
getDefaultPermission(): permissionLevel {
|
||||
return this.shouldProtect() ? 'view' : 'admin'
|
||||
}
|
||||
|
||||
getPermission(senderId: number): permissionLevel {
|
||||
const existing = this.permissionsBySenderId.get(senderId)
|
||||
if (existing) return existing
|
||||
const def = this.getDefaultPermission()
|
||||
this.permissionsBySenderId.set(senderId, def)
|
||||
return def
|
||||
}
|
||||
|
||||
setPermission(senderId: number, level: permissionLevel) {
|
||||
this.permissionsBySenderId.set(senderId, level)
|
||||
}
|
||||
|
||||
requirePermission(event: any, required: permissionLevel): boolean {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId !== 'number') return false
|
||||
const current = this.getPermission(senderId)
|
||||
return this.permissionRank[current] >= this.permissionRank[required]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { MainContext } from '../context'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
security: SecurityService
|
||||
}
|
||||
}
|
||||
|
||||
export class SecurityService extends Service {
|
||||
private ivKey = 'security_crypto_iv'
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'security')
|
||||
}
|
||||
|
||||
async ensureSecurityIv() {
|
||||
let ivHex = this.ctx.settings.getRaw(this.ivKey)
|
||||
if (!ivHex) {
|
||||
ivHex = crypto.randomBytes(16).toString('hex')
|
||||
await this.ctx.settings.setRaw(this.ivKey, ivHex)
|
||||
}
|
||||
return ivHex
|
||||
}
|
||||
|
||||
getCryptoKey() {
|
||||
return crypto.scryptSync(app.getPath('userData'), 'secscore-salt', 32)
|
||||
}
|
||||
|
||||
async encryptSecret(plainText: string) {
|
||||
const ivHex = await this.ensureSecurityIv()
|
||||
const key = this.getCryptoKey()
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'))
|
||||
let encrypted = cipher.update(plainText, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async decryptSecret(cipherText: string) {
|
||||
try {
|
||||
if (!cipherText) return ''
|
||||
const ivHex = await this.ensureSecurityIv()
|
||||
const key = this.getCryptoKey()
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'))
|
||||
let plain = decipher.update(cipherText, 'hex', 'utf8')
|
||||
plain += decipher.final('utf8')
|
||||
return plain
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
isSixDigit(s: string) {
|
||||
return /^\d{6}$/.test(s)
|
||||
}
|
||||
|
||||
hasSecret(key: string) {
|
||||
const v = this.ctx.settings.getRaw(key)
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { settingsKey, settingsSpec, settingChange } from '../../preload/types'
|
||||
import type { permissionLevel } from './PermissionService'
|
||||
import { SettingEntity } from '../db/entities'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
settings: SettingsService
|
||||
}
|
||||
}
|
||||
|
||||
type settingValueKind = 'string' | 'boolean' | 'number' | 'json'
|
||||
|
||||
type settingDefinition = {
|
||||
kind: settingValueKind
|
||||
defaultValue: unknown
|
||||
readPermission?: permissionLevel | 'any'
|
||||
writePermission?: permissionLevel | 'any'
|
||||
validate?: (value: unknown) => boolean
|
||||
normalize?: (value: unknown) => unknown
|
||||
onChanged?: (ctx: MainContext, next: unknown, prev: unknown) => void
|
||||
}
|
||||
|
||||
export class SettingsService extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'settings')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private definitions: Record<settingsKey, settingDefinition> = {
|
||||
is_wizard_completed: {
|
||||
kind: 'boolean',
|
||||
defaultValue: false,
|
||||
writePermission: 'any'
|
||||
},
|
||||
log_level: {
|
||||
kind: 'string',
|
||||
defaultValue: 'info',
|
||||
writePermission: 'admin',
|
||||
validate: (v) => v === 'debug' || v === 'info' || v === 'warn' || v === 'error',
|
||||
onChanged: (ctx, next) => {
|
||||
ctx.logger.setLevel(next as any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cache = new Map<string, string>()
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise
|
||||
this.initPromise = (async () => {
|
||||
await this.loadCache()
|
||||
await this.ensureDefaults()
|
||||
})()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async loadCache() {
|
||||
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
|
||||
const rows = await repo.find()
|
||||
this.cache.clear()
|
||||
for (const r of rows) this.cache.set(r.key, String(r.value ?? ''))
|
||||
}
|
||||
|
||||
private async ensureDefaults() {
|
||||
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
if (this.cache.has(key)) continue
|
||||
const def = this.definitions[key]
|
||||
const raw = this.serializeValue(key, def.defaultValue)
|
||||
await repo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(SettingEntity)
|
||||
.values({ key, value: raw })
|
||||
.orIgnore()
|
||||
.execute()
|
||||
this.cache.set(key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
private serializeValue(key: settingsKey, value: unknown): string {
|
||||
const def = this.definitions[key]
|
||||
switch (def.kind) {
|
||||
case 'boolean':
|
||||
return value ? '1' : '0'
|
||||
case 'number':
|
||||
return String(value)
|
||||
case 'json':
|
||||
return JSON.stringify(value)
|
||||
case 'string':
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
private deserializeValue(key: settingsKey, raw: string): unknown {
|
||||
const def = this.definitions[key]
|
||||
switch (def.kind) {
|
||||
case 'boolean':
|
||||
return raw === '1' || raw.toLowerCase() === 'true'
|
||||
case 'number':
|
||||
return Number(raw)
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return def.defaultValue
|
||||
}
|
||||
case 'string':
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
private parseValue(key: settingsKey, raw: string | undefined): unknown {
|
||||
const def = this.definitions[key]
|
||||
if (raw == null) return def.defaultValue
|
||||
const deserialized = this.deserializeValue(key, raw)
|
||||
const normalized = def.normalize ? def.normalize(deserialized) : deserialized
|
||||
if (def.validate && !def.validate(normalized)) return def.defaultValue
|
||||
return normalized
|
||||
}
|
||||
|
||||
private canWrite(event: IpcMainInvokeEvent, key: settingsKey): boolean {
|
||||
const required = this.definitions[key].writePermission
|
||||
if (!required || required === 'any') return true
|
||||
return this.mainCtx.permissions.requirePermission(event, required)
|
||||
}
|
||||
|
||||
private notifyChanged(key: settingsKey, value: unknown) {
|
||||
const change: settingChange = { key, value } as any
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
for (const win of windows) {
|
||||
win.webContents.send('settings:changed', change)
|
||||
}
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('settings:getAll', async () => {
|
||||
await this.initialize()
|
||||
return { success: true, data: this.getAll() }
|
||||
})
|
||||
this.mainCtx.handle('settings:get', async (_event, key: settingsKey) => {
|
||||
await this.initialize()
|
||||
return { success: true, data: this.getValue(key) }
|
||||
})
|
||||
this.mainCtx.handle('settings:set', async (event, key: settingsKey, value: any) => {
|
||||
if (!this.canWrite(event, key)) return { success: false, message: 'Permission denied' }
|
||||
await this.setValue(key, value)
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
async reloadFromDb(options: { notify?: boolean } = {}): Promise<void> {
|
||||
await this.initialize()
|
||||
const notify = options.notify ?? true
|
||||
const prev = this.getAll()
|
||||
await this.loadCache()
|
||||
await this.ensureDefaults()
|
||||
if (!notify) return
|
||||
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
const def = this.definitions[key]
|
||||
const next = this.getValue(key as any) as any
|
||||
const prevValue = prev[key]
|
||||
if (next !== prevValue) {
|
||||
def.onChanged?.(this.mainCtx, next, prevValue)
|
||||
this.notifyChanged(key, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRaw(key: string): string {
|
||||
return this.cache.get(key) ?? ''
|
||||
}
|
||||
|
||||
async setRaw(key: string, value: string): Promise<void> {
|
||||
await this.initialize()
|
||||
const prev = this.cache.get(key) ?? ''
|
||||
if (key in this.definitions) {
|
||||
const typedKey = key as settingsKey
|
||||
const def = this.definitions[typedKey]
|
||||
const nextDeserialized = this.deserializeValue(typedKey, value)
|
||||
const nextNormalized = def.normalize ? def.normalize(nextDeserialized) : nextDeserialized
|
||||
const nextRaw =
|
||||
def.validate && !def.validate(nextNormalized)
|
||||
? this.serializeValue(typedKey, def.defaultValue)
|
||||
: this.serializeValue(typedKey, nextNormalized)
|
||||
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: nextRaw })
|
||||
this.cache.set(key, nextRaw)
|
||||
|
||||
const nextTyped = this.parseValue(typedKey, nextRaw)
|
||||
const prevTyped = this.parseValue(typedKey, prev)
|
||||
if (nextTyped !== prevTyped) {
|
||||
def.onChanged?.(this.mainCtx, nextTyped, prevTyped)
|
||||
this.notifyChanged(typedKey, nextTyped)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value })
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
getValue<K extends settingsKey>(key: K): settingsSpec[K] {
|
||||
return this.parseValue(key, this.cache.get(key)) as settingsSpec[K]
|
||||
}
|
||||
|
||||
async setValue<K extends settingsKey>(key: K, value: settingsSpec[K]): Promise<void> {
|
||||
await this.initialize()
|
||||
const def = this.definitions[key]
|
||||
const prev = this.getValue(key)
|
||||
const normalized = def.normalize ? def.normalize(value) : value
|
||||
if (def.validate && !def.validate(normalized)) {
|
||||
throw new Error(`Invalid value for setting: ${String(key)}`)
|
||||
}
|
||||
const raw = this.serializeValue(key, normalized)
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: raw })
|
||||
this.cache.set(key, raw)
|
||||
const next = this.getValue(key)
|
||||
if (next !== prev) {
|
||||
def.onChanged?.(this.mainCtx, next, prev)
|
||||
this.notifyChanged(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
getAllRaw(): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of this.cache.entries()) out[k] = v
|
||||
return out
|
||||
}
|
||||
|
||||
getAll(): settingsSpec {
|
||||
const out: any = {}
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
out[key] = this.getValue(key)
|
||||
}
|
||||
return out as settingsSpec
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { WsClient } from './WsClient'
|
||||
import { DbManager } from '../db/DbManager'
|
||||
|
||||
export class SyncEngine {
|
||||
private wsClient: WsClient
|
||||
private db: DbManager
|
||||
private isSyncing: boolean = false
|
||||
|
||||
constructor(wsClient: WsClient, db: DbManager) {
|
||||
this.wsClient = wsClient
|
||||
this.db = db
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
private init() {
|
||||
// 监听 WS 事件
|
||||
this.wsClient.on('connected', () => {
|
||||
this.syncAll()
|
||||
})
|
||||
|
||||
this.wsClient.on('event', (msg) => {
|
||||
this.handleRemoteEvent(msg)
|
||||
})
|
||||
|
||||
this.wsClient.on('reason_sync', (msg) => {
|
||||
this.handleReasonSync(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// 启动同步任务(处理 Outbox)
|
||||
async startOutboxSync() {
|
||||
if (this.isSyncing) return
|
||||
this.isSyncing = true
|
||||
|
||||
try {
|
||||
// 1. 获取未同步的事件
|
||||
const unsyncedEvents = this.db
|
||||
.getDb()
|
||||
.prepare('SELECT * FROM score_events WHERE sync_state = 0 ORDER BY id ASC')
|
||||
.all() as any[]
|
||||
|
||||
for (const event of unsyncedEvents) {
|
||||
if (!this.wsClient.isConnected()) break
|
||||
|
||||
try {
|
||||
await this.wsClient.send({
|
||||
type: 'score_event',
|
||||
data: {
|
||||
uuid: event.uuid,
|
||||
student_name: event.student_name,
|
||||
reason_content: event.reason_content,
|
||||
delta: event.delta,
|
||||
event_time: event.event_time
|
||||
}
|
||||
})
|
||||
|
||||
// 标记为已同步
|
||||
this.db
|
||||
.getDb()
|
||||
.prepare('UPDATE score_events SET sync_state = 1 WHERE id = ?')
|
||||
.run(event.id)
|
||||
} catch (e) {
|
||||
console.error(`[Sync] Failed to sync event ${event.uuid}`, e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触发全量同步
|
||||
async triggerFullSync() {
|
||||
await this.syncAll()
|
||||
}
|
||||
|
||||
// 全量对齐
|
||||
private async syncAll() {
|
||||
if (!this.wsClient.isConnected()) return
|
||||
|
||||
console.log('[Sync] Starting full sync...')
|
||||
try {
|
||||
// 1. 同步理由
|
||||
const resReasons = await this.wsClient.send({ type: 'query_reasons' })
|
||||
if (resReasons.data) {
|
||||
this.handleReasonSync(resReasons)
|
||||
}
|
||||
|
||||
// 2. 同步学生分值 (Local-first: 本地值为准,除非服务器有更正)
|
||||
await this.wsClient.send({ type: 'query_students' })
|
||||
// TODO: 实现更复杂的对齐逻辑
|
||||
|
||||
// 3. 处理 Outbox
|
||||
await this.startOutboxSync()
|
||||
|
||||
console.log('[Sync] Full sync completed')
|
||||
} catch (e) {
|
||||
console.error('[Sync] Full sync failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteEvent(msg: any) {
|
||||
// 远程推送的事件,通常是其他客户端的操作
|
||||
// 本地需要根据此事件更新分值,但不要再产生新的同步事件(避免循环)
|
||||
const { data } = msg
|
||||
if (!data) return
|
||||
|
||||
this.db.getDb().transaction(() => {
|
||||
// 更新学生分值
|
||||
this.db
|
||||
.getDb()
|
||||
.prepare(
|
||||
'UPDATE students SET score = score + ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
|
||||
)
|
||||
.run(data.delta, data.student_name)
|
||||
|
||||
// 记录事件(标记为已同步,防止回环)
|
||||
this.db
|
||||
.getDb()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state)
|
||||
SELECT ?, ?, ?, ?, score - ?, score, ?, 1 FROM students WHERE name = ?
|
||||
`
|
||||
)
|
||||
.run(
|
||||
data.uuid,
|
||||
data.student_name,
|
||||
data.reason_content,
|
||||
data.delta,
|
||||
data.delta,
|
||||
data.event_time || new Date().toISOString(),
|
||||
data.student_name
|
||||
)
|
||||
})()
|
||||
}
|
||||
|
||||
private handleReasonSync(msg: any) {
|
||||
const reasons = msg.data
|
||||
if (!Array.isArray(reasons)) return
|
||||
|
||||
this.db.getDb().transaction(() => {
|
||||
for (const r of reasons) {
|
||||
// 如果是系统理由,且 category 匹配,则归入 __config__ 组 (按照协议要求)
|
||||
const category = r.is_system ? '__config__' : r.category
|
||||
|
||||
this.db
|
||||
.getDb()
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO reasons (content, category, delta, is_system)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(content) DO UPDATE SET
|
||||
category = excluded.category,
|
||||
delta = excluded.delta
|
||||
`
|
||||
)
|
||||
.run(r.content, category, r.delta, r.is_system ? 1 : 0)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { FSWatcher, watch } from 'chokidar'
|
||||
|
||||
export interface ThemeConfig {
|
||||
export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
@@ -13,20 +15,34 @@ export interface ThemeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export class ThemeService {
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
themes: ThemeService
|
||||
}
|
||||
}
|
||||
|
||||
export class ThemeService extends Service {
|
||||
private themeDir: string
|
||||
private watcher: FSWatcher | null = null
|
||||
private currentThemeId: string = 'light-default'
|
||||
private canSetTheme: ((senderId: number) => boolean) | null = null
|
||||
|
||||
constructor(themeDir: string, canSetTheme?: (senderId: number) => boolean) {
|
||||
constructor(ctx: MainContext, themeDir: string) {
|
||||
super(ctx, 'themes')
|
||||
this.themeDir = themeDir
|
||||
this.canSetTheme = canSetTheme || null
|
||||
this.setupWatcher()
|
||||
this.registerIpc()
|
||||
|
||||
ctx.effect(() => {
|
||||
if (this.watcher) this.watcher.close()
|
||||
})
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public init() {
|
||||
this.setupWatcher()
|
||||
this.registerIpc()
|
||||
// Already inited in constructor
|
||||
}
|
||||
|
||||
private setupWatcher() {
|
||||
@@ -39,26 +55,27 @@ export class ThemeService {
|
||||
|
||||
this.watcher.on('change', (filePath) => {
|
||||
if (filePath.endsWith('.json')) {
|
||||
console.log(`Theme file changed: ${filePath}`)
|
||||
this.mainCtx.logger.info('Theme file changed', { filePath })
|
||||
this.notifyThemeUpdate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
ipcMain.handle('theme:list', async () => {
|
||||
this.mainCtx.handle('theme:list', async () => {
|
||||
return { success: true, data: this.getThemeList() }
|
||||
})
|
||||
|
||||
ipcMain.handle('theme:current', async () => {
|
||||
this.mainCtx.handle('theme:current', async () => {
|
||||
const theme = this.getThemeById(this.currentThemeId)
|
||||
return { success: true, data: theme }
|
||||
})
|
||||
|
||||
ipcMain.handle('theme:set', async (event, themeId: string) => {
|
||||
this.mainCtx.handle('theme:set', async (event, themeId: string) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (this.canSetTheme && typeof senderId === 'number') {
|
||||
if (!this.canSetTheme(senderId)) return { success: false, message: 'Permission denied' }
|
||||
if (typeof senderId === 'number') {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
}
|
||||
this.currentThemeId = themeId
|
||||
this.notifyThemeUpdate()
|
||||
@@ -66,7 +83,7 @@ export class ThemeService {
|
||||
})
|
||||
}
|
||||
|
||||
private getThemeList(): ThemeConfig[] {
|
||||
private getThemeList(): themeConfig[] {
|
||||
try {
|
||||
if (!fs.existsSync(this.themeDir)) return []
|
||||
const files = fs.readdirSync(this.themeDir)
|
||||
@@ -75,19 +92,21 @@ export class ThemeService {
|
||||
.map((f) => {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8')
|
||||
return JSON.parse(content) as ThemeConfig
|
||||
return JSON.parse(content) as themeConfig
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((t): t is ThemeConfig => t !== null)
|
||||
.filter((t): t is themeConfig => t !== null)
|
||||
} catch (e) {
|
||||
console.error('Failed to read themes:', e)
|
||||
this.mainCtx.logger.error('Failed to read themes', {
|
||||
meta: e instanceof Error ? { message: e.message, stack: e.stack } : { e }
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getThemeById(id: string): ThemeConfig | null {
|
||||
private getThemeById(id: string): themeConfig | null {
|
||||
const list = this.getThemeList()
|
||||
return list.find((t) => t.id === id) || list[0] || null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow, shell } from 'electron'
|
||||
import type { BrowserWindowConstructorOptions } from 'electron'
|
||||
|
||||
export type windowOpenInput = {
|
||||
key: string
|
||||
title?: string
|
||||
route?: string
|
||||
options?: BrowserWindowConstructorOptions
|
||||
}
|
||||
|
||||
export type windowManagerOptions = {
|
||||
icon: any
|
||||
preloadPath: string
|
||||
rendererHtmlPath: string
|
||||
getRendererUrl: () => string | undefined
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
windows: WindowManager
|
||||
}
|
||||
}
|
||||
|
||||
export class WindowManager extends Service {
|
||||
private readonly windows = new Map<string, BrowserWindow>()
|
||||
|
||||
constructor(
|
||||
ctx: MainContext,
|
||||
private readonly opts: windowManagerOptions
|
||||
) {
|
||||
super(ctx, 'windows')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public get(key: string) {
|
||||
const existing = this.windows.get(key)
|
||||
if (!existing) return null
|
||||
if (existing.isDestroyed()) {
|
||||
this.windows.delete(key)
|
||||
return null
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
public open(input: windowOpenInput) {
|
||||
const existing = this.get(input.key)
|
||||
if (existing) {
|
||||
if (input.route) void this.loadRoute(existing, input.route)
|
||||
existing.show()
|
||||
existing.focus()
|
||||
return existing
|
||||
}
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
icon: this.opts.icon,
|
||||
title: input.title,
|
||||
webPreferences: {
|
||||
preload: this.opts.preloadPath,
|
||||
sandbox: false
|
||||
},
|
||||
...input.options
|
||||
})
|
||||
|
||||
this.windows.set(input.key, win)
|
||||
win.on('closed', () => {
|
||||
this.windows.delete(input.key)
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
void this.loadRoute(win, input.route ?? '/')
|
||||
return win
|
||||
}
|
||||
|
||||
public navigate(key: string, route: string) {
|
||||
const win = this.get(key)
|
||||
if (!win) return false
|
||||
void this.loadRoute(win, route)
|
||||
return true
|
||||
}
|
||||
|
||||
public navigateWindow(win: BrowserWindow, route: string) {
|
||||
if (win.isDestroyed()) return false
|
||||
void this.loadRoute(win, route)
|
||||
return true
|
||||
}
|
||||
|
||||
private async loadRoute(win: BrowserWindow, route: string) {
|
||||
const normalizedRoute = route.startsWith('/') ? route : `/${route}`
|
||||
const rendererUrl = this.opts.getRendererUrl()
|
||||
|
||||
if (rendererUrl) {
|
||||
await win.loadURL(`${rendererUrl}#${normalizedRoute}`)
|
||||
return
|
||||
}
|
||||
|
||||
await win.loadFile(this.opts.rendererHtmlPath, { hash: normalizedRoute })
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('window:open', async (_event, input: any) => {
|
||||
const key = String(input?.key ?? '').trim()
|
||||
if (!key) return { success: false, message: 'Missing key' }
|
||||
this.open({
|
||||
key,
|
||||
title: input?.title ? String(input.title) : undefined,
|
||||
route: input?.route ? String(input.route) : undefined,
|
||||
options: input?.options
|
||||
})
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:navigate', async (event, input: any) => {
|
||||
const route = String(input?.route ?? '').trim()
|
||||
if (!route) return { success: false, message: 'Missing route' }
|
||||
|
||||
const key = input?.key ? String(input.key).trim() : ''
|
||||
if (key) {
|
||||
const ok = this.navigate(key, route)
|
||||
return ok ? { success: true } : { success: false, message: 'Window not found' }
|
||||
}
|
||||
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return { success: false, message: 'Window not found' }
|
||||
const ok = this.navigateWindow(win, route)
|
||||
return ok ? { success: true } : { success: false, message: 'Window not found' }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import WebSocket from 'ws'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
export class WsClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string = ''
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null
|
||||
private pendingRequests: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: any) => void
|
||||
reject: (reason?: any) => void
|
||||
timeout: NodeJS.Timeout
|
||||
}
|
||||
> = new Map()
|
||||
private isManualClose: boolean = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
connect(url: string) {
|
||||
this.url = url
|
||||
this.isManualClose = false
|
||||
this._connect()
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.ws) {
|
||||
this.ws.terminate()
|
||||
}
|
||||
|
||||
console.log(`[WS] Connecting to ${this.url}...`)
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('[WS] Connected')
|
||||
this.emit('connected')
|
||||
this.startHeartbeat()
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString())
|
||||
this.handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('[WS] Parse error', e)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('[WS] Closed')
|
||||
this.stopHeartbeat()
|
||||
this.emit('disconnected')
|
||||
if (!this.isManualClose) {
|
||||
this.reconnect()
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('[WS] Error', err)
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(msg: any) {
|
||||
// 1. 处理请求响应 (seq 匹配)
|
||||
if (msg.seq && this.pendingRequests.has(msg.seq)) {
|
||||
const { resolve, timeout } = this.pendingRequests.get(msg.seq)!
|
||||
clearTimeout(timeout)
|
||||
this.pendingRequests.delete(msg.seq)
|
||||
resolve(msg)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理服务器主动推送 (事件)
|
||||
if (msg.type === 'event' || msg.type === 'correction') {
|
||||
this.emit('event', msg)
|
||||
} else if (msg.type === 'reason_sync') {
|
||||
this.emit('reason_sync', msg)
|
||||
}
|
||||
}
|
||||
|
||||
private reconnect() {
|
||||
if (this.reconnectTimer) return
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this._connect()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send({ type: 'heartbeat' }).catch(() => {})
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
send(data: any, timeoutMs: number = 5000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return reject(new Error('WebSocket not connected'))
|
||||
}
|
||||
|
||||
const seq = uuidv4()
|
||||
const payload = { ...data, seq }
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(seq)
|
||||
reject(new Error('Request timeout'))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pendingRequests.set(seq, { resolve, reject, timeout })
|
||||
this.ws.send(JSON.stringify(payload))
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isManualClose = true
|
||||
this.stopHeartbeat()
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
Vendored
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { ElectronApi } from './types'
|
||||
import { electronApi } from './types'
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: ElectronApi
|
||||
api: electronApi
|
||||
}
|
||||
}
|
||||
|
||||
+30
-8
@@ -1,14 +1,14 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { ThemeConfig } from './types'
|
||||
import { settingChange, settingsKey, settingsSpec, themeConfig } from './types'
|
||||
|
||||
const api = {
|
||||
// Theme
|
||||
getThemes: () => ipcRenderer.invoke('theme:list'),
|
||||
getCurrentTheme: () => ipcRenderer.invoke('theme:current'),
|
||||
setTheme: (themeId: string) => ipcRenderer.invoke('theme:set', themeId),
|
||||
onThemeChanged: (callback: (theme: ThemeConfig) => void) => {
|
||||
const subscription = (_event: any, theme: ThemeConfig) => callback(theme)
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => {
|
||||
const subscription = (_event: any, theme: themeConfig) => callback(theme)
|
||||
ipcRenderer.on('theme:updated', subscription)
|
||||
return () => ipcRenderer.removeListener('theme:updated', subscription)
|
||||
},
|
||||
@@ -39,10 +39,15 @@ const api = {
|
||||
ipcRenderer.invoke('db:settlement:leaderboard', params),
|
||||
|
||||
// Settings & Sync
|
||||
getSettings: () => ipcRenderer.invoke('db:getSettings'),
|
||||
updateSetting: (key: string, value: string) => ipcRenderer.invoke('db:updateSetting', key, value),
|
||||
getSyncStatus: () => ipcRenderer.invoke('ws:getStatus'),
|
||||
triggerSync: () => ipcRenderer.invoke('ws:triggerSync'),
|
||||
getAllSettings: () => ipcRenderer.invoke('settings:getAll'),
|
||||
getSetting: <K extends settingsKey>(key: K) => ipcRenderer.invoke('settings:get', key),
|
||||
setSetting: <K extends settingsKey>(key: K, value: settingsSpec[K]) =>
|
||||
ipcRenderer.invoke('settings:set', key, value),
|
||||
onSettingChanged: (callback: (change: settingChange) => void) => {
|
||||
const subscription = (_event: any, change: settingChange) => callback(change)
|
||||
ipcRenderer.on('settings:changed', subscription)
|
||||
return () => ipcRenderer.removeListener('settings:changed', subscription)
|
||||
},
|
||||
|
||||
// Auth & Security
|
||||
authGetStatus: () => ipcRenderer.invoke('auth:getStatus'),
|
||||
@@ -59,6 +64,12 @@ const api = {
|
||||
exportDataJson: () => ipcRenderer.invoke('data:exportJson'),
|
||||
importDataJson: (jsonText: string) => ipcRenderer.invoke('data:importJson', jsonText),
|
||||
|
||||
// Window
|
||||
openWindow: (input: { key: string; title?: string; route?: string; options?: any }) =>
|
||||
ipcRenderer.invoke('window:open', input),
|
||||
navigateWindow: (input: { key?: string; route: string }) =>
|
||||
ipcRenderer.invoke('window:navigate', input),
|
||||
|
||||
// Logger
|
||||
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
|
||||
clearLogs: () => ipcRenderer.invoke('log:clear'),
|
||||
@@ -72,7 +83,18 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
try {
|
||||
ipcRenderer.invoke('log:write', {
|
||||
level: 'error',
|
||||
message: 'preload:expose failed',
|
||||
meta:
|
||||
error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: { error: String(error) }
|
||||
})
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
|
||||
+71
-45
@@ -1,10 +1,13 @@
|
||||
export interface IpcResponse<T = any> {
|
||||
export interface ipcResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
export type logLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
export type permissionLevel = 'admin' | 'points' | 'view'
|
||||
|
||||
export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
@@ -14,90 +17,113 @@ export interface ThemeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
export type settingsSpec = {
|
||||
is_wizard_completed: boolean
|
||||
log_level: logLevel
|
||||
}
|
||||
|
||||
export type settingsKey = keyof settingsSpec
|
||||
|
||||
export type settingChange<K extends settingsKey = settingsKey> = {
|
||||
key: K
|
||||
value: settingsSpec[K]
|
||||
}
|
||||
|
||||
export interface electronApi {
|
||||
// Theme
|
||||
getThemes: () => Promise<IpcResponse<ThemeConfig[]>>
|
||||
getCurrentTheme: () => Promise<IpcResponse<ThemeConfig>>
|
||||
setTheme: (themeId: string) => Promise<IpcResponse<void>>
|
||||
onThemeChanged: (callback: (theme: ThemeConfig) => void) => () => void
|
||||
getThemes: () => Promise<ipcResponse<themeConfig[]>>
|
||||
getCurrentTheme: () => Promise<ipcResponse<themeConfig>>
|
||||
setTheme: (themeId: string) => Promise<ipcResponse<void>>
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => () => void
|
||||
|
||||
// DB - Student
|
||||
queryStudents: (params?: any) => Promise<IpcResponse<any[]>>
|
||||
createStudent: (data: { name: string }) => Promise<IpcResponse<number>>
|
||||
updateStudent: (id: number, data: any) => Promise<IpcResponse<void>>
|
||||
deleteStudent: (id: number) => Promise<IpcResponse<void>>
|
||||
queryStudents: (params?: any) => Promise<ipcResponse<any[]>>
|
||||
createStudent: (data: { name: string }) => Promise<ipcResponse<number>>
|
||||
updateStudent: (id: number, data: any) => Promise<ipcResponse<void>>
|
||||
deleteStudent: (id: number) => Promise<ipcResponse<void>>
|
||||
|
||||
// DB - Reason
|
||||
queryReasons: () => Promise<IpcResponse<any[]>>
|
||||
createReason: (data: any) => Promise<IpcResponse<number>>
|
||||
updateReason: (id: number, data: any) => Promise<IpcResponse<void>>
|
||||
deleteReason: (id: number) => Promise<IpcResponse<void>>
|
||||
queryReasons: () => Promise<ipcResponse<any[]>>
|
||||
createReason: (data: any) => Promise<ipcResponse<number>>
|
||||
updateReason: (id: number, data: any) => Promise<ipcResponse<void>>
|
||||
deleteReason: (id: number) => Promise<ipcResponse<void>>
|
||||
|
||||
// DB - Event
|
||||
queryEvents: (params?: any) => Promise<IpcResponse<any[]>>
|
||||
queryEvents: (params?: any) => Promise<ipcResponse<any[]>>
|
||||
createEvent: (data: {
|
||||
student_name: string
|
||||
reason_content: string
|
||||
delta: number
|
||||
}) => Promise<IpcResponse<number>>
|
||||
deleteEvent: (uuid: string) => Promise<IpcResponse<void>>
|
||||
}) => Promise<ipcResponse<number>>
|
||||
deleteEvent: (uuid: string) => Promise<ipcResponse<void>>
|
||||
queryEventsByStudent: (params: {
|
||||
student_name: string
|
||||
limit?: number
|
||||
startTime?: string | null
|
||||
}) => Promise<IpcResponse<any[]>>
|
||||
}) => Promise<ipcResponse<any[]>>
|
||||
queryLeaderboard: (params: {
|
||||
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 }>>
|
||||
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<{
|
||||
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>>
|
||||
getSyncStatus: () => Promise<IpcResponse<{ connected: boolean; lastSync?: string }>>
|
||||
triggerSync: () => Promise<IpcResponse<void>>
|
||||
// Settings
|
||||
getAllSettings: () => Promise<ipcResponse<settingsSpec>>
|
||||
getSetting: <K extends settingsKey>(key: K) => Promise<ipcResponse<settingsSpec[K]>>
|
||||
setSetting: <K extends settingsKey>(key: K, value: settingsSpec[K]) => Promise<ipcResponse<void>>
|
||||
onSettingChanged: (callback: (change: settingChange) => void) => () => void
|
||||
|
||||
// Auth & Security
|
||||
authGetStatus: () => Promise<
|
||||
IpcResponse<{
|
||||
permission: 'admin' | 'points' | 'view'
|
||||
ipcResponse<{
|
||||
permission: permissionLevel
|
||||
hasAdminPassword: boolean
|
||||
hasPointsPassword: boolean
|
||||
hasRecoveryString: boolean
|
||||
}>
|
||||
>
|
||||
authLogin: (password: string) => Promise<IpcResponse<{ permission: 'admin' | 'points' | 'view' }>>
|
||||
authLogout: () => Promise<IpcResponse<{ permission: 'admin' | 'points' | 'view' }>>
|
||||
authLogin: (password: string) => Promise<ipcResponse<{ permission: permissionLevel }>>
|
||||
authLogout: () => Promise<ipcResponse<{ permission: permissionLevel }>>
|
||||
authSetPasswords: (payload: {
|
||||
adminPassword?: string | null
|
||||
pointsPassword?: string | null
|
||||
}) => Promise<IpcResponse<{ recoveryString?: string }>>
|
||||
authGenerateRecovery: () => Promise<IpcResponse<{ recoveryString: string }>>
|
||||
authResetByRecovery: (recoveryString: string) => Promise<IpcResponse<{ recoveryString: string }>>
|
||||
authClearAll: () => Promise<IpcResponse<void>>
|
||||
}) => Promise<ipcResponse<{ recoveryString?: string }>>
|
||||
authGenerateRecovery: () => Promise<ipcResponse<{ recoveryString: string }>>
|
||||
authResetByRecovery: (recoveryString: string) => Promise<ipcResponse<{ recoveryString: string }>>
|
||||
authClearAll: () => Promise<ipcResponse<void>>
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: () => Promise<IpcResponse<string>>
|
||||
importDataJson: (jsonText: string) => Promise<IpcResponse<void>>
|
||||
exportDataJson: () => Promise<ipcResponse<string>>
|
||||
importDataJson: (jsonText: string) => Promise<ipcResponse<void>>
|
||||
|
||||
// Window
|
||||
openWindow: (input: {
|
||||
key: string
|
||||
title?: string
|
||||
route?: string
|
||||
options?: any
|
||||
}) => Promise<ipcResponse<void>>
|
||||
navigateWindow: (input: { key?: string; route: string }) => Promise<ipcResponse<void>>
|
||||
|
||||
// Logger
|
||||
queryLogs: (lines?: number) => Promise<IpcResponse<string[]>>
|
||||
clearLogs: () => Promise<IpcResponse<void>>
|
||||
setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') => Promise<IpcResponse<void>>
|
||||
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
|
||||
clearLogs: () => Promise<ipcResponse<void>>
|
||||
setLogLevel: (level: logLevel) => Promise<ipcResponse<void>>
|
||||
writeLog: (payload: {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
level: logLevel
|
||||
message: string
|
||||
meta?: any
|
||||
}) => Promise<IpcResponse<void>>
|
||||
}) => Promise<ipcResponse<void>>
|
||||
}
|
||||
|
||||
+48
-29
@@ -1,6 +1,7 @@
|
||||
import { Layout, Menu, Space, Dialog, Input, Button, Tag, MessagePlugin } from 'tdesign-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { StudentManager } from './components/StudentManager'
|
||||
import { Settings } from './components/Settings'
|
||||
import { ReasonManager } from './components/ReasonManager'
|
||||
@@ -13,7 +14,9 @@ import { ThemeProvider } from './contexts/ThemeContext'
|
||||
const { Header, Content, Aside } = Layout
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const [activeMenu, setActiveMenu] = useState('score')
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const [wizardVisible, setWizardVisible] = useState(false)
|
||||
const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view')
|
||||
const [hasAnyPassword, setHasAnyPassword] = useState(false)
|
||||
@@ -21,11 +24,22 @@ function MainContent(): React.JSX.Element {
|
||||
const [authPassword, setAuthPassword] = useState('')
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const activeMenu = useMemo(() => {
|
||||
const p = location.pathname
|
||||
if (p.startsWith('/students')) return 'students'
|
||||
if (p.startsWith('/score')) return 'score'
|
||||
if (p.startsWith('/leaderboard')) return 'leaderboard'
|
||||
if (p.startsWith('/settlements')) return 'settlements'
|
||||
if (p.startsWith('/reasons')) return 'reasons'
|
||||
if (p.startsWith('/settings')) return 'settings'
|
||||
return 'score'
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const checkWizard = async () => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.getSettings()
|
||||
if (res.success && res.data && res.data.is_wizard_completed !== '1') {
|
||||
const res = await (window as any).api.getAllSettings()
|
||||
if (res.success && res.data && !res.data.is_wizard_completed) {
|
||||
setWizardVisible(true)
|
||||
}
|
||||
}
|
||||
@@ -71,23 +85,14 @@ function MainContent(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeMenu) {
|
||||
case 'students':
|
||||
return <StudentManager canEdit={permission === 'admin'} />
|
||||
case 'score':
|
||||
return <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
|
||||
case 'leaderboard':
|
||||
return <Leaderboard />
|
||||
case 'settlements':
|
||||
return <SettlementHistory />
|
||||
case 'reasons':
|
||||
return <ReasonManager canEdit={permission === 'admin'} />
|
||||
case 'settings':
|
||||
return <Settings permission={permission} />
|
||||
default:
|
||||
return <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
|
||||
}
|
||||
const onMenuChange = (v: string | number) => {
|
||||
const key = String(v)
|
||||
if (key === 'students') navigate('/students')
|
||||
if (key === 'score') navigate('/score')
|
||||
if (key === 'leaderboard') navigate('/leaderboard')
|
||||
if (key === 'settlements') navigate('/settlements')
|
||||
if (key === 'reasons') navigate('/reasons')
|
||||
if (key === 'settings') navigate('/settings')
|
||||
}
|
||||
|
||||
const permissionTag = (
|
||||
@@ -109,7 +114,9 @@ function MainContent(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<h2 style={{ color: 'var(--ss-sidebar-text, 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',
|
||||
@@ -119,11 +126,7 @@ function MainContent(): React.JSX.Element {
|
||||
教育积分管理
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
value={activeMenu}
|
||||
onChange={(v) => setActiveMenu(v as string)}
|
||||
style={{ width: '100%', border: 'none' }}
|
||||
>
|
||||
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
|
||||
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
|
||||
学生管理
|
||||
</Menu.MenuItem>
|
||||
@@ -169,7 +172,21 @@ function MainContent(): React.JSX.Element {
|
||||
)}
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ overflowY: 'auto' }}>{renderContent()}</Content>
|
||||
<Content style={{ overflowY: 'auto' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/score" replace />} />
|
||||
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
|
||||
<Route
|
||||
path="/score"
|
||||
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
|
||||
/>
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/score" replace />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||
|
||||
@@ -199,7 +216,9 @@ function MainContent(): React.JSX.Element {
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<MainContent />
|
||||
<HashRouter>
|
||||
<MainContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Context } from '../../shared/kernel'
|
||||
|
||||
export class ClientContext extends Context {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,44 @@ body,
|
||||
}
|
||||
|
||||
.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--active,
|
||||
.ss-sidebar .t-menu__item.t-is-active,
|
||||
.ss-sidebar .t-menu__item--active .t-menu__item-icon,
|
||||
.ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
|
||||
.ss-sidebar .t-menu__item--active .t-menu__content,
|
||||
.ss-sidebar .t-menu__item.t-is-active .t-menu__content {
|
||||
color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main)));
|
||||
}
|
||||
|
||||
.ss-sidebar .t-menu__item--active,
|
||||
.ss-sidebar .t-menu__item.t-is-active {
|
||||
background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important;
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active,
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active,
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__item-icon,
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__item-icon,
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active .t-menu__content,
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active .t-menu__content {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg[fill='none'],
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg[fill='none'] {
|
||||
stroke: currentColor !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item--active svg:not([fill='none']),
|
||||
html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='none']) {
|
||||
fill: currentColor !important;
|
||||
}
|
||||
|
||||
.ss-sidebar .t-menu__item:hover {
|
||||
color: var(--ss-sidebar-text, var(--ss-text-main));
|
||||
}
|
||||
@@ -49,3 +82,11 @@ body,
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.ss-table-center .t-table th,
|
||||
.ss-table-center .t-table td,
|
||||
.ss-table-center .t-table th .t-table__cell,
|
||||
.ss-table-center .t-table td .t-table__cell {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, PrimaryTableCol, Tag } from 'tdesign-react'
|
||||
|
||||
interface ScoreEvent {
|
||||
interface scoreEvent {
|
||||
id: number
|
||||
uuid: string
|
||||
student_name: string
|
||||
@@ -13,7 +13,7 @@ interface ScoreEvent {
|
||||
}
|
||||
|
||||
export const EventHistory: React.FC = () => {
|
||||
const [data, setData] = useState<ScoreEvent[]>([])
|
||||
const [data, setData] = useState<scoreEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchEvents = async () => {
|
||||
@@ -30,7 +30,7 @@ export const EventHistory: React.FC = () => {
|
||||
fetchEvents()
|
||||
}, [])
|
||||
|
||||
const columns: PrimaryTableCol<ScoreEvent>[] = [
|
||||
const columns: PrimaryTableCol<scoreEvent>[] = [
|
||||
{ colKey: 'student_name', title: '学生姓名', width: 120 },
|
||||
{ colKey: 'reason_content', title: '积分理由', width: 200 },
|
||||
{
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
Space,
|
||||
Card,
|
||||
MessagePlugin,
|
||||
DialogPlugin
|
||||
Dialog
|
||||
} from 'tdesign-react'
|
||||
import { ViewListIcon, DownloadIcon } from 'tdesign-icons-react'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
interface StudentRank {
|
||||
interface studentRank {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
@@ -20,10 +21,13 @@ interface StudentRank {
|
||||
}
|
||||
|
||||
export const Leaderboard: React.FC = () => {
|
||||
const [data, setData] = useState<StudentRank[]>([])
|
||||
const [data, setData] = useState<studentRank[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timeRange, setTimeRange] = useState('today')
|
||||
const [startTime, setStartTime] = useState<string | null>(null)
|
||||
const [historyVisible, setHistoryVisible] = useState(false)
|
||||
const [historyHeader, setHistoryHeader] = useState('')
|
||||
const [historyText, setHistoryText] = useState('')
|
||||
|
||||
const fetchRankings = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
@@ -67,76 +71,57 @@ export const Leaderboard: React.FC = () => {
|
||||
return `${time} ${delta} ${e.reason_content}`
|
||||
})
|
||||
|
||||
DialogPlugin.confirm({
|
||||
header: `${studentName} - 操作记录`,
|
||||
body: (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: '10px'
|
||||
}}
|
||||
>
|
||||
{lines.join('\n') || '暂无记录'}
|
||||
</div>
|
||||
),
|
||||
width: '80%',
|
||||
cancelBtn: null,
|
||||
confirmBtn: '关闭'
|
||||
})
|
||||
setHistoryHeader(`${studentName} - 操作记录`)
|
||||
setHistoryText(lines.join('\n') || '暂无记录')
|
||||
setHistoryVisible(true)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
// 简单的 CSV 导出实现
|
||||
const headers = ['排名', '姓名', '总积分', '变化']
|
||||
const rows = data.map((item, index) => [
|
||||
index + 1,
|
||||
item.name,
|
||||
item.score,
|
||||
item.range_change > 0 ? `+${item.range_change}` : item.range_change
|
||||
])
|
||||
|
||||
const csvContent = [headers, ...rows].map((e) => e.join(',')).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `排行榜_${timeRange}_${new Date().toLocaleDateString()}.csv`)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
MessagePlugin.success('导出成功')
|
||||
}
|
||||
|
||||
const handleExportExcel = () => {
|
||||
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
|
||||
const rowsHtml = data
|
||||
.map((item, index) => {
|
||||
const change = item.range_change > 0 ? `+${item.range_change}` : item.range_change
|
||||
return `<tr><td>${index + 1}</td><td>${item.name}</td><td>${item.score}</td><td>${change}</td></tr>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const html = `\ufeff<html><head><meta charset="UTF-8" /></head><body><table border="1"><tr><th>排名</th><th>姓名</th><th>总积分</th><th>${title}变化</th></tr>${rowsHtml}</table></body></html>`
|
||||
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8;' })
|
||||
const sanitizeCell = (v: unknown) => {
|
||||
if (typeof v !== 'string') return v
|
||||
if (/^[=+\-@]/.test(v)) return `'${v}`
|
||||
return v
|
||||
}
|
||||
|
||||
const sheetData = [
|
||||
['排名', '姓名', '总积分', `${title}变化`],
|
||||
...data.map((item, index) => [
|
||||
index + 1,
|
||||
sanitizeCell(item.name),
|
||||
item.score,
|
||||
item.range_change
|
||||
])
|
||||
]
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
||||
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '排行榜')
|
||||
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([xlsxBytes], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', `排行榜_${timeRange}_${new Date().toLocaleDateString()}.xls`)
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
MessagePlugin.success('导出成功')
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<StudentRank>[] = [
|
||||
const columns: PrimaryTableCol<studentRank>[] = [
|
||||
{
|
||||
colKey: 'rank',
|
||||
title: '排名',
|
||||
@@ -155,17 +140,19 @@ export const Leaderboard: React.FC = () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
{ colKey: 'name', title: '姓名', width: 120 },
|
||||
{ colKey: 'name', title: '姓名', width: 120, align: 'center' },
|
||||
{
|
||||
colKey: 'score',
|
||||
title: '总积分',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
|
||||
},
|
||||
{
|
||||
colKey: 'range_change',
|
||||
title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cell: ({ row }) => (
|
||||
<Tag
|
||||
theme={row.range_change > 0 ? 'success' : row.range_change < 0 ? 'danger' : 'default'}
|
||||
@@ -179,6 +166,7 @@ export const Leaderboard: React.FC = () => {
|
||||
colKey: 'operation',
|
||||
title: '操作记录',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="text"
|
||||
@@ -214,10 +202,7 @@ export const Leaderboard: React.FC = () => {
|
||||
<Select.Option value="month" label="本月" />
|
||||
</Select>
|
||||
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExport}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExportExcel}>
|
||||
导出 Excel
|
||||
导出 XLSX
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -230,9 +215,36 @@ export const Leaderboard: React.FC = () => {
|
||||
loading={loading}
|
||||
bordered
|
||||
hover
|
||||
className="ss-table-center"
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
header={historyHeader}
|
||||
visible={historyVisible}
|
||||
width="80%"
|
||||
cancelBtn={null}
|
||||
confirmBtn="关闭"
|
||||
onClose={() => setHistoryVisible(false)}
|
||||
onConfirm={() => setHistoryVisible(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
fontSize: '12px',
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: '10px'
|
||||
}}
|
||||
>
|
||||
{historyText}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
MessagePlugin,
|
||||
Tag,
|
||||
DialogPlugin
|
||||
Tag
|
||||
} from 'tdesign-react'
|
||||
|
||||
interface Reason {
|
||||
interface reason {
|
||||
id: number
|
||||
content: string
|
||||
category: string
|
||||
@@ -21,9 +20,12 @@ interface Reason {
|
||||
}
|
||||
|
||||
export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [data, setData] = useState<Reason[]>([])
|
||||
const [data, setData] = useState<reason[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [deleteDialogVisible, setDeleteDialogVisible] = useState(false)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<reason | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const emitDataUpdated = (category: 'reasons' | 'all') => {
|
||||
@@ -82,35 +84,17 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Reason) => {
|
||||
const handleDelete = async (row: reason) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
MessagePlugin.error('当前为只读权限')
|
||||
return
|
||||
}
|
||||
const dialog = DialogPlugin.confirm({
|
||||
header: '确认删除该理由?',
|
||||
body: (
|
||||
<div style={{ wordBreak: 'break-all' }}>
|
||||
{row.category} / {row.content} ({row.delta > 0 ? `+${row.delta}` : row.delta})
|
||||
</div>
|
||||
),
|
||||
confirmBtn: '删除',
|
||||
onConfirm: async () => {
|
||||
const res = await (window as any).api.deleteReason(row.id)
|
||||
if (res.success) {
|
||||
MessagePlugin.success('删除成功')
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败')
|
||||
}
|
||||
dialog.hide()
|
||||
}
|
||||
})
|
||||
setDeleteTarget(row)
|
||||
setDeleteDialogVisible(true)
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<Reason>[] = [
|
||||
const columns: PrimaryTableCol<reason>[] = [
|
||||
{
|
||||
colKey: 'category',
|
||||
title: '分类',
|
||||
@@ -187,6 +171,49 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</Form.FormItem>
|
||||
</Form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
header="确认删除该理由?"
|
||||
visible={deleteDialogVisible}
|
||||
confirmBtn="删除"
|
||||
confirmLoading={deleteLoading}
|
||||
onClose={() => {
|
||||
if (!deleteLoading) {
|
||||
setDeleteDialogVisible(false)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!deleteLoading) {
|
||||
setDeleteDialogVisible(false)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!deleteTarget) return
|
||||
setDeleteLoading(true)
|
||||
const res = await (window as any).api.deleteReason(deleteTarget.id)
|
||||
setDeleteLoading(false)
|
||||
if (res.success) {
|
||||
MessagePlugin.success('删除成功')
|
||||
setDeleteDialogVisible(false)
|
||||
setDeleteTarget(null)
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ wordBreak: 'break-all' }}>
|
||||
{deleteTarget
|
||||
? `${deleteTarget.category} / ${deleteTarget.content} (${
|
||||
deleteTarget.delta > 0 ? `+${deleteTarget.delta}` : deleteTarget.delta
|
||||
})`
|
||||
: ''}
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,20 +54,20 @@ const matchStudentName = (name: string, keyword: string) => {
|
||||
return false
|
||||
}
|
||||
|
||||
interface Student {
|
||||
interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface Reason {
|
||||
interface reason {
|
||||
id: number
|
||||
content: string
|
||||
delta: number
|
||||
category: string
|
||||
}
|
||||
|
||||
interface ScoreEvent {
|
||||
interface scoreEvent {
|
||||
id: number
|
||||
uuid: string
|
||||
student_name: string
|
||||
@@ -79,9 +79,9 @@ interface ScoreEvent {
|
||||
}
|
||||
|
||||
export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [students, setStudents] = useState<Student[]>([])
|
||||
const [reasons, setReasons] = useState<Reason[]>([])
|
||||
const [events, setEvents] = useState<ScoreEvent[]>([])
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [events, setEvents] = useState<scoreEvent[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
@@ -190,7 +190,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<ScoreEvent>[] = [
|
||||
const columns: PrimaryTableCol<scoreEvent>[] = [
|
||||
{ colKey: 'student_name', title: '学生', width: 100 },
|
||||
{
|
||||
colKey: 'delta',
|
||||
|
||||
@@ -5,30 +5,31 @@ import {
|
||||
Form,
|
||||
Select,
|
||||
Input,
|
||||
Switch,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
Tag,
|
||||
Dialog,
|
||||
MessagePlugin,
|
||||
DialogPlugin
|
||||
MessagePlugin
|
||||
} from 'tdesign-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
type PermissionLevel = 'admin' | 'points' | 'view'
|
||||
type permissionLevel = 'admin' | 'points' | 'view'
|
||||
type appSettings = {
|
||||
is_wizard_completed: boolean
|
||||
log_level: 'debug' | 'info' | 'warn' | 'error'
|
||||
}
|
||||
|
||||
export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission }) => {
|
||||
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
|
||||
const { themes, currentTheme, setTheme } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState('appearance')
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [syncStatus, setSyncStatus] = useState<{ connected: boolean; lastSync?: string }>({
|
||||
connected: false
|
||||
const [settings, setSettings] = useState<appSettings>({
|
||||
is_wizard_completed: false,
|
||||
log_level: 'info'
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [securityStatus, setSecurityStatus] = useState<{
|
||||
permission: PermissionLevel
|
||||
permission: permissionLevel
|
||||
hasAdminPassword: boolean
|
||||
hasPointsPassword: boolean
|
||||
hasRecoveryString: boolean
|
||||
@@ -53,6 +54,7 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [settleLoading, setSettleLoading] = useState(false)
|
||||
const [settleDialogVisible, setSettleDialogVisible] = useState(false)
|
||||
|
||||
const canAdmin = permission === 'admin'
|
||||
|
||||
@@ -67,45 +69,35 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
)
|
||||
}, [permission])
|
||||
|
||||
const emitSettingUpdated = (key: string, value: string) => {
|
||||
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()
|
||||
if (res.success && res.data) setSettings(res.data)
|
||||
const statusRes = await (window as any).api.getSyncStatus()
|
||||
if (statusRes.success && statusRes.data) setSyncStatus(statusRes.data)
|
||||
const res = await (window as any).api.getAllSettings()
|
||||
if (res.success && res.data) {
|
||||
setSettings(res.data)
|
||||
}
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
const timer = setInterval(async () => {
|
||||
if (!(window as any).api) return
|
||||
const statusRes = await (window as any).api.getSyncStatus()
|
||||
if (statusRes.success && statusRes.data) setSyncStatus(statusRes.data)
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const handleUpdateSetting = async (key: string, value: string) => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.updateSetting(key, value)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||
emitSettingUpdated(key, value)
|
||||
MessagePlugin.success('设置已更新')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '设置更新失败')
|
||||
const unsubscribe = (window as any).api.onSettingChanged((change: any) => {
|
||||
setSettings((prev) => {
|
||||
if (change?.key === 'log_level') return { ...prev, log_level: change.value }
|
||||
if (change?.key === 'is_wizard_completed')
|
||||
return { ...prev, is_wizard_completed: change.value }
|
||||
return prev
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showLogs = async () => {
|
||||
if (!(window as any).api) return
|
||||
@@ -133,7 +125,7 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
}
|
||||
|
||||
const downloadTextFile = (filename: string, text: string) => {
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const blob = new Blob(['\ufeff' + text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
@@ -244,30 +236,7 @@ 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 || '结算失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
setSettleDialogVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -431,71 +400,6 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="同步设置 (远程模式)"
|
||||
style={{
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
color: 'var(--ss-text-main)',
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<Form labelWidth={120}>
|
||||
<Form.FormItem label="同步模式">
|
||||
<Space align="center">
|
||||
<Switch
|
||||
value={settings.sync_mode === 'remote'}
|
||||
onChange={(v) => handleUpdateSetting('sync_mode', v ? 'remote' : 'local')}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', color: 'var(--ss-text-secondary)' }}>
|
||||
{settings.sync_mode === 'remote' ? '远程同步模式' : '纯本地模式'}
|
||||
</span>
|
||||
</Space>
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem label="服务器地址">
|
||||
<Input
|
||||
value={settings.ws_server}
|
||||
onChange={(v) => setSettings((prev) => ({ ...prev, ws_server: v }))}
|
||||
onBlur={() => handleUpdateSetting('ws_server', settings.ws_server)}
|
||||
placeholder="ws://localhost:8080"
|
||||
style={{ width: '320px' }}
|
||||
disabled={settings.sync_mode !== 'remote'}
|
||||
/>
|
||||
</Form.FormItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Form.FormItem label="同步状态">
|
||||
<Space align="center">
|
||||
<Tag theme={syncStatus.connected ? 'success' : 'default'} variant="light">
|
||||
{syncStatus.connected ? '已连接' : '未连接'}
|
||||
</Tag>
|
||||
{syncStatus.lastSync && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
上次同步: {new Date(syncStatus.lastSync).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
loading={loading}
|
||||
disabled={settings.sync_mode !== 'remote' || !syncStatus.connected}
|
||||
onClick={async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
const res = await (window as any).api.triggerSync()
|
||||
setLoading(false)
|
||||
if (res.success) MessagePlugin.success('同步完成')
|
||||
else MessagePlugin.error('同步失败: ' + res.message)
|
||||
}}
|
||||
>
|
||||
立即对齐数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.FormItem>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
@@ -535,12 +439,13 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
<Form labelWidth={120}>
|
||||
<Form.FormItem label="日志级别">
|
||||
<Select
|
||||
value={settings.log_level || 'info'}
|
||||
value={settings.log_level}
|
||||
onChange={async (v) => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.setLogLevel(String(v))
|
||||
const next = String(v) as any
|
||||
const res = await (window as any).api.setSetting('log_level', next)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, log_level: v as string }))
|
||||
setSettings((prev) => ({ ...prev, log_level: next }))
|
||||
MessagePlugin.success('日志级别已更新')
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '更新失败')
|
||||
@@ -611,7 +516,13 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
onConfirm={() => setRecoveryDialogVisible(false)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
|
||||
<div
|
||||
style={{
|
||||
wordBreak: 'break-all',
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace'
|
||||
}}
|
||||
>
|
||||
{recoveryDialogString}
|
||||
</div>
|
||||
<Space>
|
||||
@@ -648,7 +559,8 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
@@ -659,6 +571,39 @@ export const Settings: React.FC<{ permission: PermissionLevel }> = ({ permission
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
header="确认结算并重新开始?"
|
||||
visible={settleDialogVisible}
|
||||
confirmBtn="结算"
|
||||
confirmLoading={settleLoading}
|
||||
onClose={() => {
|
||||
if (!settleLoading) setSettleDialogVisible(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!settleLoading) setSettleDialogVisible(false)
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!(window as any).api) return
|
||||
setSettleLoading(true)
|
||||
const res = await (window as any).api.createSettlement()
|
||||
setSettleLoading(false)
|
||||
if (res.success && res.data) {
|
||||
MessagePlugin.success('结算成功,已重新开始积分')
|
||||
emitDataUpdated('all')
|
||||
setSettleDialogVisible(false)
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '结算失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div>将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
学生名单不变;结算后的历史在“结算历史”页面查看。
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
header="确认清空所有密码?"
|
||||
visible={clearDialogVisible}
|
||||
|
||||
@@ -2,27 +2,29 @@ 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 {
|
||||
interface settlementSummary {
|
||||
id: number
|
||||
start_time: string
|
||||
end_time: string
|
||||
event_count: number
|
||||
}
|
||||
|
||||
interface SettlementLeaderboardRow {
|
||||
interface settlementLeaderboardRow {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export const SettlementHistory: React.FC = () => {
|
||||
const [settlements, setSettlements] = useState<SettlementSummary[]>([])
|
||||
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 [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 }) => {
|
||||
@@ -70,7 +72,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
setRows(res.data.rows || [])
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<SettlementLeaderboardRow>[] = useMemo(
|
||||
const columns: PrimaryTableCol<settlementLeaderboardRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
colKey: 'rank',
|
||||
@@ -128,11 +130,22 @@ export const SettlementHistory: React.FC = () => {
|
||||
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' }}>
|
||||
<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)' }}>
|
||||
<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' }}>
|
||||
<div
|
||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}
|
||||
>
|
||||
{formatRange(s)}
|
||||
</div>
|
||||
<Space>
|
||||
@@ -152,4 +165,3 @@ export const SettlementHistory: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
|
||||
import type { PrimaryTableCol } from 'tdesign-react'
|
||||
|
||||
interface Student {
|
||||
interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [data, setData] = useState<Student[]>([])
|
||||
const [data, setData] = useState<student[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
@@ -73,7 +73,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
MessagePlugin.error(res.message || '添加失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Validate error', err)
|
||||
try {
|
||||
const api = (window as any).api
|
||||
api?.writeLog?.({
|
||||
level: 'error',
|
||||
message: 'renderer:validate error',
|
||||
meta:
|
||||
err instanceof Error ? { message: err.message, stack: err.stack } : { err: String(err) }
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: PrimaryTableCol<Student>[] = [
|
||||
const columns: PrimaryTableCol<student>[] = [
|
||||
{ colKey: 'name', title: '姓名', width: 200 },
|
||||
{
|
||||
colKey: 'score',
|
||||
|
||||
@@ -1,39 +1,22 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
Form,
|
||||
Select,
|
||||
Input,
|
||||
Switch,
|
||||
MessagePlugin,
|
||||
Space,
|
||||
Typography
|
||||
} from 'tdesign-react'
|
||||
import { Dialog, Form, Select, MessagePlugin, Typography } from 'tdesign-react'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
interface WizardProps {
|
||||
interface wizardProps {
|
||||
visible: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
|
||||
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
const { themes, currentTheme, setTheme } = useTheme()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
sync_mode: 'local',
|
||||
ws_server: 'ws://localhost:8080'
|
||||
})
|
||||
|
||||
const handleFinish = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!(window as any).api) throw new Error('api not ready')
|
||||
// 1. 保存模式
|
||||
await (window as any).api.updateSetting('sync_mode', formData.sync_mode)
|
||||
// 2. 保存服务器地址
|
||||
await (window as any).api.updateSetting('ws_server', formData.ws_server)
|
||||
// 3. 标记向导已完成
|
||||
await (window as any).api.updateSetting('is_wizard_completed', '1')
|
||||
const res = await (window as any).api.setSetting('is_wizard_completed', true)
|
||||
if (!res?.success) throw new Error(res?.message || 'failed')
|
||||
|
||||
MessagePlugin.success('配置完成!')
|
||||
onComplete()
|
||||
@@ -67,30 +50,6 @@ export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
|
||||
))}
|
||||
</Select>
|
||||
</Form.FormItem>
|
||||
|
||||
<Form.FormItem label="同步模式">
|
||||
<Space align="center">
|
||||
<Switch
|
||||
value={formData.sync_mode === 'remote'}
|
||||
onChange={(v) =>
|
||||
setFormData((prev) => ({ ...prev, sync_mode: v ? 'remote' : 'local' }))
|
||||
}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>
|
||||
{formData.sync_mode === 'remote' ? '远程同步模式' : '纯本地模式'}
|
||||
</span>
|
||||
</Space>
|
||||
</Form.FormItem>
|
||||
|
||||
{formData.sync_mode === 'remote' && (
|
||||
<Form.FormItem label="服务器地址">
|
||||
<Input
|
||||
value={formData.ws_server}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, ws_server: v }))}
|
||||
placeholder="ws://localhost:8080"
|
||||
/>
|
||||
</Form.FormItem>
|
||||
)}
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { ClientContext } from '../ClientContext'
|
||||
|
||||
const ServiceContext = createContext<ClientContext | null>(null)
|
||||
|
||||
export const ServiceProvider = ServiceContext.Provider
|
||||
|
||||
export const useService = () => {
|
||||
const ctx = useContext(ServiceContext)
|
||||
if (!ctx) throw new Error('No ServiceProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { ThemeConfig } from '../../../preload/types'
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
import { generateColorMap } from '../utils/color'
|
||||
|
||||
interface ThemeContextType {
|
||||
currentTheme: ThemeConfig | null
|
||||
interface themeContextType {
|
||||
currentTheme: themeConfig | null
|
||||
setTheme: (id: string) => Promise<void>
|
||||
themes: ThemeConfig[]
|
||||
themes: themeConfig[]
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
const ThemeContext = createContext<themeContextType | undefined>(undefined)
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null)
|
||||
const [themes, setThemes] = useState<ThemeConfig[]>([])
|
||||
const [currentTheme, setCurrentTheme] = useState<themeConfig | null>(null)
|
||||
const [themes, setThemes] = useState<themeConfig[]>([])
|
||||
const appliedStyleKeysRef = useRef<string[]>([])
|
||||
const currentThemeRef = useRef<themeConfig | null>(null)
|
||||
|
||||
const applyThemeConfig = useCallback((theme: ThemeConfig) => {
|
||||
const applyThemeConfig = useCallback((theme: themeConfig) => {
|
||||
const { tdesign, custom } = theme.config
|
||||
const root = document.documentElement
|
||||
const prevKeys = appliedStyleKeysRef.current
|
||||
for (const k of prevKeys) root.style.removeProperty(k)
|
||||
const nextKeys: string[] = []
|
||||
|
||||
// 1. 设置 TDesign 亮/暗模式
|
||||
root.setAttribute('theme-mode', theme.mode)
|
||||
@@ -26,16 +31,29 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
|
||||
Object.entries(colorMap).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
nextKeys.push(key)
|
||||
})
|
||||
}
|
||||
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)
|
||||
if (tdesign.warningColor) {
|
||||
root.style.setProperty('--td-warning-color', tdesign.warningColor)
|
||||
nextKeys.push('--td-warning-color')
|
||||
}
|
||||
if (tdesign.errorColor) {
|
||||
root.style.setProperty('--td-error-color', tdesign.errorColor)
|
||||
nextKeys.push('--td-error-color')
|
||||
}
|
||||
if (tdesign.successColor) {
|
||||
root.style.setProperty('--td-success-color', tdesign.successColor)
|
||||
nextKeys.push('--td-success-color')
|
||||
}
|
||||
|
||||
// 3. 应用自定义 CSS 变量 (用于业务 UI)
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
nextKeys.push(key)
|
||||
})
|
||||
|
||||
appliedStyleKeysRef.current = nextKeys
|
||||
}, [])
|
||||
|
||||
const loadThemes = useCallback(async () => {
|
||||
@@ -51,17 +69,21 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const res = await (window as any).api.getCurrentTheme()
|
||||
if (res.success && res.data) {
|
||||
setCurrentTheme(res.data)
|
||||
currentThemeRef.current = res.data
|
||||
applyThemeConfig(res.data)
|
||||
}
|
||||
}, [applyThemeConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(window as any).api) return
|
||||
loadThemes()
|
||||
loadCurrentTheme()
|
||||
;(async () => {
|
||||
await loadThemes()
|
||||
await loadCurrentTheme()
|
||||
})()
|
||||
|
||||
const unsubscribe = (window as any).api.onThemeChanged((theme) => {
|
||||
setCurrentTheme(theme)
|
||||
currentThemeRef.current = theme
|
||||
applyThemeConfig(theme)
|
||||
loadThemes() // Refresh list in case of new files
|
||||
})
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import './react-19-patch'
|
||||
import './assets/main.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'tdesign-react/es/_util/react-19-adapter'
|
||||
import App from './App'
|
||||
import { ClientContext } from './ClientContext'
|
||||
import { StudentService } from './services/StudentService'
|
||||
import { ServiceProvider } from './contexts/ServiceContext'
|
||||
|
||||
const ctx = new ClientContext()
|
||||
new StudentService(ctx)
|
||||
|
||||
const safeWriteLog = (payload: {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
@@ -19,6 +25,53 @@ const safeWriteLog = (payload: {
|
||||
}
|
||||
}
|
||||
|
||||
const patchConsole = () => {
|
||||
const c = window.console as any
|
||||
const set = (name: string, fn: (...args: any[]) => void) => {
|
||||
try {
|
||||
c[name] = fn
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
set('log', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('info', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('warn', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'warn', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('debug', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'debug', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('error', (...args: any[]) => {
|
||||
const first = args[0]
|
||||
if (first instanceof Error) {
|
||||
safeWriteLog({
|
||||
level: 'error',
|
||||
message: first.message,
|
||||
meta: { stack: first.stack, args: args.slice(1) }
|
||||
})
|
||||
return
|
||||
}
|
||||
safeWriteLog({ level: 'error', message: String(first ?? ''), meta: args.slice(1) })
|
||||
})
|
||||
set('trace', (...args: any[]) =>
|
||||
safeWriteLog({
|
||||
level: 'debug',
|
||||
message: 'console.trace',
|
||||
meta: { args, stack: new Error('console.trace').stack }
|
||||
})
|
||||
)
|
||||
set('table', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: 'console.table', meta: args })
|
||||
)
|
||||
}
|
||||
patchConsole()
|
||||
|
||||
window.addEventListener('error', (e: any) => {
|
||||
const error = e?.error
|
||||
safeWriteLog({
|
||||
@@ -45,6 +98,8 @@ window.addEventListener('unhandledrejection', (e: any) => {
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ServiceProvider value={ctx}>
|
||||
<App />
|
||||
</ServiceProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -35,5 +35,3 @@ if (!(ReactDOM as any).unmountComponentAtNode) {
|
||||
((element: ReactNode, container: HTMLElement) => {
|
||||
;(ReactDOM as any).render(element, container)
|
||||
})
|
||||
|
||||
console.log('[SecScore] React 19 compatibility patch applied.')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Service } from '../../../shared/kernel'
|
||||
import { ClientContext } from '../ClientContext'
|
||||
|
||||
declare module '../../../shared/kernel' {
|
||||
interface Context {
|
||||
students: StudentService
|
||||
}
|
||||
}
|
||||
|
||||
export class StudentService extends Service {
|
||||
constructor(ctx: ClientContext) {
|
||||
super(ctx, 'students')
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
return await (window as any).api.queryStudents({})
|
||||
}
|
||||
|
||||
async create(data: any) {
|
||||
return await (window as any).api.createStudent(data)
|
||||
}
|
||||
|
||||
async update(id: number, data: any) {
|
||||
return await (window as any).api.updateStudent(id, data)
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return await (window as any).api.deleteStudent(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
export type disposer = () => void
|
||||
|
||||
type eventListener = (...args: any[]) => void
|
||||
|
||||
/**
|
||||
* Simple EventEmitter implementation to avoid Node.js 'events' dependency in browser.
|
||||
*/
|
||||
export class EventEmitter {
|
||||
protected _events: Record<string | symbol, eventListener[]> = {}
|
||||
|
||||
on(event: string | symbol, listener: eventListener): this {
|
||||
if (!this._events[event]) {
|
||||
this._events[event] = []
|
||||
}
|
||||
this._events[event].push(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
off(event: string | symbol, listener: eventListener): this {
|
||||
if (!this._events[event]) return this
|
||||
this._events[event] = this._events[event].filter((l) => l !== listener)
|
||||
return this
|
||||
}
|
||||
|
||||
once(event: string | symbol, listener: eventListener): this {
|
||||
const onceListener = (...args: any[]) => {
|
||||
this.off(event, onceListener)
|
||||
listener(...args)
|
||||
}
|
||||
return this.on(event, onceListener)
|
||||
}
|
||||
|
||||
emit(event: string | symbol, ...args: any[]): boolean {
|
||||
if (!this._events[event]) return false
|
||||
// Copy to avoid issues if listeners are removed during emission
|
||||
const listeners = [...this._events[event]]
|
||||
listeners.forEach((listener) => listener(...args))
|
||||
return true
|
||||
}
|
||||
|
||||
removeAllListeners(event?: string | symbol): this {
|
||||
if (event) {
|
||||
delete this._events[event]
|
||||
} else {
|
||||
this._events = {}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context class that manages lifecycle and side effects (disposables).
|
||||
* Inspired by Koishi's Cordis.
|
||||
*/
|
||||
export class Context extends EventEmitter {
|
||||
private _disposables: disposer[] = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a side effect to be disposed when the context is disposed.
|
||||
* @param callback The cleanup function
|
||||
* @returns A function to manually dispose this effect
|
||||
*/
|
||||
effect(callback: disposer): disposer {
|
||||
this._disposables.push(callback)
|
||||
return () => {
|
||||
const index = this._disposables.indexOf(callback)
|
||||
if (index >= 0) {
|
||||
this._disposables.splice(index, 1)
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event listener that is automatically disposed when the context is disposed.
|
||||
*/
|
||||
on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
super.on(event, listener)
|
||||
this.effect(() => {
|
||||
super.off(event, listener)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
once(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||
const onceListener = (...args: any[]) => {
|
||||
super.off(event, onceListener)
|
||||
listener(...args)
|
||||
}
|
||||
super.on(event, onceListener)
|
||||
this.effect(() => {
|
||||
super.off(event, onceListener)
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the context and all its side effects.
|
||||
*/
|
||||
dispose() {
|
||||
this.emit('dispose')
|
||||
// Dispose in reverse order of registration
|
||||
while (this._disposables.length) {
|
||||
const dispose = this._disposables.pop()
|
||||
try {
|
||||
if (dispose) dispose()
|
||||
} catch (e) {
|
||||
;(this as any).logger?.error?.('Error during disposal', {
|
||||
meta: e instanceof Error ? { message: e.message, stack: e.stack } : { e }
|
||||
})
|
||||
}
|
||||
}
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the context (create a new context that shares state but has its own lifecycle).
|
||||
*/
|
||||
extend(): Context {
|
||||
const child = new Context()
|
||||
const disposeChild = this.effect(() => child.dispose())
|
||||
child.on('dispose', disposeChild)
|
||||
|
||||
// Copy prototype chain to access services
|
||||
Object.setPrototypeOf(child, this)
|
||||
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for services.
|
||||
* Services are attached to the context.
|
||||
*/
|
||||
export abstract class Service {
|
||||
constructor(
|
||||
protected ctx: Context,
|
||||
name: string
|
||||
) {
|
||||
if ((ctx as any)[name]) {
|
||||
;(ctx as any).logger?.warn?.('Service already exists on context. Overwriting.', { name })
|
||||
}
|
||||
;(ctx as any)[name] = this
|
||||
|
||||
ctx.effect(() => {
|
||||
if ((ctx as any)[name] === this) {
|
||||
delete (ctx as any)[name]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected get logger() {
|
||||
return (this.ctx as any).logger
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user