feat:修复了亿点点bug

This commit is contained in:
Fox_block
2026-01-14 19:27:51 +08:00
parent ae08ec45b0
commit f27e2f21bb
33 changed files with 3161 additions and 935 deletions
+112 -112
View File
@@ -15,22 +15,22 @@
### 2.1 客户端请求 (Request)
| 字段 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| **`seq`** | String | Yes | 请求唯一序列号 (如 UUID) |
| **`category`** | String | Yes | 资源类别: `"system"`, `"student"`, `"group"`, `"event"` |
| **`action`** | String | Yes | 操作动作: `"define"`, `"create"`, `"query"`, `"update"`, `"delete"` |
| **`payload`** | Object | Yes | 具体操作参数,结构依 `action` 而定 |
| 字段 | 类型 | 必填 | 说明 |
| :------------- | :----- | :--- | :------------------------------------------------------------------ |
| **`seq`** | String | Yes | 请求唯一序列号 (如 UUID) |
| **`category`** | String | Yes | 资源类别: `"system"`, `"student"`, `"group"`, `"event"` |
| **`action`** | String | Yes | 操作动作: `"define"`, `"create"`, `"query"`, `"update"`, `"delete"` |
| **`payload`** | Object | Yes | 具体操作参数,结构依 `action` 而定 |
### 2.2 服务端响应 (Response)
| 字段 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| **`seq`** | String | Yes | 对应请求的 `seq` |
| **`status`** | String | Yes | `"ok"``"error"` |
| **`code`** | Int | Yes | 状态码 (见第 6 节) |
| **`message`** | String | No | 错误描述或提示信息 |
| **`data`** | Object | No | 成功时的返回数据 |
| 字段 | 类型 | 必填 | 说明 |
| :------------ | :----- | :--- | :------------------ |
| **`seq`** | String | Yes | 对应请求的 `seq` |
| **`status`** | String | Yes | `"ok"``"error"` |
| **`code`** | Int | Yes | 状态码 (见第 6 节) |
| **`message`** | String | No | 错误描述或提示信息 |
| **`data`** | Object | No | 成功时的返回数据 |
---
@@ -44,17 +44,17 @@
```json
{
"category": "system",
"action": "define",
"payload": {
"target": "student", // 或 "group"
"schema": {
"name": "string",
"age": "int",
"score": "double",
"active": "int"
}
"category": "system",
"action": "define",
"payload": {
"target": "student", // 或 "group"
"schema": {
"name": "string",
"age": "int",
"score": "double",
"active": "int"
}
}
}
```
@@ -76,22 +76,22 @@
```json
{
"category": "student",
"action": "create",
"payload": {
"items": [
{
"index": 0, // 客户端临时索引
"id": null, // [请求分配ID]
"data": { "name": "Alice", "age": 18, "score": 95.5 }
},
{
"index": 1,
"id": 10086, // [强制指定ID]
"data": { "name": "Bob", "age": 20 }
}
]
}
"category": "student",
"action": "create",
"payload": {
"items": [
{
"index": 0, // 客户端临时索引
"id": null, // [请求分配ID]
"data": { "name": "Alice", "age": 18, "score": 95.5 }
},
{
"index": 1,
"id": 10086, // [强制指定ID]
"data": { "name": "Bob", "age": 20 }
}
]
}
}
```
@@ -99,19 +99,19 @@
```json
{
"count": 2, // 成功数量
"results": [
{
"index": 0,
"success": true,
"id": 1001 // 服务端分配的新 ID
},
{
"index": 1,
"success": true,
"id": 10086
}
]
"count": 2, // 成功数量
"results": [
{
"index": 0,
"success": true,
"id": 1001 // 服务端分配的新 ID
},
{
"index": 1,
"success": true,
"id": 10086
}
]
}
```
@@ -123,38 +123,38 @@
```json
{
"category": "student",
"action": "query",
"payload": {
"logic": {
"op": "AND", // 根节点逻辑: AND, OR
"rules": [
{ "field": "score", "op": ">=", "val": 90.0 },
{
"op": "OR",
"rules": [
{ "field": "age", "op": "<", "val": 20 },
{ "field": "name", "op": "==", "val": "Alice" }
]
}
]
"category": "student",
"action": "query",
"payload": {
"logic": {
"op": "AND", // 根节点逻辑: AND, OR
"rules": [
{ "field": "score", "op": ">=", "val": 90.0 },
{
"op": "OR",
"rules": [
{ "field": "age", "op": "<", "val": 20 },
{ "field": "name", "op": "==", "val": "Alice" }
]
}
]
}
}
}
```
*支持的操作符 (`op`): `==`, `!=`, `>`, `<`, `>=`, `<=`*
_支持的操作符 (`op`): `==`, `!=`, `>`, `<`, `>=`, `<=`_
**Response Data:**
```json
{
"items": [
{
"id": 1001,
"data": { "name": "Alice", "age": 18, "score": 95.5 }
}
]
"items": [
{
"id": 1001,
"data": { "name": "Alice", "age": 18, "score": 95.5 }
}
]
}
```
@@ -164,15 +164,15 @@
```json
{
"category": "student",
"action": "update",
"payload": {
"id": 1001,
"set": {
"score": 98.0, // 仅更新指定字段
"age": 19
}
"category": "student",
"action": "update",
"payload": {
"id": 1001,
"set": {
"score": 98.0, // 仅更新指定字段
"age": 19
}
}
}
```
@@ -182,11 +182,11 @@
```json
{
"category": "student",
"action": "delete",
"payload": {
"id": 1001
}
"category": "student",
"action": "delete",
"payload": {
"id": 1001
}
}
```
@@ -200,16 +200,16 @@
```json
{
"category": "event",
"action": "create",
"payload": {
"id": null, // 必须为 null
"type": 1, // 1=Student, 2=Group
"ref_id": 1001, // 关联对象的 ID
"desc": "Score adjustment",
"val_prev": 95.5,
"val_curr": 98.0
}
"category": "event",
"action": "create",
"payload": {
"id": null, // 必须为 null
"type": 1, // 1=Student, 2=Group
"ref_id": 1001, // 关联对象的 ID
"desc": "Score adjustment",
"val_prev": 95.5,
"val_curr": 98.0
}
}
```
@@ -217,8 +217,8 @@
```json
{
"id": 501, // 新生成的 Event ID
"timestamp": 1710000000
"id": 501, // 新生成的 Event ID
"timestamp": 1710000000
}
```
@@ -228,12 +228,12 @@
```json
{
"category": "event",
"action": "update",
"payload": {
"id": 501,
"erased": true
}
"category": "event",
"action": "update",
"payload": {
"id": 501,
"erased": true
}
}
```
@@ -241,10 +241,10 @@
## 6. 状态码定义 (Status Codes)
| Code | Meaning | Description |
| :--- | :--- | :--- |
| **200** | OK | 请求成功执行 |
| **400** | Bad Request | JSON 格式错误、缺少必填字段或 `action` 不支持 |
| **404** | Not Found | 指定的 ID 不存在 |
| **422** | Unprocessable | 数据类型不匹配 (如给 Int 字段传 String) |
| **500** | Internal Error | 核心内部 C++ 异常 |
| Code | Meaning | Description |
| :------ | :------------- | :-------------------------------------------- |
| **200** | OK | 请求成功执行 |
| **400** | Bad Request | JSON 格式错误、缺少必填字段或 `action` 不支持 |
| **404** | Not Found | 指定的 ID 不存在 |
| **422** | Unprocessable | 数据类型不匹配 (如给 Int 字段传 String) |
| **500** | Internal Error | 核心内部 C++ 异常 |
+6 -1
View File
@@ -25,7 +25,12 @@ export default defineConfig(
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
...eslintPluginReactRefresh.configs.vite.rules,
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': 'off',
'react-hooks/exhaustive-deps': 'warn',
'react-hooks/set-state-in-effect': 'off'
}
},
eslintConfigPrettier
+4 -4
View File
@@ -10,13 +10,13 @@
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"typecheck": "pnpm -s typecheck:node && pnpm -s typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"build": "node scripts/clean-db.mjs && pnpm -s typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:unpack": "pnpm -s build && electron-builder --dir",
"build:win": "pnpm -s build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
+17
View File
@@ -0,0 +1,17 @@
import fs from 'fs'
import path from 'path'
const root = process.cwd()
const targets = ['db.sqlite']
for (const name of targets) {
const filePath = path.join(root, name)
try {
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true })
console.log(`[clean-db] removed ${filePath}`)
}
} catch (e) {
console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e)
}
}
+48 -50
View File
@@ -1,25 +1,25 @@
import BetterSqlite3 from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import BetterSqlite3 from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
export class DbManager {
private db: BetterSqlite3.Database;
private db: BetterSqlite3.Database
constructor(dbPath: string) {
const dbDir = path.dirname(dbPath);
const dbDir = path.dirname(dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
fs.mkdirSync(dbDir, { recursive: true })
}
this.db = new BetterSqlite3(dbPath);
this.init();
this.db = new BetterSqlite3(dbPath)
this.init()
}
private init() {
// 开启外键支持
this.db.pragma('foreign_keys = ON');
this.db.pragma('foreign_keys = ON')
// 执行 Migration (简单实现)
this.migrate();
this.migrate()
}
private migrate() {
@@ -33,7 +33,23 @@ export class DbManager {
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(`
@@ -41,13 +57,14 @@ export class DbManager {
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: 已完成
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(`
@@ -60,46 +77,27 @@ export class DbManager {
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);
const reasonCount = this.db.prepare('SELECT count(*) as count FROM reasons').get() as {
count: number
}
// 建立积分事件流水表
this.db.exec(`
CREATE TABLE IF NOT EXISTS score_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE,
student_name TEXT NOT NULL,
reason_content TEXT,
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, -- 0: 待同步, 1: 已同步
remote_id TEXT
if (reasonCount.count === 0) {
const insert = this.db.prepare(
'INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)'
)
`);
// 插入一些初始 Reasons (如果为空)
const count = this.db.prepare('SELECT COUNT(*) as count FROM reasons').get() as { count: number };
if (count.count === 0) {
const insert = this.db.prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)');
insert.run('作业优秀', '学习', 2, 1);
insert.run('课堂积极', '学习', 1, 1);
insert.run('打架斗殴', '纪律', -5, 1);
insert.run('迟到', '纪律', -1, 1);
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;
return this.db
}
}
+637 -56
View File
@@ -1,9 +1,11 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import fs from 'fs'
import crypto from 'crypto'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ThemeService } from './services/ThemeService'
import { LoggerService, LogLevel } from './services/LoggerService'
import { DbManager } from './db/DbManager'
import { StudentRepository } from './repos/StudentRepository'
import { ReasonRepository } from './repos/ReasonRepository'
@@ -57,83 +59,390 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// Initialize Services
const themeDir = is.dev ? join(process.cwd(), 'themes') : join(app.getPath('userData'), 'themes');
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true });
// Base data directory (production 使用单独目录)
const dataRoot = is.dev ? process.cwd() : join(app.getPath('userData'), 'secscore-data')
if (!fs.existsSync(dataRoot)) {
fs.mkdirSync(dataRoot, { recursive: true })
}
// Initialize Logger
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
const logger = new LoggerService(logDir)
logger.info('Application starting...')
// Initialize Themes (prod: 使用单独目录并从内置模板拷贝)
const themeDir = is.dev ? join(process.cwd(), 'themes') : join(dataRoot, 'themes')
if (!fs.existsSync(themeDir)) {
fs.mkdirSync(themeDir, { recursive: true })
}
if (!is.dev) {
try {
const existing = fs.readdirSync(themeDir).filter((f) => f.toLowerCase().endsWith('.json'))
if (existing.length === 0) {
const builtinThemeDir = join(app.getAppPath(), 'themes')
if (fs.existsSync(builtinThemeDir)) {
const files = fs
.readdirSync(builtinThemeDir)
.filter((f) => f.toLowerCase().endsWith('.json'))
for (const f of files) {
const src = join(builtinThemeDir, f)
const dest = join(themeDir, f)
try {
fs.copyFileSync(src, dest)
} catch (e: any) {
logger.warn?.('Failed to copy builtin theme', {
src,
dest,
message: e?.message
})
}
}
}
}
} catch (e: any) {
logger.warn?.('Failed to initialize theme directory', { message: e?.message })
}
}
const themeService = new ThemeService(themeDir)
themeService.init()
// Initialize DB
const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(app.getPath('userData'), 'db.sqlite');
const dbManager = new DbManager(dbPath);
const studentRepo = new StudentRepository(dbManager.getDb());
const reasonRepo = new ReasonRepository(dbManager.getDb());
const eventRepo = new EventRepository(dbManager.getDb());
const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite')
const dbManager = new DbManager(dbPath)
// Set logger level from settings
const logLevelSetting = dbManager
.getDb()
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
.get() as any
if (logLevelSetting) {
logger.setLevel(logLevelSetting.value as LogLevel)
}
process.on('uncaughtException', (err) => {
logger.error('uncaughtException', {
message: err?.message,
stack: err?.stack
})
})
process.on('unhandledRejection', (reason: any) => {
if (reason instanceof Error) {
logger.error('unhandledRejection', { message: reason.message, stack: reason.stack })
} else {
logger.error('unhandledRejection', reason)
}
})
app.on('render-process-gone', (_, __, details) => {
logger.error('render-process-gone', details)
})
app.on('child-process-gone', (_, details) => {
logger.error('child-process-gone', details)
})
const studentRepo = new StudentRepository(dbManager.getDb())
const reasonRepo = new ReasonRepository(dbManager.getDb())
const eventRepo = new EventRepository(dbManager.getDb())
const SETTINGS_SECURITY_ADMIN = 'security_admin_password'
const SETTINGS_SECURITY_POINTS = 'security_points_password'
const SETTINGS_SECURITY_RECOVERY = 'security_recovery_string'
const SETTINGS_SECURITY_IV = 'security_crypto_iv'
type PermissionLevel = 'admin' | 'points' | 'view'
const permissionRank: Record<PermissionLevel, number> = { view: 0, points: 1, admin: 2 }
const permissionsBySenderId = new Map<number, PermissionLevel>()
const getSetting = (key: string): string => {
const row = dbManager.getDb().prepare('SELECT value FROM settings WHERE key = ?').get(key) as
| { value?: string }
| undefined
return row?.value ?? ''
}
const setSetting = (key: string, value: string) => {
dbManager
.getDb()
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run(key, value)
}
const hasSecret = (key: string) => {
const v = getSetting(key)
return typeof v === 'string' && v.trim().length > 0
}
const ensureSecurityIv = () => {
let ivHex = getSetting(SETTINGS_SECURITY_IV)
if (!ivHex) {
ivHex = crypto.randomBytes(16).toString('hex')
setSetting(SETTINGS_SECURITY_IV, ivHex)
}
return ivHex
}
const getCryptoKey = () => {
return crypto.scryptSync(app.getPath('userData'), 'secscore-salt', 32)
}
const encryptSecret = (plainText: string) => {
const ivHex = ensureSecurityIv()
const key = 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
}
const decryptSecret = (cipherText: string) => {
try {
if (!cipherText) return ''
const ivHex = ensureSecurityIv()
const key = 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 ''
}
}
const shouldProtect = () =>
hasSecret(SETTINGS_SECURITY_ADMIN) || hasSecret(SETTINGS_SECURITY_POINTS)
const getDefaultPermission = (): PermissionLevel => {
return shouldProtect() ? 'view' : 'admin'
}
const getPermission = (senderId: number): PermissionLevel => {
const existing = permissionsBySenderId.get(senderId)
if (existing) return existing
const def = getDefaultPermission()
permissionsBySenderId.set(senderId, def)
return def
}
const setPermission = (senderId: number, level: PermissionLevel) => {
permissionsBySenderId.set(senderId, level)
}
const requirePermission = (event: any, required: PermissionLevel) => {
const senderId = event?.sender?.id
if (typeof senderId !== 'number') return false
const current = getPermission(senderId)
return permissionRank[current] >= permissionRank[required]
}
const isSixDigit = (s: string) => /^\d{6}$/.test(s)
const themeService = new ThemeService(themeDir, (senderId) => {
return permissionRank[getPermission(senderId)] >= permissionRank['admin']
})
themeService.init()
// Initialize Sync
const wsClient = new WsClient();
const syncEngine = new SyncEngine(wsClient, dbManager);
const wsClient = new WsClient()
const syncEngine = new SyncEngine(wsClient, dbManager)
const startSyncIfRemote = () => {
const syncMode = dbManager.getDb().prepare("SELECT value FROM settings WHERE key = 'sync_mode'").get() as any;
const wsServer = dbManager.getDb().prepare("SELECT value FROM settings WHERE key = 'ws_server'").get() as any;
if (syncMode?.value === 'remote' && wsServer?.value) {
wsClient.connect(wsServer.value);
} else {
wsClient.close();
}
};
const syncMode = dbManager
.getDb()
.prepare("SELECT value FROM settings WHERE key = 'sync_mode'")
.get() as any
const wsServer = dbManager
.getDb()
.prepare("SELECT value FROM settings WHERE key = 'ws_server'")
.get() as any
startSyncIfRemote();
if (syncMode?.value === 'remote' && wsServer?.value) {
wsClient.connect(wsServer.value)
} else {
wsClient.close()
}
}
startSyncIfRemote()
// 监听本地事件创建,触发同步
app.on('score-event-created' as any, () => {
syncEngine.startOutboxSync();
});
syncEngine.startOutboxSync()
})
// Student IPC
ipcMain.handle('db:student:query', async () => ({ success: true, data: studentRepo.findAll() }));
ipcMain.handle('db:student:create', async (_, data) => ({ success: true, data: studentRepo.create(data) }));
ipcMain.handle('db:student:update', async (_, id, data) => { studentRepo.update(id, data); return { success: true }; });
ipcMain.handle('db:student:delete', async (_, id) => { studentRepo.delete(id); return { success: true }; });
ipcMain.handle('db:student:query', async () => ({ success: true, data: studentRepo.findAll() }))
ipcMain.handle('db:student:create', async (event, data) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
return { success: true, data: studentRepo.create(data) }
})
ipcMain.handle('db:student:update', async (event, id, data) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
studentRepo.update(id, data)
return { success: true }
})
ipcMain.handle('db:student:delete', async (event, id) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
studentRepo.delete(id)
return { success: true }
})
// Reason IPC
ipcMain.handle('db:reason:query', async () => ({ success: true, data: reasonRepo.findAll() }));
ipcMain.handle('db:reason:create', async (_, data) => ({ success: true, data: reasonRepo.create(data) }));
ipcMain.handle('db:reason:update', async (_, id, data) => { reasonRepo.update(id, data); return { success: true }; });
ipcMain.handle('db:reason:delete', async (_, id) => { reasonRepo.delete(id); return { success: true }; });
ipcMain.handle('db:reason:query', async () => ({ success: true, data: reasonRepo.findAll() }))
ipcMain.handle('db:reason:create', async (event, data) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
return { success: true, data: reasonRepo.create(data) }
})
ipcMain.handle('db:reason:update', async (event, id, data) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
reasonRepo.update(id, data)
return { success: true }
})
ipcMain.handle('db:reason:delete', async (event, id) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
const changes = reasonRepo.delete(id)
if (!changes) return { success: false, message: '记录不存在' }
return { success: true, data: { changes } }
})
// 兼容前端 deleteReason 命名错误
ipcMain.handle('db:deleteReason', async (_, id) => { reasonRepo.delete(id); return { success: true }; });
ipcMain.handle('db:deleteReason', async (event, id) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
const changes = reasonRepo.delete(id)
if (!changes) return { success: false, message: '记录不存在' }
return { success: true, data: { changes } }
})
// Event IPC
ipcMain.handle('db:event:query', async (_, params) => ({ success: true, data: eventRepo.findAll(params?.limit) }));
ipcMain.handle('db:event:create', async (_, data) => {
ipcMain.handle('db:event:query', async (_, params) => {
try {
const id = eventRepo.create(data);
return { success: true, data: id };
} catch (e: any) {
return { success: false, message: e.message };
return { success: true, data: eventRepo.findAll(params?.limit) }
} catch (err: any) {
return { success: false, message: err.message }
}
});
})
ipcMain.handle('db:event:delete', async (event, uuid) => {
try {
if (!requirePermission(event, 'points'))
return { success: false, message: 'Permission denied' }
eventRepo.deleteByUuid(uuid)
return { success: true }
} catch (err: any) {
return { success: false, message: err.message }
}
})
ipcMain.handle('db:event:create', async (event, data) => {
try {
if (!requirePermission(event, 'points'))
return { success: false, message: 'Permission denied' }
const id = eventRepo.create(data)
return { success: true, data: id }
} catch (e: any) {
return { success: false, message: e.message }
}
})
ipcMain.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 db = dbManager.getDb()
let rows: any[]
if (startTime) {
rows = db
.prepare(
`SELECT * FROM score_events
WHERE student_name = ?
AND julianday(event_time) >= julianday(?)
ORDER BY event_time DESC
LIMIT ?`
)
.all(studentName, startTime, limit)
} else {
rows = db
.prepare(
`SELECT * FROM score_events
WHERE student_name = ?
ORDER BY event_time DESC
LIMIT ?`
)
.all(studentName, limit)
}
return { success: true, data: rows }
} catch (err: any) {
return { success: false, message: err.message }
}
})
ipcMain.handle('db:leaderboard:query', async (_, params) => {
try {
const range = String(params?.range ?? 'today')
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 db = dbManager.getDb()
const rows = db
.prepare(
`SELECT
s.id as id,
s.name as name,
s.score as score,
COALESCE(SUM(e.delta), 0) as range_change
FROM students s
LEFT JOIN score_events e
ON e.student_name = s.name
AND julianday(e.event_time) >= julianday(?)
GROUP BY s.id, s.name, s.score
ORDER BY s.score DESC, range_change DESC, s.name ASC`
)
.all(startTime)
return { success: true, data: { startTime, rows } }
} catch (err: any) {
return { success: false, message: err.message }
}
})
// Settings IPC
ipcMain.handle('db:getSettings', () => {
const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as { key: string, value: string }[];
const settings: Record<string, string> = {};
rows.forEach(r => settings[r.key] = r.value);
return { success: true, data: settings };
});
const rows = dbManager.getDb().prepare('SELECT key, value FROM settings').all() as {
key: string
value: string
}[]
const settings: Record<string, string> = {}
rows.forEach((r) => (settings[r.key] = r.value))
return { success: true, data: settings }
})
ipcMain.handle('db:updateSetting', (_event, key, value) => {
dbManager.getDb().prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
if (!requirePermission(_event, 'admin')) return { success: false, message: 'Permission denied' }
dbManager
.getDb()
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run(key, value)
if (key === 'sync_mode' || key === 'ws_server') {
startSyncIfRemote();
startSyncIfRemote()
}
return { success: true };
});
return { success: true }
})
ipcMain.handle('ws:getStatus', () => {
return {
@@ -142,13 +451,285 @@ app.whenReady().then(() => {
connected: wsClient.isConnected(),
lastSync: new Date().toISOString() // TODO: 记录真正的最后同步时间
}
};
});
}
})
ipcMain.handle('ws:triggerSync', async () => {
await syncEngine.triggerFullSync();
return { success: true };
});
ipcMain.handle('ws:triggerSync', async (event) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
await syncEngine.triggerFullSync()
return { success: true }
})
// Logger IPC
ipcMain.handle('log:query', (_, lines) => ({ success: true, data: logger.readLogs(lines) }))
ipcMain.handle('log:clear', (event) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
logger.clearLogs()
return { success: true }
})
ipcMain.handle('log:setLevel', (event, level: LogLevel) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
logger.setLevel(level)
dbManager
.getDb()
.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('log_level', level)
return { success: true }
})
ipcMain.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') {
logger.log(level as LogLevel, message, meta)
} else {
logger.info(message, meta)
}
return { success: true }
})
ipcMain.handle('auth:getStatus', (event) => {
const senderId = event?.sender?.id
const permission =
typeof senderId === 'number' ? getPermission(senderId) : getDefaultPermission()
return {
success: true,
data: {
permission,
hasAdminPassword: hasSecret(SETTINGS_SECURITY_ADMIN),
hasPointsPassword: hasSecret(SETTINGS_SECURITY_POINTS),
hasRecoveryString: hasSecret(SETTINGS_SECURITY_RECOVERY)
}
}
})
ipcMain.handle('auth:login', (event, password: string) => {
const senderId = event?.sender?.id
if (typeof senderId !== 'number') return { success: false, message: 'Invalid sender' }
if (!isSixDigit(String(password ?? ''))) {
setPermission(senderId, getDefaultPermission())
return { success: false, message: 'Invalid password format' }
}
const adminCipher = getSetting(SETTINGS_SECURITY_ADMIN)
const pointsCipher = getSetting(SETTINGS_SECURITY_POINTS)
const adminPlain = decryptSecret(adminCipher)
const pointsPlain = decryptSecret(pointsCipher)
if (adminCipher && adminPlain === password) {
setPermission(senderId, 'admin')
return { success: true, data: { permission: 'admin' as PermissionLevel } }
}
if (pointsCipher && pointsPlain === password) {
setPermission(senderId, 'points')
return { success: true, data: { permission: 'points' as PermissionLevel } }
}
setPermission(senderId, getDefaultPermission())
return { success: false, message: 'Password incorrect' }
})
ipcMain.handle('auth:logout', (event) => {
const senderId = event?.sender?.id
if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission())
return { success: true, data: { permission: getDefaultPermission() } }
})
ipcMain.handle(
'auth:setPasswords',
(event, payload: { adminPassword?: string | null; pointsPassword?: string | null }) => {
const alreadyHasAdmin = hasSecret(SETTINGS_SECURITY_ADMIN)
if (alreadyHasAdmin && !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) setSetting(SETTINGS_SECURITY_ADMIN, '')
else {
if (!isSixDigit(trimmed))
return { success: false, message: 'Admin password must be 6 digits' }
setSetting(SETTINGS_SECURITY_ADMIN, encryptSecret(trimmed))
}
}
if (typeof pointsPasswordRaw === 'string') {
const trimmed = pointsPasswordRaw.trim()
if (trimmed.length === 0) setSetting(SETTINGS_SECURITY_POINTS, '')
else {
if (!isSixDigit(trimmed))
return { success: false, message: 'Points password must be 6 digits' }
setSetting(SETTINGS_SECURITY_POINTS, encryptSecret(trimmed))
}
}
if (!hasSecret(SETTINGS_SECURITY_RECOVERY)) {
const recovery = crypto.randomBytes(18).toString('base64url')
setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(recovery))
return { success: true, data: { recoveryString: recovery } }
}
return { success: true, data: {} }
}
)
ipcMain.handle('auth:generateRecovery', (event) => {
if (hasSecret(SETTINGS_SECURITY_ADMIN) && !requirePermission(event, 'admin'))
return { success: false, message: 'Permission denied' }
const recovery = crypto.randomBytes(18).toString('base64url')
setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(recovery))
return { success: true, data: { recoveryString: recovery } }
})
ipcMain.handle('auth:resetByRecovery', (event, recoveryString: string) => {
const cipher = getSetting(SETTINGS_SECURITY_RECOVERY)
const plain = decryptSecret(cipher)
if (!plain || plain !== String(recoveryString ?? '').trim())
return { success: false, message: 'Recovery string incorrect' }
setSetting(SETTINGS_SECURITY_ADMIN, '')
setSetting(SETTINGS_SECURITY_POINTS, '')
const newRecovery = crypto.randomBytes(18).toString('base64url')
setSetting(SETTINGS_SECURITY_RECOVERY, encryptSecret(newRecovery))
const senderId = event?.sender?.id
if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission())
return { success: true, data: { recoveryString: newRecovery } }
})
ipcMain.handle('auth:clearAll', (event) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
setSetting(SETTINGS_SECURITY_ADMIN, '')
setSetting(SETTINGS_SECURITY_POINTS, '')
setSetting(SETTINGS_SECURITY_RECOVERY, '')
const senderId = event?.sender?.id
if (typeof senderId === 'number') setPermission(senderId, getDefaultPermission())
return { success: true }
})
ipcMain.handle('data:exportJson', (event) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
const db = dbManager.getDb()
const students = db
.prepare('SELECT id, name, score, extra_json, created_at, updated_at FROM students')
.all()
const reasons = db
.prepare(
'SELECT id, content, category, delta, is_system, updated_at, sync_state FROM reasons'
)
.all()
const events = db
.prepare(
'SELECT id, uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state, remote_id FROM score_events'
)
.all()
const settingsRows = db.prepare('SELECT key, value FROM settings').all() as {
key: string
value: string
}[]
const settings = settingsRows.filter((r) => !String(r.key).startsWith('security_'))
return { success: true, data: JSON.stringify({ students, reasons, events, settings }, null, 2) }
})
ipcMain.handle('data:importJson', (event, jsonText: string) => {
if (!requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' }
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 settings = Array.isArray(parsed?.settings) ? parsed.settings : []
const db = dbManager.getDb()
try {
db.transaction(() => {
db.prepare('DELETE FROM score_events').run()
db.prepare('DELETE FROM students').run()
db.prepare('DELETE FROM reasons').run()
db.prepare("DELETE FROM settings WHERE key NOT LIKE 'security_%'").run()
const insertStudent = db.prepare(
'INSERT INTO students (name, score, extra_json) VALUES (?, ?, ?)'
)
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
insertStudent.run(name, Number.isFinite(score) ? score : 0, extraJson)
}
const insertReason = db.prepare(
'INSERT OR REPLACE INTO reasons (content, category, delta, is_system, sync_state) VALUES (?, ?, ?, ?, ?)'
)
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 syncState = Number(r?.sync_state ?? 0) ? 1 : 0
insertReason.run(
content,
category,
Number.isFinite(delta) ? delta : 0,
isSystem,
syncState
)
}
const insertEvent = db.prepare(
'INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state, remote_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
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 syncState = Number(e?.sync_state ?? 0) ? 1 : 0
const remoteId = e?.remote_id != null ? String(e.remote_id) : null
insertEvent.run(
uuid,
studentName,
reasonContent,
Number.isFinite(delta) ? delta : 0,
Number.isFinite(valPrev) ? valPrev : 0,
Number.isFinite(valCurr) ? valCurr : 0,
eventTime,
syncState,
remoteId
)
}
const insertSetting = db.prepare(
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'
)
for (const it of settings) {
const key = String(it?.key ?? '').trim()
if (!key || key.startsWith('security_')) continue
insertSetting.run(key, String(it?.value ?? ''))
}
})()
} catch (e: any) {
return { success: false, message: e?.message || 'Import failed' }
}
startSyncIfRemote()
return { success: true }
})
createWindow()
+72 -33
View File
@@ -1,70 +1,109 @@
import { Database } from 'better-sqlite3';
import { v4 as uuidv4 } from 'uuid';
import { Database } from 'better-sqlite3'
import { app } from 'electron'
import { v4 as uuidv4 } from 'uuid'
export interface ScoreEvent {
id: number;
uuid: string;
student_name: string;
reason_content: string;
delta: number;
val_prev: number;
val_curr: number;
event_time: string;
sync_state: number;
remote_id?: string;
id: number
uuid: string
student_name: string
reason_content: string
delta: number
val_prev: number
val_curr: number
event_time: string
sync_state: number
remote_id?: string
}
export class EventRepository {
constructor(private db: Database) {}
findAll(limit = 100) {
return this.db.prepare(`
return this.db
.prepare(
`
SELECT * FROM score_events
ORDER BY event_time DESC
LIMIT ?
`).all(limit);
`
)
.all(limit)
}
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');
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')
const val_prev = student.score;
const val_curr = val_prev + event.delta;
const uuid = uuidv4();
const event_time = new Date().toISOString();
const val_prev = student.score
const val_curr = val_prev + event.delta
const uuid = uuidv4()
const event_time = new Date().toISOString()
// 2. Insert event
const info = this.db.prepare(`
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(uuid, event.student_name, event.reason_content, event.delta, val_prev, val_curr, event_time);
`
)
.run(
uuid,
event.student_name,
event.reason_content,
event.delta,
val_prev,
val_curr,
event_time
)
// 3. Update student score
this.db.prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?')
.run(val_curr, event.student_name);
this.db
.prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?')
.run(val_curr, event.student_name)
return info.lastInsertRowid as number;
})();
return info.lastInsertRowid as number
})()
// 触发同步 (如果已连接)
// @ts-ignore
const { app } = require('electron');
if (app) {
app.emit('score-event-created');
app.emit('score-event-created')
}
return lastInsertRowid;
return lastInsertRowid
}
getUnsynced() {
return this.db.prepare('SELECT * FROM score_events WHERE sync_state = 0').all() as ScoreEvent[];
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);
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 FROM score_events WHERE uuid = ?')
.get(uuid) as { student_name: string; delta: number }
if (!event) return
// 2. Revert student score
this.db
.prepare(
'UPDATE students SET score = score - ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
)
.run(event.delta, event.student_name)
// 3. Delete event
this.db.prepare('DELETE FROM score_events WHERE uuid = ?').run(uuid)
})()
}
}
+25 -20
View File
@@ -1,42 +1,47 @@
import { Database } from 'better-sqlite3';
import { Database } from 'better-sqlite3'
export interface Reason {
id: number;
content: string;
category: string;
delta: number;
is_system: number;
sync_state: number;
id: number
content: string
category: string
delta: number
is_system: number
sync_state: number
}
export class ReasonRepository {
constructor(private db: Database) {}
findAll() {
return this.db.prepare('SELECT * FROM reasons ORDER BY category ASC, content ASC').all() as Reason[];
return this.db
.prepare('SELECT * FROM reasons ORDER BY category ASC, content ASC')
.all() as Reason[]
}
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;
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
}
update(id: number, reason: Partial<Reason>) {
const sets: string[] = [];
const vals: any[] = [];
const sets: string[] = []
const vals: any[] = []
Object.entries(reason).forEach(([key, val]) => {
if (key !== 'id') {
sets.push(`${key} = ?`);
vals.push(val);
sets.push(`${key} = ?`)
vals.push(val)
}
});
vals.push(id);
this.db.prepare(`UPDATE reasons SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(...vals);
})
vals.push(id)
this.db
.prepare(`UPDATE reasons SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
.run(...vals)
}
delete(id: number) {
this.db.prepare('DELETE FROM reasons WHERE id = ? AND is_system = 0').run(id);
const info = this.db.prepare('DELETE FROM reasons WHERE id = ?').run(id)
return info.changes
}
}
+28 -20
View File
@@ -1,43 +1,51 @@
import { Database } from 'better-sqlite3';
import { Database } from 'better-sqlite3'
export interface Student {
id: number;
name: string;
score: number;
extra_json?: string;
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[];
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;
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[] = [];
const sets: string[] = []
const vals: any[] = []
Object.entries(student).forEach(([key, val]) => {
if (key !== 'id') {
sets.push(`${key} = ?`);
vals.push(val);
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);
})
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_id = ?').run(id);
this.db.prepare('DELETE FROM students WHERE id = ?').run(id);
})();
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)
})()
}
}
+77
View File
@@ -0,0 +1,77 @@
import fs from 'fs'
import path from 'path'
export type LogLevel = 'info' | 'warn' | 'error' | 'debug'
export class LoggerService {
private logPath: string
private currentLevel: LogLevel = 'info'
constructor(logDir: string) {
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
this.logPath = path.join(logDir, 'app.log')
}
setLevel(level: LogLevel) {
this.currentLevel = 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)
}
}
info(message: string, ...args: any[]) {
this.log('info', message, ...args)
}
warn(message: string, ...args: any[]) {
this.log('warn', message, ...args)
}
error(message: string, ...args: any[]) {
this.log('error', message, ...args)
}
debug(message: string, ...args: any[]) {
this.log('debug', message, ...args)
}
getLogPath() {
return this.logPath
}
clearLogs() {
try {
fs.writeFileSync(this.logPath, '')
} catch (err) {
console.error('Failed to clear logs', 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)
} catch (err) {
console.error('Failed to read logs', err)
return []
}
}
}
+74 -60
View File
@@ -1,55 +1,47 @@
import { WsClient } from './WsClient';
import { DbManager } from '../db/DbManager';
import { StudentRepository } from '../repos/StudentRepository';
import { EventRepository } from '../repos/EventRepository';
import { ReasonRepository } from '../repos/ReasonRepository';
import { WsClient } from './WsClient'
import { DbManager } from '../db/DbManager'
export class SyncEngine {
private wsClient: WsClient;
private db: DbManager;
private _studentRepo: StudentRepository;
private _eventRepo: EventRepository;
private _reasonRepo: ReasonRepository;
private isSyncing: boolean = false;
private wsClient: WsClient
private db: DbManager
private isSyncing: boolean = false
constructor(wsClient: WsClient, db: DbManager) {
this.wsClient = wsClient;
this.db = db;
this._studentRepo = new StudentRepository(db.getDb());
this._eventRepo = new EventRepository(db.getDb());
this._reasonRepo = new ReasonRepository(db.getDb());
this.wsClient = wsClient
this.db = db
this.init();
this.init()
}
private init() {
// 监听 WS 事件
this.wsClient.on('connected', () => {
this.syncAll();
});
this.syncAll()
})
this.wsClient.on('event', (msg) => {
this.handleRemoteEvent(msg);
});
this.handleRemoteEvent(msg)
})
this.wsClient.on('reason_sync', (msg) => {
this.handleReasonSync(msg);
});
this.handleReasonSync(msg)
})
}
// 启动同步任务(处理 Outbox)
async startOutboxSync() {
if (this.isSyncing) return;
this.isSyncing = true;
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[];
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;
if (!this.wsClient.isConnected()) break
try {
await this.wsClient.send({
@@ -61,88 +53,110 @@ export class SyncEngine {
delta: event.delta,
event_time: event.event_time
}
});
})
// 标记为已同步
this.db.getDb().prepare(
'UPDATE score_events SET sync_state = 1 WHERE id = ?'
).run(event.id);
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);
console.error(`[Sync] Failed to sync event ${event.uuid}`, e)
}
}
} finally {
this.isSyncing = false;
this.isSyncing = false
}
}
// 触发全量同步
async triggerFullSync() {
await this.syncAll();
await this.syncAll()
}
// 全量对齐
private async syncAll() {
if (!this.wsClient.isConnected()) return;
if (!this.wsClient.isConnected()) return
console.log('[Sync] Starting full sync...');
console.log('[Sync] Starting full sync...')
try {
// 1. 同步理由
const resReasons = await this.wsClient.send({ type: 'query_reasons' });
const resReasons = await this.wsClient.send({ type: 'query_reasons' })
if (resReasons.data) {
this.handleReasonSync(resReasons);
this.handleReasonSync(resReasons)
}
// 2. 同步学生分值 (Local-first: 本地值为准,除非服务器有更正)
const _resStudents = await this.wsClient.send({ type: 'query_students' });
await this.wsClient.send({ type: 'query_students' })
// TODO: 实现更复杂的对齐逻辑
// 3. 处理 Outbox
await this.startOutboxSync();
console.log('[Sync] Full sync completed');
await this.startOutboxSync()
console.log('[Sync] Full sync completed')
} catch (e) {
console.error('[Sync] Full sync failed', e);
console.error('[Sync] Full sync failed', e)
}
}
private handleRemoteEvent(msg: any) {
// 远程推送的事件,通常是其他客户端的操作
// 本地需要根据此事件更新分值,但不要再产生新的同步事件(避免循环)
const { data } = msg;
if (!data) return;
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(
'UPDATE students SET score = score + ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
)
.run(data.delta, data.student_name)
// 记录事件(标记为已同步,防止回环)
this.db.getDb().prepare(`
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);
})();
`
)
.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;
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(`
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);
`
)
.run(r.content, category, r.delta, r.is_system ? 1 : 0)
}
})();
})()
}
}
+58 -52
View File
@@ -1,98 +1,104 @@
import { ipcMain, BrowserWindow } from 'electron';
import fs from 'fs';
import path from 'path';
import chokidar from 'chokidar';
import { ipcMain, BrowserWindow } from 'electron'
import fs from 'fs'
import path from 'path'
import { FSWatcher, watch } from 'chokidar'
export interface ThemeConfig {
name: string;
id: string;
mode: 'light' | 'dark';
name: string
id: string
mode: 'light' | 'dark'
config: {
tdesign: Record<string, string>;
custom: Record<string, string>;
};
tdesign: Record<string, string>
custom: Record<string, string>
}
}
export class ThemeService {
private themeDir: string;
private watcher: chokidar.FSWatcher | null = null;
private currentThemeId: string = 'light-default';
private themeDir: string
private watcher: FSWatcher | null = null
private currentThemeId: string = 'light-default'
private canSetTheme: ((senderId: number) => boolean) | null = null
constructor(themeDir: string) {
this.themeDir = themeDir;
constructor(themeDir: string, canSetTheme?: (senderId: number) => boolean) {
this.themeDir = themeDir
this.canSetTheme = canSetTheme || null
}
public init() {
this.setupWatcher();
this.registerIpc();
this.setupWatcher()
this.registerIpc()
}
private setupWatcher() {
if (this.watcher) this.watcher.close();
this.watcher = chokidar.watch(this.themeDir, {
ignored: /(^|[\/\\])\../,
if (this.watcher) this.watcher.close()
this.watcher = watch(this.themeDir, {
ignored: /(^|[/\\])\../,
persistent: true
});
})
this.watcher.on('change', (filePath) => {
if (filePath.endsWith('.json')) {
console.log(`Theme file changed: ${filePath}`);
this.notifyThemeUpdate();
console.log(`Theme file changed: ${filePath}`)
this.notifyThemeUpdate()
}
});
})
}
private registerIpc() {
ipcMain.handle('theme:list', async () => {
return { success: true, data: this.getThemeList() };
});
return { success: true, data: this.getThemeList() }
})
ipcMain.handle('theme:current', async () => {
const theme = this.getThemeById(this.currentThemeId);
return { success: true, data: theme };
});
const theme = this.getThemeById(this.currentThemeId)
return { success: true, data: theme }
})
ipcMain.handle('theme:set', async (_, themeId: string) => {
this.currentThemeId = themeId;
this.notifyThemeUpdate();
return { success: true };
});
ipcMain.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' }
}
this.currentThemeId = themeId
this.notifyThemeUpdate()
return { success: true }
})
}
private getThemeList(): ThemeConfig[] {
try {
if (!fs.existsSync(this.themeDir)) return [];
const files = fs.readdirSync(this.themeDir);
if (!fs.existsSync(this.themeDir)) return []
const files = fs.readdirSync(this.themeDir)
return files
.filter(f => f.endsWith('.json'))
.map(f => {
.filter((f) => f.endsWith('.json'))
.map((f) => {
try {
const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8');
return JSON.parse(content) as ThemeConfig;
} catch (e) {
return null;
const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8')
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);
return [];
console.error('Failed to read themes:', e)
return []
}
}
private getThemeById(id: string): ThemeConfig | null {
const list = this.getThemeList();
return list.find(t => t.id === id) || list[0] || null;
const list = this.getThemeList()
return list.find((t) => t.id === id) || list[0] || null
}
private notifyThemeUpdate() {
const theme = this.getThemeById(this.currentThemeId);
if (!theme) return;
const theme = this.getThemeById(this.currentThemeId)
if (!theme) return
const windows = BrowserWindow.getAllWindows();
const windows = BrowserWindow.getAllWindows()
for (const win of windows) {
win.webContents.send('theme:updated', theme);
win.webContents.send('theme:updated', theme)
}
}
}
+70 -63
View File
@@ -1,135 +1,142 @@
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter } from 'events';
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: Function, reject: Function, timeout: NodeJS.Timeout }> = new Map();
private isManualClose: boolean = false;
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();
super()
}
connect(url: string) {
this.url = url;
this.isManualClose = false;
this._connect();
this.url = url
this.isManualClose = false
this._connect()
}
private _connect() {
if (this.ws) {
this.ws.terminate();
this.ws.terminate()
}
console.log(`[WS] Connecting to ${this.url}...`);
this.ws = new WebSocket(this.url);
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();
console.log('[WS] Connected')
this.emit('connected')
this.startHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
});
})
this.ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
this.handleMessage(msg);
const msg = JSON.parse(data.toString())
this.handleMessage(msg)
} catch (e) {
console.error('[WS] Parse error', e);
console.error('[WS] Parse error', e)
}
});
})
this.ws.on('close', () => {
console.log('[WS] Closed');
this.stopHeartbeat();
this.emit('disconnected');
console.log('[WS] Closed')
this.stopHeartbeat()
this.emit('disconnected')
if (!this.isManualClose) {
this.reconnect();
this.reconnect()
}
});
})
this.ws.on('error', (err) => {
console.error('[WS] 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;
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);
this.emit('event', msg)
} else if (msg.type === 'reason_sync') {
this.emit('reason_sync', msg);
this.emit('reason_sync', msg)
}
}
private reconnect() {
if (this.reconnectTimer) return;
if (this.reconnectTimer) return
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect();
}, 5000);
this.reconnectTimer = null
this._connect()
}, 5000)
}
private startHeartbeat() {
this.stopHeartbeat();
this.stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
this.send({ type: 'heartbeat' }).catch(() => {});
}, 30000);
this.send({ type: 'heartbeat' }).catch(() => {})
}, 30000)
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
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'));
return reject(new Error('WebSocket not connected'))
}
const seq = uuidv4();
const payload = { ...data, seq };
const seq = uuidv4()
const payload = { ...data, seq }
const timeout = setTimeout(() => {
this.pendingRequests.delete(seq);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.delete(seq)
reject(new Error('Request timeout'))
}, timeoutMs)
this.pendingRequests.set(seq, { resolve, reject, timeout });
this.ws.send(JSON.stringify(payload));
});
this.pendingRequests.set(seq, { resolve, reject, timeout })
this.ws.send(JSON.stringify(payload))
})
}
close() {
this.isManualClose = true;
this.stopHeartbeat();
this.isManualClose = true
this.stopHeartbeat()
if (this.ws) {
this.ws.close();
this.ws = null;
this.ws.close()
this.ws = null
}
}
isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
return this.ws?.readyState === WebSocket.OPEN
}
}
+2
View File
@@ -1,7 +1,9 @@
import { ElectronApi } from './types'
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: ElectronApi
}
}
+28 -1
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { ThemeConfig } from './types'
const api = {
@@ -22,21 +23,47 @@ const api = {
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
createReason: (data: any) => ipcRenderer.invoke('db:reason:create', data),
updateReason: (id: number, data: any) => ipcRenderer.invoke('db:reason:update', id, data),
deleteReason: (id: number) => ipcRenderer.invoke('db:deleteReason', id),
deleteReason: (id: number) => ipcRenderer.invoke('db:reason:delete', id),
// DB - Event
queryEvents: (params: any) => ipcRenderer.invoke('db:event:query', params),
createEvent: (data: any) => ipcRenderer.invoke('db:event:create', data),
deleteEvent: (uuid: string) => ipcRenderer.invoke('db:event:delete', uuid),
queryEventsByStudent: (params: any) => ipcRenderer.invoke('db:event:queryByStudent', params),
queryLeaderboard: (params: any) => ipcRenderer.invoke('db:leaderboard:query', 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'),
// Auth & Security
authGetStatus: () => ipcRenderer.invoke('auth:getStatus'),
authLogin: (password: string) => ipcRenderer.invoke('auth:login', password),
authLogout: () => ipcRenderer.invoke('auth:logout'),
authSetPasswords: (payload: { adminPassword?: string | null; pointsPassword?: string | null }) =>
ipcRenderer.invoke('auth:setPasswords', payload),
authGenerateRecovery: () => ipcRenderer.invoke('auth:generateRecovery'),
authResetByRecovery: (recoveryString: string) =>
ipcRenderer.invoke('auth:resetByRecovery', recoveryString),
authClearAll: () => ipcRenderer.invoke('auth:clearAll'),
// Data import/export
exportDataJson: () => ipcRenderer.invoke('data:exportJson'),
importDataJson: (jsonText: string) => ipcRenderer.invoke('data:importJson', jsonText),
// Logger
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
clearLogs: () => ipcRenderer.invoke('log:clear'),
setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level),
writeLog: (payload: { level: string; message: string; meta?: any }) =>
ipcRenderer.invoke('log:write', payload)
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
+74 -28
View File
@@ -1,45 +1,91 @@
export interface IpcResponse<T = any> {
success: boolean;
data?: T;
message?: string;
success: boolean
data?: T
message?: string
}
export interface ThemeConfig {
name: string;
id: string;
mode: 'light' | 'dark';
name: string
id: string
mode: 'light' | 'dark'
config: {
tdesign: Record<string, string>;
custom: Record<string, string>;
};
tdesign: Record<string, string>
custom: Record<string, string>
}
}
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[]>>;
createEvent: (data: { student_name: string; reason_content: string; delta: number }) => Promise<IpcResponse<number>>;
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>>
queryEventsByStudent: (params: {
student_name: string
limit?: number
startTime?: string | null
}) => Promise<IpcResponse<any[]>>
queryLeaderboard: (params: {
range: 'today' | 'week' | 'month'
}) => Promise<IpcResponse<{ startTime: string; rows: any[] }>>
// 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>>;
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>>
// Auth & Security
authGetStatus: () => Promise<
IpcResponse<{
permission: 'admin' | 'points' | 'view'
hasAdminPassword: boolean
hasPointsPassword: boolean
hasRecoveryString: boolean
}>
>
authLogin: (password: string) => Promise<IpcResponse<{ permission: 'admin' | 'points' | 'view' }>>
authLogout: () => Promise<IpcResponse<{ permission: 'admin' | 'points' | 'view' }>>
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>>
// Data import/export
exportDataJson: () => Promise<IpcResponse<string>>
importDataJson: (jsonText: string) => Promise<IpcResponse<void>>
// Logger
queryLogs: (lines?: number) => Promise<IpcResponse<string[]>>
clearLogs: () => Promise<IpcResponse<void>>
setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') => Promise<IpcResponse<void>>
writeLog: (payload: {
level: 'debug' | 'info' | 'warn' | 'error'
message: string
meta?: any
}) => Promise<IpcResponse<void>>
}
+176 -37
View File
@@ -1,22 +1,31 @@
import { Layout, Menu, Space } from 'tdesign-react'
import { Layout, Menu, Space, Dialog, Input, Button, Tag, MessagePlugin } from 'tdesign-react'
import { useState, useEffect } from 'react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon } from 'tdesign-icons-react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react'
import { StudentManager } from './components/StudentManager'
import { Settings } from './components/Settings'
import { EventHistory } from './components/EventHistory'
import { ReasonManager } from './components/ReasonManager'
import { ScoreManager } from './components/ScoreManager'
import { Leaderboard } from './components/Leaderboard'
import { Wizard } from './components/Wizard'
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
import { ThemeProvider } from './contexts/ThemeContext'
import WavyLines from './assets/wavy-lines.svg'
const { Header, Content, Aside } = Layout
function MainContent(): React.JSX.Element {
const [activeMenu, setActiveMenu] = useState('students')
const [activeMenu, setActiveMenu] = useState('score')
const [wizardVisible, setWizardVisible] = useState(false)
const { currentTheme } = useTheme()
const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view')
const [hasAnyPassword, setHasAnyPassword] = useState(false)
const [authVisible, setAuthVisible] = useState(false)
const [authPassword, setAuthPassword] = useState('')
const [authLoading, setAuthLoading] = useState(false)
const [appearanceBackground, setAppearanceBackground] = useState<'none' | 'wavy-lines'>('none')
const [appearanceBlur, setAppearanceBlur] = useState(0)
useEffect(() => {
const checkWizard = async () => {
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') {
setWizardVisible(true)
@@ -25,24 +34,115 @@ function MainContent(): React.JSX.Element {
checkWizard()
}, [])
const renderContent = () => {
switch (activeMenu) {
case 'students':
return <StudentManager />
case 'history':
return <EventHistory />
case 'reasons':
return <ReasonManager />
case 'settings':
return <Settings />
default:
return <StudentManager />
useEffect(() => {
const loadAuthAndSettings = async () => {
if (!(window as any).api) return
const authRes = await (window as any).api.authGetStatus()
if (authRes?.success && authRes.data) {
setPermission(authRes.data.permission)
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
setHasAnyPassword(anyPwd)
if (anyPwd && authRes.data.permission === 'view') setAuthVisible(true)
}
const settingsRes = await (window as any).api.getSettings()
if (settingsRes?.success && settingsRes.data) {
const bg = settingsRes.data.appearance_background
const blur = Number(settingsRes.data.appearance_background_blur || 0)
setAppearanceBackground(bg === 'wavy-lines' ? 'wavy-lines' : 'none')
setAppearanceBlur(Number.isFinite(blur) ? blur : 0)
}
}
loadAuthAndSettings()
const onSettingsUpdated = (e: any) => {
const key = e?.detail?.key
const value = e?.detail?.value
if (key === 'appearance_background') {
setAppearanceBackground(value === 'wavy-lines' ? 'wavy-lines' : 'none')
}
if (key === 'appearance_background_blur') {
const blur = Number(value || 0)
setAppearanceBlur(Number.isFinite(blur) ? blur : 0)
}
}
window.addEventListener('ss:settings-updated', onSettingsUpdated as any)
return () => window.removeEventListener('ss:settings-updated', onSettingsUpdated as any)
}, [])
const login = async () => {
if (!(window as any).api) return
setAuthLoading(true)
const res = await (window as any).api.authLogin(authPassword)
setAuthLoading(false)
if (res.success && res.data) {
setPermission(res.data.permission)
setAuthVisible(false)
setAuthPassword('')
MessagePlugin.success('权限已解锁')
} else {
MessagePlugin.error(res.message || '密码错误')
}
}
const logout = async () => {
if (!(window as any).api) return
const res = await (window as any).api.authLogout()
if (res?.success && res.data) {
setPermission(res.data.permission)
MessagePlugin.success('已切换为只读')
}
}
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 'reasons':
return <ReasonManager canEdit={permission === 'admin'} />
case 'settings':
return <Settings permission={permission} />
default:
return <ScoreManager canEdit={permission === 'admin' || permission === 'points'} />
}
}
const permissionTag = (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
)
return (
<Layout style={{ height: '100vh', backgroundColor: 'var(--ss-bg-color)' }}>
<Aside style={{ backgroundColor: 'var(--ss-sidebar-bg)', borderRight: '1px solid var(--ss-border-color)' }}>
{appearanceBackground !== 'none' && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: -1,
backgroundImage: `url(${WavyLines})`,
backgroundRepeat: 'repeat',
backgroundSize: '600px auto',
opacity: 0.35,
filter: appearanceBlur > 0 ? `blur(${appearanceBlur}px)` : undefined,
transform: appearanceBlur > 0 ? 'scale(1.05)' : undefined
}}
/>
)}
<Aside
style={{
backgroundColor: 'var(--ss-sidebar-bg)',
borderRight: '1px solid var(--ss-border-color)'
}}
>
<div style={{ padding: '24px', textAlign: 'center' }}>
<h2 style={{ color: 'var(--ss-text-main)', margin: 0 }}>SecScore</h2>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}></div>
@@ -52,32 +152,71 @@ function MainContent(): React.JSX.Element {
onChange={(v) => setActiveMenu(v as string)}
style={{ width: '100%', border: 'none' }}
>
<Menu.MenuItem value="students" icon={<UserIcon />}></Menu.MenuItem>
<Menu.MenuItem value="history" icon={<HistoryIcon />}></Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />}></Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />}></Menu.MenuItem>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="score" icon={<HistoryIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="leaderboard" icon={<ViewListIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="reasons" icon={<RootListIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
<Menu.MenuItem value="settings" icon={<SettingIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>
</Menu>
</Aside>
<Layout>
<Header style={{
backgroundColor: 'var(--ss-header-bg)',
borderBottom: '1px solid var(--ss-border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '0 24px'
}}>
<Header
style={{
backgroundColor: 'var(--ss-header-bg)',
borderBottom: '1px solid var(--ss-border-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '0 24px'
}}
>
<Space>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '14px' }}>
: {currentTheme?.name}
</span>
{permissionTag}
{hasAnyPassword && (
<>
<Button size="small" variant="outline" onClick={() => setAuthVisible(true)}>
</Button>
<Button size="small" variant="outline" theme="danger" onClick={logout}>
</Button>
</>
)}
</Space>
</Header>
<Content style={{ overflowY: 'auto' }}>
{renderContent()}
</Content>
<Content style={{ overflowY: 'auto' }}>{renderContent()}</Content>
</Layout>
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Dialog
header="权限解锁"
visible={authVisible}
onClose={() => setAuthVisible(false)}
onConfirm={login}
confirmBtn={{ content: '解锁', loading: authLoading }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
6 ==
</div>
<Input
value={authPassword}
onChange={(v) => setAuthPassword(v)}
placeholder="例如 123456"
maxlength={6}
/>
</div>
</Dialog>
</Layout>
)
}
+1 -1
View File
@@ -8,4 +8,4 @@ body,
margin: 0;
padding: 0;
overflow: hidden;
}
}
+30 -29
View File
@@ -1,40 +1,41 @@
import React, { useState, useEffect } from 'react';
import { Table, PrimaryTableCol, Tag } from 'tdesign-react';
import React, { useState, useEffect } from 'react'
import { Table, PrimaryTableCol, Tag } from 'tdesign-react'
interface ScoreEvent {
id: number;
uuid: string;
student_name: string;
reason_content: string;
delta: number;
val_prev: number;
val_curr: number;
event_time: string;
id: number
uuid: string
student_name: string
reason_content: string
delta: number
val_prev: number
val_curr: number
event_time: string
}
export const EventHistory: React.FC = () => {
const [data, setData] = useState<ScoreEvent[]>([]);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ScoreEvent[]>([])
const [loading, setLoading] = useState(false)
const fetchEvents = async () => {
setLoading(true);
const res = await (window as any).api.queryEvents({ limit: 100 });
if (!(window as any).api) return
setLoading(true)
const res = await (window as any).api.queryEvents({ limit: 100 })
if (res.success && res.data) {
setData(res.data);
setData(res.data)
}
setLoading(false);
};
setLoading(false)
}
useEffect(() => {
fetchEvents();
}, []);
fetchEvents()
}, [])
const columns: PrimaryTableCol<ScoreEvent>[] = [
{ colKey: 'student_name', title: '学生姓名', width: 120 },
{ colKey: 'reason_content', title: '积分理由', width: 200 },
{
colKey: 'delta',
title: '分值变动',
{
colKey: 'delta',
title: '分值变动',
width: 100,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
@@ -44,13 +45,13 @@ export const EventHistory: React.FC = () => {
},
{ colKey: 'val_prev', title: '原分值', width: 100 },
{ colKey: 'val_curr', title: '新分值', width: 100 },
{
colKey: 'event_time',
title: '发生时间',
{
colKey: 'event_time',
title: '发生时间',
width: 180,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
},
];
}
]
return (
<div style={{ padding: '24px' }}>
@@ -65,5 +66,5 @@ export const EventHistory: React.FC = () => {
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
</div>
);
};
)
}
+238
View File
@@ -0,0 +1,238 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Tag,
Button,
Select,
Space,
Card,
MessagePlugin,
DialogPlugin
} from 'tdesign-react'
import { ViewListIcon, DownloadIcon } from 'tdesign-icons-react'
interface StudentRank {
id: number
name: string
score: number
range_change: number
}
export const Leaderboard: React.FC = () => {
const [data, setData] = useState<StudentRank[]>([])
const [loading, setLoading] = useState(false)
const [timeRange, setTimeRange] = useState('today')
const [startTime, setStartTime] = useState<string | null>(null)
const fetchRankings = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
const res = await (window as any).api.queryLeaderboard({ range: timeRange })
if (res.success && res.data) {
setStartTime(res.data.startTime)
setData(res.data.rows)
}
setLoading(false)
}, [timeRange])
useEffect(() => {
fetchRankings()
}, [fetchRankings])
useEffect(() => {
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === 'events' || category === 'students' || category === 'all') fetchRankings()
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchRankings])
const handleViewHistory = async (studentName: string) => {
if (!(window as any).api) return
const res = await (window as any).api.queryEventsByStudent({
student_name: studentName,
limit: 200,
startTime
})
if (!res.success) {
MessagePlugin.error(res.message || '查询失败')
return
}
const lines = (res.data || []).map((e: any) => {
const time = new Date(e.event_time).toLocaleString()
const delta = e.delta > 0 ? `+${e.delta}` : String(e.delta)
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: '关闭'
})
}
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 link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `排行榜_${timeRange}_${new Date().toLocaleDateString()}.xls`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
MessagePlugin.success('导出成功')
}
const columns: PrimaryTableCol<StudentRank>[] = [
{
colKey: 'rank',
title: '排名',
width: 70,
align: 'center',
cell: ({ rowIndex }) => {
const rank = rowIndex + 1
let color = 'inherit'
if (rank === 1) color = '#FFD700'
if (rank === 2) color = '#C0C0C0'
if (rank === 3) color = '#CD7F32'
return (
<span style={{ fontWeight: 'bold', color, fontSize: rank <= 3 ? '18px' : '14px' }}>
{rank}
</span>
)
}
},
{ colKey: 'name', title: '姓名', width: 120 },
{
colKey: 'score',
title: '总积分',
width: 100,
cell: ({ row }) => <span style={{ fontWeight: 'bold' }}>{row.score}</span>
},
{
colKey: 'range_change',
title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
width: 100,
cell: ({ row }) => (
<Tag
theme={row.range_change > 0 ? 'success' : row.range_change < 0 ? 'danger' : 'default'}
variant="light"
>
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
</Tag>
)
},
{
colKey: 'operation',
title: '操作记录',
width: 100,
cell: ({ row }) => (
<Button
variant="text"
theme="primary"
icon={<ViewListIcon />}
onClick={() => handleViewHistory(row.name)}
>
</Button>
)
}
]
return (
<div style={{ padding: '24px' }}>
<div
style={{
marginBottom: '24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Space>
<Select
value={timeRange}
onChange={(v) => setTimeRange(v as string)}
style={{ width: '120px' }}
>
<Select.Option value="today" label="今天" />
<Select.Option value="week" label="本周" />
<Select.Option value="month" label="本月" />
</Select>
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExport}>
CSV
</Button>
<Button variant="outline" icon={<DownloadIcon />} onClick={handleExportExcel}>
Excel
</Button>
</Space>
</div>
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
hover
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
</div>
)
}
+108 -49
View File
@@ -1,64 +1,121 @@
import React, { useState, useEffect } from 'react';
import { Table, PrimaryTableCol, Button, Space, Dialog, Form, Input, MessagePlugin, Tag } from 'tdesign-react';
import React, { useState, useEffect, useCallback } from 'react'
import {
Table,
PrimaryTableCol,
Button,
Space,
Dialog,
Form,
Input,
MessagePlugin,
Tag,
DialogPlugin
} from 'tdesign-react'
interface Reason {
id: number;
content: string;
category: string;
delta: number;
is_system: number;
id: number
content: string
category: string
delta: number
is_system: number
}
export const ReasonManager: React.FC = () => {
const [data, setData] = useState<Reason[]>([]);
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<Reason[]>([])
const [loading, setLoading] = useState(false)
const [visible, setVisible] = useState(false)
const [form] = Form.useForm()
const fetchReasons = async () => {
setLoading(true);
const res = await (window as any).api.queryReasons();
const emitDataUpdated = (category: 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
const fetchReasons = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
const res = await (window as any).api.queryReasons()
if (res.success && res.data) {
setData(res.data);
setData(res.data)
}
setLoading(false);
};
setLoading(false)
}, [])
useEffect(() => {
fetchReasons();
}, []);
fetchReasons()
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === 'reasons' || category === 'all') fetchReasons()
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchReasons])
const handleAdd = async () => {
const values = form.getFieldsValue?.(true) as any;
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
const values = form.getFieldsValue?.(true) as any
const res = await (window as any).api.createReason({
...values,
delta: Number(values.delta)
});
})
if (res.success) {
MessagePlugin.success('添加成功');
setVisible(false);
form.reset();
fetchReasons();
MessagePlugin.success('添加成功')
setVisible(false)
form.reset()
fetchReasons()
emitDataUpdated('reasons')
} else {
MessagePlugin.error(res.message || '添加失败')
}
};
}
const handleDelete = async (id: number) => {
const res = await (window as any).api.deleteReason(id);
if (res.success) {
MessagePlugin.success('删除成功');
fetchReasons();
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()
}
})
}
const columns: PrimaryTableCol<Reason>[] = [
{ colKey: 'category', title: '分类', width: 120, cell: ({ row }) => <Tag variant="outline">{row.category}</Tag> },
{
colKey: 'category',
title: '分类',
width: 120,
cell: ({ row }) => <Tag variant="outline">{row.category}</Tag>
},
{ colKey: 'content', title: '理由内容', width: 250 },
{
colKey: 'delta',
title: '预设分值',
{
colKey: 'delta',
title: '预设分值',
width: 100,
cell: ({ row }) => (
<span style={{ color: row.delta > 0 ? 'var(--td-success-color)' : 'var(--td-error-color)' }}>
<span
style={{ color: row.delta > 0 ? 'var(--td-success-color)' : 'var(--td-error-color)' }}
>
{row.delta > 0 ? `+${row.delta}` : row.delta}
</span>
)
@@ -69,24 +126,26 @@ export const ReasonManager: React.FC = () => {
width: 150,
cell: ({ row }) => (
<Space>
<Button
theme="danger"
variant="text"
disabled={row.is_system === 1}
onClick={() => handleDelete(row.id)}
<Button
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row)}
>
</Button>
</Space>
),
},
];
)
}
]
return (
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Button theme="primary" onClick={() => setVisible(true)}></Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</div>
<Table
@@ -119,5 +178,5 @@ export const ReasonManager: React.FC = () => {
</Form>
</Dialog>
</div>
);
};
)
}
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from 'react'
import {
Form,
Select,
Radio,
Input,
InputNumber,
Button,
MessagePlugin,
Card,
Table,
PrimaryTableCol,
Tag,
Space,
Popconfirm
} from 'tdesign-react'
import { RollbackIcon } from 'tdesign-icons-react'
interface Student {
id: number
name: string
score: number
}
interface Reason {
id: number
content: string
delta: number
category: string
}
interface ScoreEvent {
id: number
uuid: string
student_name: string
reason_content: string
delta: number
val_prev: number
val_curr: number
event_time: string
}
export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
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()
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
const fetchData = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
const [stuRes, reaRes, eveRes] = await Promise.all([
(window as any).api.queryStudents({}),
(window as any).api.queryReasons(),
(window as any).api.queryEvents({ limit: 10 })
])
if (stuRes.success) setStudents(stuRes.data)
if (reaRes.success) setReasons(reaRes.data)
if (eveRes.success) setEvents(eveRes.data)
setLoading(false)
}, [])
useEffect(() => {
fetchData()
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (
category === 'events' ||
category === 'students' ||
category === 'reasons' ||
category === 'all'
) {
fetchData()
}
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchData])
const handleSubmit = async () => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
const values = form.getFieldsValue(true) as any
if (!values.student_name || !values.delta || !values.reason_content) {
MessagePlugin.warning('请填写完整信息')
return
}
setSubmitLoading(true)
const delta = values.type === 'subtract' ? -Math.abs(values.delta) : Math.abs(values.delta)
const res = await (window as any).api.createEvent({
student_name: values.student_name,
reason_content: values.reason_content,
delta: delta
})
if (res.success) {
MessagePlugin.success('积分提交成功')
form.setFieldsValue({ delta: undefined, reason_content: '', reason_id: undefined })
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '提交失败')
}
setSubmitLoading(false)
}
const handleUndo = async (uuid: string) => {
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
const res = await (window as any).api.deleteEvent(uuid)
if (res.success) {
MessagePlugin.success('已撤销操作')
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '撤销失败')
}
}
const columns: PrimaryTableCol<ScoreEvent>[] = [
{ colKey: 'student_name', title: '学生', width: 100 },
{
colKey: 'delta',
title: '变动',
width: 80,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
)
},
{ colKey: 'reason_content', title: '理由', ellipsis: true },
{
colKey: 'event_time',
title: '时间',
width: 160,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
},
{
colKey: 'operation',
title: '操作',
width: 80,
cell: ({ row }) => (
<Popconfirm
content="确定要撤销这条记录吗?学生积分将回滚。"
onConfirm={() => handleUndo(row.uuid)}
>
<Button variant="text" theme="warning" disabled={!canEdit} icon={<RollbackIcon />}>
</Button>
</Popconfirm>
)
}
]
return (
<div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
<Form
form={form}
labelWidth={80}
initialData={{ type: 'add' }}
onReset={() => form.setFieldsValue({ type: 'add' })}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<Form.FormItem label="姓名" name="student_name">
<Select
filterable
placeholder="请选择或搜索学生"
options={students.map((s) => ({ label: s.name, value: s.name }))}
/>
</Form.FormItem>
<Form.FormItem label="分数" name="delta">
<Space>
<Form.FormItem name="type" style={{ marginBottom: 0 }}>
<Radio.Group variant="default-filled">
<Radio.Button value="add"></Radio.Button>
<Radio.Button value="subtract"></Radio.Button>
</Radio.Group>
</Form.FormItem>
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
</Space>
</Form.FormItem>
<Form.FormItem label="快捷理由" name="reason_id">
<Select
placeholder="选择预设理由"
onChange={(v) => {
const id = Number(v)
if (!Number.isFinite(id)) return
const reason = reasons.find((r) => r.id === id)
if (!reason) return
form.setFieldsValue({
reason_content: reason.content,
delta: Math.abs(reason.delta),
type: reason.delta > 0 ? 'add' : 'subtract'
})
}}
>
{reasons.map((r) => (
<Select.Option key={r.id} value={r.id} label={r.content}>
{r.content} ({r.delta > 0 ? `+${r.delta}` : r.delta})
</Select.Option>
))}
</Select>
</Form.FormItem>
<Form.FormItem label="理由内容" name="reason_content">
<Input placeholder="手动输入或选择快捷理由" />
</Form.FormItem>
</div>
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
<Button
theme="primary"
size="large"
disabled={!canEdit}
onClick={handleSubmit}
loading={submitLoading}
style={{ width: '200px' }}
>
</Button>
</div>
</Form>
</Card>
<Card title="最近记录" style={{ backgroundColor: 'var(--ss-card-bg)' }}>
<Table
data={events}
columns={columns}
rowKey="uuid"
loading={loading}
size="small"
pagination={{ pageSize: 5, total: events.length }}
style={{ color: 'var(--ss-text-main)' }}
/>
</Card>
</div>
)
}
+627 -112
View File
@@ -1,129 +1,644 @@
import React, { useState, useEffect } from 'react';
import { Form, Select, Input, Switch, Button, MessagePlugin, Space, Card, Divider, Tag } from 'tdesign-react';
import { useTheme } from '../contexts/ThemeContext';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Tabs,
Card,
Form,
Select,
Input,
InputNumber,
Switch,
Button,
Space,
Divider,
Tag,
Dialog,
MessagePlugin
} from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext'
export const Settings: React.FC = () => {
const { themes, currentTheme, setTheme } = useTheme();
const [settings, setSettings] = useState<Record<string, string>>({});
const [syncStatus, setSyncStatus] = useState<{ connected: boolean, lastSync?: string }>({ connected: false });
const [loading, setLoading] = useState(false);
type PermissionLevel = 'admin' | 'points' | 'view'
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 [loading, setLoading] = useState(false)
const [securityStatus, setSecurityStatus] = useState<{
permission: PermissionLevel
hasAdminPassword: boolean
hasPointsPassword: boolean
hasRecoveryString: boolean
} | null>(null)
const [adminPassword, setAdminPassword] = useState('')
const [pointsPassword, setPointsPassword] = useState('')
const [recoveryToReset, setRecoveryToReset] = useState('')
const [recoveryDialogVisible, setRecoveryDialogVisible] = useState(false)
const [recoveryDialogHeader, setRecoveryDialogHeader] = useState('')
const [recoveryDialogString, setRecoveryDialogString] = useState('')
const [recoveryDialogFilename, setRecoveryDialogFilename] = useState('')
const [logsDialogVisible, setLogsDialogVisible] = useState(false)
const [logsText, setLogsText] = useState('')
const [logsLoading, setLogsLoading] = useState(false)
const [clearDialogVisible, setClearDialogVisible] = useState(false)
const [clearLoading, setClearLoading] = useState(false)
const importInputRef = useRef<HTMLInputElement | null>(null)
const canAdmin = permission === 'admin'
const permissionTag = useMemo(() => {
return (
<Tag
theme={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
variant="light"
>
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
</Tag>
)
}, [permission])
const emitSettingUpdated = (key: string, value: string) => {
window.dispatchEvent(new CustomEvent('ss:settings-updated', { detail: { key, value } }))
}
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 authRes = await (window as any).api.authGetStatus()
if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
}
useEffect(() => {
const loadSettings = async () => {
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);
}
};
loadSettings();
// 定时刷新同步状态
loadAll()
const timer = setInterval(async () => {
const statusRes = await (window as any).api.getSyncStatus();
if (statusRes.success && statusRes.data) {
setSyncStatus(statusRes.data);
}
}, 5000);
return () => clearInterval(timer);
}, []);
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) => {
const res = await (window as any).api.updateSetting(key, value);
if (!(window as any).api) return
const res = await (window as any).api.updateSetting(key, value)
if (res.success) {
setSettings(prev => ({ ...prev, [key]: value }));
MessagePlugin.success('设置已更新');
setSettings((prev) => ({ ...prev, [key]: value }))
emitSettingUpdated(key, value)
MessagePlugin.success('设置已更新')
} else {
MessagePlugin.error(res.message || '设置更新失败')
}
};
}
const showLogs = async () => {
if (!(window as any).api) return
setLogsLoading(true)
const res = await (window as any).api.queryLogs(200)
setLogsLoading(false)
if (!res.success) {
MessagePlugin.error(res.message || '读取日志失败')
return
}
setLogsText((res.data || []).join('\n'))
setLogsDialogVisible(true)
}
const exportLogs = async () => {
if (!(window as any).api) return
const res = await (window as any).api.queryLogs(5000)
if (!res.success) {
MessagePlugin.error(res.message || '读取日志失败')
return
}
const dateTime = new Date().toISOString().replace(/[:.]/g, '-')
downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`)
MessagePlugin.success('日志已导出')
}
const downloadTextFile = (filename: string, text: string) => {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
const showRecoveryDialog = (header: string, recoveryString: string) => {
const date = new Date().toISOString().slice(0, 10)
const filename = `secscore_recovery_${date}.txt`
setRecoveryDialogHeader(header)
setRecoveryDialogString(recoveryString)
setRecoveryDialogFilename(filename)
setRecoveryDialogVisible(true)
}
const exportJson = async () => {
if (!(window as any).api) return
const res = await (window as any).api.exportDataJson()
if (!res.success || !res.data) {
MessagePlugin.error(res.message || '导出失败')
return
}
const blob = new Blob([res.data], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
MessagePlugin.success('导出成功')
}
const importJson = async (file: File) => {
if (!(window as any).api) return
const text = await file.text()
const res = await (window as any).api.importDataJson(text)
if (res.success) {
MessagePlugin.success('导入成功,正在刷新')
setTimeout(() => window.location.reload(), 300)
} else {
MessagePlugin.error(res.message || '导入失败')
}
}
const savePasswords = async () => {
if (!(window as any).api) return
const res = await (window as any).api.authSetPasswords({
adminPassword: adminPassword ? adminPassword : undefined,
pointsPassword: pointsPassword ? pointsPassword : undefined
})
if (res.success) {
setAdminPassword('')
setPointsPassword('')
await loadAll()
if (res.data?.recoveryString) {
showRecoveryDialog('找回字符串(请妥善保存)', res.data.recoveryString)
} else {
MessagePlugin.success('密码已更新')
}
} else {
MessagePlugin.error(res.message || '更新失败')
}
}
const generateRecovery = async () => {
if (!(window as any).api) return
const res = await (window as any).api.authGenerateRecovery()
if (!res.success || !res.data?.recoveryString) {
MessagePlugin.error(res.message || '生成失败')
return
}
await loadAll()
showRecoveryDialog('新的找回字符串(请妥善保存)', res.data.recoveryString)
}
const resetByRecovery = async () => {
if (!(window as any).api) return
const res = await (window as any).api.authResetByRecovery(recoveryToReset)
if (!res.success || !res.data?.recoveryString) {
MessagePlugin.error(res.message || '重置失败')
return
}
setRecoveryToReset('')
await loadAll()
showRecoveryDialog('密码已清空,新的找回字符串', res.data.recoveryString)
}
const clearAllPasswords = () => {
if (!(window as any).api) return
setClearDialogVisible(true)
}
const handleConfirmClearAll = async () => {
if (!(window as any).api) return
setClearLoading(true)
const res = await (window as any).api.authClearAll()
setClearLoading(false)
if (res.success) {
MessagePlugin.success('已清空')
await loadAll()
setClearDialogVisible(false)
} else {
MessagePlugin.error(res.message || '清空失败')
}
}
return (
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<div style={{ padding: '24px', maxWidth: '900px', margin: '0 auto' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px'
}}
>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
{permissionTag}
</div>
<Card title="界面显示" style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<Form labelWidth={120}>
<Form.FormItem label="当前主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
style={{ width: '300px' }}
>
{themes.map(t => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
</Form.FormItem>
</Form>
</Card>
<Tabs value={activeTab} onChange={(v) => setActiveTab(v as string)}>
<Tabs.TabPanel value="appearance" label="外观">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<Form labelWidth={120}>
<Form.FormItem label="当前主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
style={{ width: '320px' }}
>
{themes.map((t) => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
</Form.FormItem>
<Card title="同步设置 (远程模式)" style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<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: '300px' }}
disabled={settings.sync_mode !== 'remote'}
/>
</Form.FormItem>
<Form.FormItem label="背景样式">
<Select
value={settings.appearance_background || 'none'}
onChange={(v) => handleUpdateSetting('appearance_background', String(v))}
style={{ width: '320px' }}
>
<Select.Option value="none" label="无背景" />
<Select.Option value="wavy-lines" label="波浪线" />
</Select>
</Form.FormItem>
<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 () => {
setLoading(true);
const res = await (window as any).api.triggerSync();
setLoading(false);
if (res.success) {
MessagePlugin.success('同步完成');
} else {
MessagePlugin.error('同步失败: ' + res.message);
<Form.FormItem label="背景虚化(px)">
<InputNumber
min={0}
max={20}
value={Number(settings.appearance_background_blur || 0)}
onChange={(v) =>
handleUpdateSetting('appearance_background_blur', String(v || 0))
}
}}
>
style={{ width: '160px' }}
/>
</Form.FormItem>
</Form>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="security" label="安全">
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
color: 'var(--ss-text-main)',
marginBottom: '16px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontWeight: 600 }}></div>
<Space>
<Tag
theme={securityStatus?.hasAdminPassword ? 'success' : 'default'}
variant="light"
>
{securityStatus?.hasAdminPassword ? '已设置' : '未设置'}
</Tag>
<Tag
theme={securityStatus?.hasPointsPassword ? 'success' : 'default'}
variant="light"
>
{securityStatus?.hasPointsPassword ? '已设置' : '未设置'}
</Tag>
<Tag
theme={securityStatus?.hasRecoveryString ? 'success' : 'default'}
variant="light"
>
{securityStatus?.hasRecoveryString ? '已生成' : '未生成'}
</Tag>
</Space>
</div>
<Divider />
<Form labelWidth={120}>
<Form.FormItem label="管理密码">
<Input
value={adminPassword}
onChange={(v) => setAdminPassword(v)}
placeholder="输入6位数字(留空则不修改)"
maxlength={6}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
/>
</Form.FormItem>
<Form.FormItem label="积分密码">
<Input
value={pointsPassword}
onChange={(v) => setPointsPassword(v)}
placeholder="输入6位数字(留空则不修改)"
maxlength={6}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
/>
</Form.FormItem>
<Form.FormItem label="操作">
<Space>
<Button
theme="primary"
onClick={savePasswords}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
>
</Button>
<Button
variant="outline"
onClick={generateRecovery}
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
>
</Button>
<Button
theme="danger"
variant="outline"
onClick={clearAllPasswords}
disabled={!canAdmin}
>
</Button>
</Space>
</Form.FormItem>
</Form>
</Card>
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontWeight: 600, marginBottom: '12px' }}></div>
<Space>
<Input
value={recoveryToReset}
onChange={(v) => setRecoveryToReset(v)}
placeholder="输入找回字符串"
style={{ width: '420px' }}
/>
<Button theme="primary" variant="outline" onClick={resetByRecovery}>
</Button>
</Space>
</Form.FormItem>
</Form>
</Card>
<div style={{ marginTop: '48px', textAlign: 'center', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
SecScore v1.0.0 - Education Scene Personal Point Management
</div>
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
/
</div>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="data" label="数据管理">
<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)',
color: 'var(--ss-text-main)',
marginBottom: '16px'
}}
>
<div style={{ fontWeight: 600, marginBottom: '12px' }}> / </div>
<Space>
<Button theme="primary" onClick={exportJson}>
JSON
</Button>
<Button variant="outline" onClick={() => importInputRef.current?.click()}>
JSON
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) importJson(file)
if (importInputRef.current) importInputRef.current.value = ''
}}
/>
</Space>
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
///
</div>
</Card>
<Card
title="日志"
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
>
<Form labelWidth={120}>
<Form.FormItem label="日志级别">
<Select
value={settings.log_level || 'info'}
onChange={async (v) => {
if (!(window as any).api) return
const res = await (window as any).api.setLogLevel(String(v))
if (res.success) {
setSettings((prev) => ({ ...prev, log_level: v as string }))
MessagePlugin.success('日志级别已更新')
} else {
MessagePlugin.error(res.message || '更新失败')
}
}}
style={{ width: '320px' }}
>
<Select.Option value="debug" label="DEBUG (调试)" />
<Select.Option value="info" label="INFO (信息)" />
<Select.Option value="warn" label="WARN (警告)" />
<Select.Option value="error" label="ERROR (错误)" />
</Select>
</Form.FormItem>
<Form.FormItem label="日志操作">
<Space>
<Button variant="outline" loading={logsLoading} onClick={showLogs}>
</Button>
<Button variant="outline" onClick={exportLogs}>
</Button>
<Button
theme="danger"
variant="outline"
onClick={async () => {
if (!(window as any).api) return
const res = await (window as any).api.clearLogs()
if (res.success) MessagePlugin.success('日志已清空')
else MessagePlugin.error(res.message || '清空失败')
}}
>
</Button>
</Space>
</Form.FormItem>
</Form>
</Card>
</Tabs.TabPanel>
<Tabs.TabPanel value="about" label="关于">
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>
<div style={{ color: 'var(--ss-text-secondary)', marginBottom: '16px' }}>
</div>
<Divider />
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', rowGap: '10px' }}>
<div style={{ color: 'var(--ss-text-secondary)' }}></div>
<div>v1.0.0</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>Electron</div>
<div>{(window as any).electron?.process?.versions?.electron || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>Chromium</div>
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
<div style={{ color: 'var(--ss-text-secondary)' }}>Node</div>
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
</div>
</Card>
</Tabs.TabPanel>
</Tabs>
<Dialog
header={recoveryDialogHeader}
visible={recoveryDialogVisible}
width="70%"
cancelBtn={null}
confirmBtn="我已保存"
onClose={() => setRecoveryDialogVisible(false)}
onConfirm={() => setRecoveryDialogVisible(false)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ wordBreak: 'break-all', fontFamily: 'monospace' }}>
{recoveryDialogString}
</div>
<Space>
<Button
theme="primary"
onClick={() =>
downloadTextFile(
recoveryDialogFilename ||
`secscore_recovery_${new Date().toISOString().slice(0, 10)}.txt`,
`SecScore 找回字符串: ${recoveryDialogString}\n`
)
}
>
</Button>
</Space>
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
线
</div>
</div>
</Dialog>
<Dialog
header="系统日志 (最后200条)"
visible={logsDialogVisible}
width="80%"
cancelBtn={null}
confirmBtn="关闭"
onClose={() => setLogsDialogVisible(false)}
onConfirm={() => setLogsDialogVisible(false)}
>
<div
style={{
maxHeight: '400px',
overflowY: 'auto',
fontSize: '12px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
padding: '10px'
}}
>
{logsText || '暂无日志'}
</div>
</Dialog>
<Dialog
header="确认清空所有密码?"
visible={clearDialogVisible}
confirmBtn="确认清空"
confirmLoading={clearLoading}
onClose={() => {
if (!clearLoading) setClearDialogVisible(false)
}}
onCancel={() => {
if (!clearLoading) setClearDialogVisible(false)
}}
onConfirm={handleConfirmClearAll}
>
</Dialog>
</div>
);
};
)
}
+102 -128
View File
@@ -1,98 +1,110 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Select } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react';
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 {
id: number;
name: string;
score: number;
id: number
name: string
score: number
}
export const StudentManager: React.FC = () => {
const [data, setData] = useState<Student[]>([]);
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const [scoreVisible, setScoreVisible] = useState(false);
const [selectedStudent, setSelectedStudent] = useState<Student | null>(null);
const [reasons, setReasons] = useState<any[]>([]);
const [form] = Form.useForm();
const [scoreForm] = Form.useForm();
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [data, setData] = useState<Student[]>([])
const [loading, setLoading] = useState(false)
const [visible, setVisible] = useState(false)
const [form] = Form.useForm()
const fetchStudents = async () => {
setLoading(true);
const res = await (window as any).api.queryStudents({});
if (res.success && res.data) {
setData(res.data);
}
setLoading(false);
};
const emitDataUpdated = (category: 'students' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
const fetchReasons = async () => {
const res = await (window as any).api.queryReasons();
const fetchStudents = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
const res = await (window as any).api.queryStudents({})
if (res.success && res.data) {
setReasons(res.data);
setData(res.data)
}
};
setLoading(false)
}, [])
useEffect(() => {
fetchStudents();
fetchReasons();
}, []);
fetchStudents()
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === 'students' || category === 'all') fetchStudents()
}
window.addEventListener('ss:data-updated', onDataUpdated as any)
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
}, [fetchStudents])
const handleAdd = async () => {
const values = form.getFieldsValue?.(true) as { name: string };
if (!values.name) {
MessagePlugin.warning('请输入姓名');
return;
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
const res = await (window as any).api.createStudent(values);
if (res.success) {
MessagePlugin.success('添加成功');
setVisible(false);
form.reset();
fetchStudents();
}
};
try {
const validateResult = await form.validate()
if (validateResult !== true) {
return
}
const handleScoreChange = async () => {
const values = scoreForm.getFieldsValue?.(true) as { delta: number; reason_content: string };
if (!selectedStudent || !values.delta || !values.reason_content) {
MessagePlugin.warning('请填写完整信息');
return;
const values = form.getFieldsValue(true) as { name: string }
if (!values.name) {
MessagePlugin.warning('请输入姓名')
return
}
const res = await (window as any).api.createStudent(values)
if (res.success) {
MessagePlugin.success('添加成功')
setVisible(false)
form.reset()
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '添加失败')
}
} catch (err) {
console.error('Validate error', err)
}
const res = await (window as any).api.createEvent({
student_name: selectedStudent.name,
reason_content: values.reason_content,
delta: Number(values.delta)
});
if (res.success) {
MessagePlugin.success('积分操作成功');
setScoreVisible(false);
scoreForm.reset();
fetchStudents();
} else {
MessagePlugin.error(res.message || '操作失败');
}
};
}
const handleDelete = async (id: number) => {
const res = await (window as any).api.deleteStudent(id);
if (res.success) {
MessagePlugin.success('删除成功');
fetchStudents();
if (!(window as any).api) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
};
const res = await (window as any).api.deleteStudent(id)
if (res.success) {
MessagePlugin.success('删除成功')
fetchStudents()
emitDataUpdated('students')
} else {
MessagePlugin.error(res.message || '删除失败')
}
}
const columns: PrimaryTableCol<Student>[] = [
{ colKey: 'name', title: '姓名', width: 200 },
{ colKey: 'score', title: '当前积分', width: 120, align: 'center',
{
colKey: 'score',
title: '当前积分',
width: 120,
align: 'center',
cell: ({ row }) => (
<span style={{
fontWeight: 'bold',
color: row.score > 0 ? 'var(--td-success-color)' : row.score < 0 ? 'var(--td-error-color)' : 'inherit'
}}>
<span
style={{
fontWeight: 'bold',
color:
row.score > 0
? 'var(--td-success-color)'
: row.score < 0
? 'var(--td-error-color)'
: 'inherit'
}}
>
{row.score > 0 ? `+${row.score}` : row.score}
</span>
)
@@ -100,24 +112,29 @@ export const StudentManager: React.FC = () => {
{
colKey: 'operation',
title: '操作',
width: 200,
width: 100,
cell: ({ row }) => (
<Space>
<Button theme="primary" variant="text" onClick={() => {
setSelectedStudent(row);
setScoreVisible(true);
}}></Button>
<Button theme="danger" variant="text" onClick={() => handleDelete(row.id)}></Button>
<Button
theme="danger"
variant="text"
disabled={!canEdit}
onClick={() => handleDelete(row.id)}
>
</Button>
</Space>
),
},
];
)
}
]
return (
<div style={{ padding: '24px' }}>
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}></h2>
<Button theme="primary" onClick={() => setVisible(true)}></Button>
<Button theme="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
</Button>
</div>
<Table
@@ -144,49 +161,6 @@ export const StudentManager: React.FC = () => {
</Form.FormItem>
</Form>
</Dialog>
{/* 积分操作弹窗 */}
<Dialog
header={`积分操作 - ${selectedStudent?.name}`}
visible={scoreVisible}
onConfirm={handleScoreChange}
onClose={() => setScoreVisible(false)}
destroyOnClose
>
<Form form={scoreForm} labelWidth={80}>
<Form.FormItem label="快捷理由" name="reason_id">
<Select
onChange={(_v, { selectedOptions }) => {
const opt = selectedOptions as any;
if (opt) {
scoreForm.setFieldsValue({
reason_content: opt.label,
delta: opt.value_delta
});
}
}}
>
{reasons.map(r => (
<Select.Option
key={r.id}
value={r.id}
label={r.content}
// @ts-ignore
value_delta={r.delta}
>
{r.content} ({r.delta > 0 ? `+${r.delta}` : r.delta})
</Select.Option>
))}
</Select>
</Form.FormItem>
<Form.FormItem label="理由内容" name="reason_content">
<Input placeholder="手动输入或选择快捷理由" />
</Form.FormItem>
<Form.FormItem label="分值变动" name="delta">
<Input type="number" placeholder="例如: 2 或 -2" />
</Form.FormItem>
</Form>
</Dialog>
</div>
);
};
)
}
+41 -32
View File
@@ -1,38 +1,48 @@
import React, { useState } from 'react';
import { Dialog, Form, Select, Input, Switch, MessagePlugin, Space, Typography } from 'tdesign-react';
import { useTheme } from '../contexts/ThemeContext';
import React, { useState } from 'react'
import {
Dialog,
Form,
Select,
Input,
Switch,
MessagePlugin,
Space,
Typography
} from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext'
interface WizardProps {
visible: boolean;
onComplete: () => void;
visible: boolean
onComplete: () => void
}
export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme();
const [loading, setLoading] = useState(false);
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);
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);
await (window as any).api.updateSetting('sync_mode', formData.sync_mode)
// 2. 保存服务器地址
await (window as any).api.updateSetting('ws_server', formData.ws_server);
await (window as any).api.updateSetting('ws_server', formData.ws_server)
// 3. 标记向导已完成
await (window as any).api.updateSetting('is_wizard_completed', '1');
MessagePlugin.success('配置完成!');
onComplete();
} catch (e) {
MessagePlugin.error('配置保存失败');
await (window as any).api.updateSetting('is_wizard_completed', '1')
MessagePlugin.success('配置完成!')
onComplete()
} catch {
MessagePlugin.error('配置保存失败')
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<Dialog
@@ -51,11 +61,8 @@ export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
<Form labelWidth={100}>
<Form.FormItem label="外观主题">
<Select
value={currentTheme?.id}
onChange={(v) => setTheme(v as string)}
>
{themes.map(t => (
<Select value={currentTheme?.id} onChange={(v) => setTheme(v as string)}>
{themes.map((t) => (
<Select.Option key={t.id} value={t.id} label={t.name} />
))}
</Select>
@@ -63,9 +70,11 @@ export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
<Form.FormItem label="同步模式">
<Space align="center">
<Switch
value={formData.sync_mode === 'remote'}
onChange={(v) => setFormData(prev => ({ ...prev, sync_mode: v ? 'remote' : 'local' }))}
<Switch
value={formData.sync_mode === 'remote'}
onChange={(v) =>
setFormData((prev) => ({ ...prev, sync_mode: v ? 'remote' : 'local' }))
}
/>
<span style={{ fontSize: '14px' }}>
{formData.sync_mode === 'remote' ? '远程同步模式' : '纯本地模式'}
@@ -75,14 +84,14 @@ export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
{formData.sync_mode === 'remote' && (
<Form.FormItem label="服务器地址">
<Input
value={formData.ws_server}
onChange={(v) => setFormData(prev => ({ ...prev, ws_server: v }))}
<Input
value={formData.ws_server}
onChange={(v) => setFormData((prev) => ({ ...prev, ws_server: v }))}
placeholder="ws://localhost:8080"
/>
</Form.FormItem>
)}
</Form>
</Dialog>
);
};
)
}
+49 -47
View File
@@ -1,86 +1,88 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ConfigProvider } from 'tdesign-react';
import type { ThemeConfig } from '../../../preload/types';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { ThemeConfig } from '../../../preload/types'
interface ThemeContextType {
currentTheme: ThemeConfig | null;
setTheme: (id: string) => Promise<void>;
themes: ThemeConfig[];
currentTheme: ThemeConfig | null
setTheme: (id: string) => Promise<void>
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 applyThemeConfig = (theme: ThemeConfig) => {
const { tdesign, custom } = theme.config;
const root = document.documentElement;
const applyThemeConfig = useCallback((theme: ThemeConfig) => {
const { tdesign, custom } = theme.config
const root = document.documentElement
// 1. 设置 TDesign 亮/暗模式
root.setAttribute('theme-mode', theme.mode);
root.setAttribute('theme-mode', theme.mode)
// 2. 设置 TDesign 品牌色
if (tdesign.brandColor) root.style.setProperty('--td-brand-color', tdesign.brandColor);
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.brandColor) root.style.setProperty('--td-brand-color', tdesign.brandColor)
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)
// 3. 应用自定义 CSS 变量 (用于业务 UI)
Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
};
root.style.setProperty(key, value)
})
}, [])
const loadThemes = async () => {
const res = await (window as any).api.getThemes();
const loadThemes = useCallback(async () => {
if (!(window as any).api) return
const res = await (window as any).api.getThemes()
if (res.success && res.data) {
setThemes(res.data);
setThemes(res.data)
}
};
}, [])
const loadCurrentTheme = async () => {
const res = await (window as any).api.getCurrentTheme();
const loadCurrentTheme = useCallback(async () => {
if (!(window as any).api) return
const res = await (window as any).api.getCurrentTheme()
if (res.success && res.data) {
setCurrentTheme(res.data);
applyThemeConfig(res.data);
setCurrentTheme(res.data)
applyThemeConfig(res.data)
}
};
}, [applyThemeConfig])
useEffect(() => {
loadThemes();
loadCurrentTheme();
if (!(window as any).api) return
loadThemes()
loadCurrentTheme()
const unsubscribe = (window as any).api.onThemeChanged((theme) => {
setCurrentTheme(theme);
applyThemeConfig(theme);
loadThemes(); // Refresh list in case of new files
});
setCurrentTheme(theme)
applyThemeConfig(theme)
loadThemes() // Refresh list in case of new files
})
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
unsubscribe()
}
};
}, []);
}
}, [applyThemeConfig, loadCurrentTheme, loadThemes])
const setTheme = async (id: string) => {
const res = await (window as any).api.setTheme(id);
const res = await (window as any).api.setTheme(id)
if (res.success) {
await loadCurrentTheme();
await loadCurrentTheme()
}
};
}
return (
<ThemeContext.Provider value={{ currentTheme, setTheme, themes }}>
{children}
</ThemeContext.Provider>
);
};
)
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
};
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
+39
View File
@@ -1,9 +1,48 @@
import './react-19-patch'
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
const safeWriteLog = (payload: {
level: 'debug' | 'info' | 'warn' | 'error'
message: string
meta?: any
}) => {
try {
const api = (window as any).api
if (!api?.writeLog) return
api.writeLog(payload)
} catch {
return
}
}
window.addEventListener('error', (e: any) => {
const error = e?.error
safeWriteLog({
level: 'error',
message: 'renderer:error',
meta: {
message: error?.message || e?.message,
stack: error?.stack,
filename: e?.filename,
lineno: e?.lineno,
colno: e?.colno
}
})
})
window.addEventListener('unhandledrejection', (e: any) => {
const reason = e?.reason
safeWriteLog({
level: 'error',
message: 'renderer:unhandledrejection',
meta: reason instanceof Error ? { message: reason.message, stack: reason.stack } : { reason }
})
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+39
View File
@@ -0,0 +1,39 @@
import ReactDOM from 'react-dom'
import { createRoot } from 'react-dom/client'
import type { ReactNode } from 'react'
const MARK = '__td_react_root__'
// 兼容 React 19:给 ReactDOM 补上 render
// TDesign 在初始化时会读取 ReactDOM.render,所以必须尽早 patch
if (!(ReactDOM as any).render) {
;(ReactDOM as any).render = (node: ReactNode, container: HTMLElement) => {
// 简单的单例模式,避免重复 createRoot
let root = (container as any)[MARK]
if (!root) {
root = createRoot(container)
;(container as any)[MARK] = root
}
root.render(node)
}
}
// 兼容 React 19:给 ReactDOM 补上 unmountComponentAtNode
if (!(ReactDOM as any).unmountComponentAtNode) {
;(ReactDOM as any).unmountComponentAtNode = (container: HTMLElement) => {
const root = (container as any)[MARK]
if (root) {
root.unmount()
delete (container as any)[MARK]
}
}
}
// 同时也挂载到 window 上,以防万一
;(window as any).reactRender =
(window as any).reactRender ||
((element: ReactNode, container: HTMLElement) => {
;(ReactDOM as any).render(element, container)
})
console.log('[SecScore] React 19 compatibility patch applied.')
+24
View File
@@ -0,0 +1,24 @@
{
"name": "暖阳琥珀",
"id": "light-amber",
"mode": "light",
"config": {
"tdesign": {
"brandColor": "#F39C12",
"warningColor": "#E67E22",
"errorColor": "#C0392B",
"successColor": "#27AE60"
},
"custom": {
"--ss-bg-color": "#FFF8E6",
"--ss-card-bg": "#FFFFFF",
"--ss-text-main": "#3A2A12",
"--ss-text-secondary": "#8A7350",
"--ss-border-color": "#F4D9A6",
"--ss-header-bg": "#FFFFFF",
"--ss-sidebar-bg": "#FFFFFF",
"--ss-item-hover": "#FFEAC2"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "冷静青蓝",
"id": "dark-cyan",
"mode": "dark",
"config": {
"tdesign": {
"brandColor": "#16A085",
"warningColor": "#F39C12",
"errorColor": "#E74C3C",
"successColor": "#1ABC9C"
},
"custom": {
"--ss-bg-color": "#050B10",
"--ss-card-bg": "#0F1A23",
"--ss-text-main": "#E5F7FF",
"--ss-text-secondary": "#7FA4B8",
"--ss-border-color": "#1F3645",
"--ss-header-bg": "#0F1A23",
"--ss-sidebar-bg": "#0F1A23",
"--ss-item-hover": "#182635"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "校园青绿",
"id": "light-green",
"mode": "light",
"config": {
"tdesign": {
"brandColor": "#1ABC9C",
"warningColor": "#F5B041",
"errorColor": "#E74C3C",
"successColor": "#27AE60"
},
"custom": {
"--ss-bg-color": "#F0F9F6",
"--ss-card-bg": "#FFFFFF",
"--ss-text-main": "#123D2B",
"--ss-text-secondary": "#5F7D6C",
"--ss-border-color": "#D1E9DD",
"--ss-header-bg": "#FFFFFF",
"--ss-sidebar-bg": "#FFFFFF",
"--ss-item-hover": "#E1F3EB"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "清新马卡龙",
"id": "light-pastel",
"mode": "light",
"config": {
"tdesign": {
"brandColor": "#FF9AA2",
"warningColor": "#FFB347",
"errorColor": "#FF6F69",
"successColor": "#B5EAD7"
},
"custom": {
"--ss-bg-color": "#FFF7F1",
"--ss-card-bg": "#FFFFFF",
"--ss-text-main": "#3A3A3A",
"--ss-text-secondary": "#8A8A8A",
"--ss-border-color": "#F1D3D3",
"--ss-header-bg": "#FFFFFF",
"--ss-sidebar-bg": "#FFFFFF",
"--ss-item-hover": "#FFE7E0"
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "星空紫蓝",
"id": "dark-purple",
"mode": "dark",
"config": {
"tdesign": {
"brandColor": "#8E44AD",
"warningColor": "#F1C40F",
"errorColor": "#E74C3C",
"successColor": "#2ECC71"
},
"custom": {
"--ss-bg-color": "#0B0A1A",
"--ss-card-bg": "#18152C",
"--ss-text-main": "#F5F3FF",
"--ss-text-secondary": "#A69AC7",
"--ss-border-color": "#332F4D",
"--ss-header-bg": "#18152C",
"--ss-sidebar-bg": "#18152C",
"--ss-item-hover": "#252046"
}
}
}