mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat:修复了亿点点bug
This commit is contained in:
+112
-112
@@ -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
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})();
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -1,7 +1,9 @@
|
||||
import { ElectronApi } from './types'
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: ElectronApi
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ body,
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.')
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user