refactor: 移除WebSocket同步功能及相关代码

feat(theme): 增强侧边栏选中项样式配置
style: 优化主题变量和样式覆盖逻辑
chore: 清理未使用的依赖和配置文件
This commit is contained in:
Fox_block
2026-01-18 11:44:50 +08:00
parent b41139b941
commit e83f17af56
70 changed files with 4199 additions and 2173 deletions
+21
View File
@@ -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
View File
@@ -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()
}
}
+170
View File
@@ -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 }
}
}
+5
View File
@@ -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'
+23
View File
@@ -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
}
+33
View File
@@ -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
}
+10
View File
@@ -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
}
+16
View File
@@ -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
}
+22
View File
@@ -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
}
+5
View File
@@ -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"`)
}
}
+3
View File
@@ -0,0 +1,3 @@
import { InitSchema2026011800000 } from './InitSchema2026011800000'
export const migrations = [InitSchema2026011800000]
+92
View File
@@ -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))
}
}
+110
View File
@@ -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
}
+29
View File
@@ -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'
+61
View File
@@ -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) // 记录错误但不中断
}
}
}
}
+216
View File
@@ -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()
}
}
}
+14
View File
@@ -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')
+71
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+185 -80
View File
@@ -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 }
}
}
+76 -29
View File
@@ -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)
}
}
+136 -66
View File
@@ -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) }))
}
}
}
+81 -42
View File
@@ -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 })
})
}
}
+175
View File
@@ -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 }
})
}
}
+45
View File
@@ -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 }
})
}
}
+148 -41
View File
@@ -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
}
}
+50
View File
@@ -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]
}
}
+63
View File
@@ -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
}
}
+250
View File
@@ -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
}
}
-162
View File
@@ -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)
}
})()
}
}
+38 -19
View File
@@ -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
}
+146
View File
@@ -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' }
})
}
}
-142
View File
@@ -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
}
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { Context } from '../../shared/kernel'
export class ClientContext extends Context {
constructor() {
super()
}
}
+42 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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 },
{
+75 -63
View File
@@ -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>
)
}
+53 -26
View File
@@ -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>
)
}
+7 -7
View File
@@ -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',
+75 -130
View File
@@ -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>
)
}
+14 -4
View File
@@ -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',
+5 -46
View File
@@ -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
}
+36 -14
View File
@@ -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
})
+57 -2
View File
@@ -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>
)
-2
View File
@@ -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)
}
}
+159
View File
@@ -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
}
}