feat: 初始化SecScore项目基础结构

- 添加Electron + React + TypeScript基础框架
- 实现学生管理、积分流水、理由管理功能
- 添加主题切换和暗黑模式支持
- 配置数据库和WebSocket同步功能
- 完善项目文档和构建配置
This commit is contained in:
Fox_block
2026-01-13 20:19:57 +08:00
parent 4ddd4cbfb7
commit ae08ec45b0
44 changed files with 8638 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+16
View File
@@ -0,0 +1,16 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
build
db.sqlite
*.local
.env.local
.env.*.local
.vscode
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
+3
View File
@@ -0,0 +1,3 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true
+6
View File
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+4
View File
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
+34 -2
View File
@@ -1,2 +1,34 @@
# SecScore
一个简单易用,巴拉巴拉的积分软件。(目前还在新建文件夹状态)
# secscore
An Electron application with React and TypeScript
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ pnpm dev
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```
+250
View File
@@ -0,0 +1,250 @@
# SecScoreDB WebSocket JSON Protocol Specification v1.0
## 1. 协议概述 (Overview)
- **传输层**: WebSocket
- **数据格式**: JSON (UTF-8)
- **通信模式**: 全双工异步通信 (Request-Response)
- **匹配机制**: 客户端生成唯一 `seq`,服务端在响应中原样返回,用于关联请求与响应。
---
## 2. 通信信封 (Message Envelope)
所有通信消息必须包含以下基础字段。
### 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` 而定 |
### 2.2 服务端响应 (Response)
| 字段 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| **`seq`** | String | Yes | 对应请求的 `seq` |
| **`status`** | String | Yes | `"ok"``"error"` |
| **`code`** | Int | Yes | 状态码 (见第 6 节) |
| **`message`** | String | No | 错误描述或提示信息 |
| **`data`** | Object | No | 成功时的返回数据 |
---
## 3. 系统管理 (System Category)
### 3.1 定义 Schema (Define)
在操作数据前,必须定义动态字段结构。
**Request:**
```json
{
"category": "system",
"action": "define",
"payload": {
"target": "student", // 或 "group"
"schema": {
"name": "string",
"age": "int",
"score": "double",
"active": "int"
}
}
}
```
---
## 4. 数据资源操作 (Student & Group Category)
以下示例以 `category: "student"` 为例,`group` 同理。
### 4.1 批量创建/导入 (Batch Create)
**核心规则**:
1. `id``null` 时,服务端自动分配 ID。
2. `id` 有值时,强制使用该 ID (导入模式)。
3. 使用 `index` 锚定请求项,确保批量回包的对应关系。
**Request:**
```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 }
}
]
}
}
```
**Response Data:**
```json
{
"count": 2, // 成功数量
"results": [
{
"index": 0,
"success": true,
"id": 1001 // 服务端分配的新 ID
},
{
"index": 1,
"success": true,
"id": 10086
}
]
}
```
### 4.2 逻辑查询 (Query)
基于 AST 将 JSON 映射为 C++ Lambda。
**Request:**
```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" }
]
}
]
}
}
}
```
*支持的操作符 (`op`): `==`, `!=`, `>`, `<`, `>=`, `<=`*
**Response Data:**
```json
{
"items": [
{
"id": 1001,
"data": { "name": "Alice", "age": 18, "score": 95.5 }
}
]
}
```
### 4.3 更新 (Update)
**Request:**
```json
{
"category": "student",
"action": "update",
"payload": {
"id": 1001,
"set": {
"score": 98.0, // 仅更新指定字段
"age": 19
}
}
}
```
### 4.4 删除 (Delete)
**Request:**
```json
{
"category": "student",
"action": "delete",
"payload": {
"id": 1001
}
}
```
---
## 5. 事件操作 (Event Category)
### 5.1 添加事件 (Create)
**Request:**
```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
}
}
```
**Response Data:**
```json
{
"id": 501, // 新生成的 Event ID
"timestamp": 1710000000
}
```
### 5.2 标记擦除 (Update)
**Request:**
```json
{
"category": "event",
"action": "update",
"payload": {
"id": 501,
"erased": true
}
}
```
---
## 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++ 异常 |
+3
View File
@@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: secscore-updater
+45
View File
@@ -0,0 +1,45 @@
appId: com.electron.app
productName: secscore
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: secscore
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+16
View File
@@ -0,0 +1,16 @@
import { resolve } from 'path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {},
preload: {},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react()]
}
})
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
}
},
eslintConfigPrettier
)
+64
View File
@@ -0,0 +1,64 @@
{
"name": "secscore",
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
"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",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run 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:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"better-sqlite3": "^12.6.0",
"chokidar": "^5.0.0",
"electron-updater": "^6.3.9",
"tdesign-react": "^1.16.3",
"uuid": "^13.0.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^11.0.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"prettier": "^3.7.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"electron-winstaller",
"esbuild"
]
}
}
+6296
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+105
View File
@@ -0,0 +1,105 @@
import BetterSqlite3 from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
export class DbManager {
private db: BetterSqlite3.Database;
constructor(dbPath: string) {
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
this.db = new BetterSqlite3(dbPath);
this.init();
}
private init() {
// 开启外键支持
this.db.pragma('foreign_keys = ON');
// 执行 Migration (简单实现)
this.migrate();
}
private migrate() {
// 建立学生表 - 仅保留姓名
this.db.exec(`
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
score INTEGER DEFAULT 0,
extra_json TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 建立系统设置表
this.db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
`);
// 初始设置
const setSetting = this.db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
setSetting.run('ws_server', 'ws://localhost:8080');
setSetting.run('sync_mode', 'local'); // local | remote
setSetting.run('is_wizard_completed', '0'); // 0: 未完成, 1: 已完成
// 建立积分理由分类/预设表
this.db.exec(`
CREATE TABLE IF NOT EXISTS reasons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL UNIQUE,
category TEXT DEFAULT '其他',
delta INTEGER NOT NULL,
is_system INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sync_state INTEGER DEFAULT 0
)
`);
// 初始数据种子
const reasonCount = this.db.prepare('SELECT count(*) as count FROM reasons').get() as { count: number };
if (reasonCount.count === 0) {
const insert = this.db.prepare('INSERT INTO reasons (content, category, delta, is_system) VALUES (?, ?, ?, ?)');
insert.run('课堂表现优秀', '学习', 2, 1);
insert.run('作业未交', '学习', -2, 1);
insert.run('帮助同学', '品德', 5, 1);
insert.run('打架斗殴', '纪律', -10, 1);
}
// 建立积分事件流水表
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
)
`);
// 插入一些初始 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);
}
}
public getDb(): BetterSqlite3.Database {
return this.db;
}
}
+172
View File
@@ -0,0 +1,172 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import fs from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { ThemeService } from './services/ThemeService'
import { DbManager } from './db/DbManager'
import { StudentRepository } from './repos/StudentRepository'
import { ReasonRepository } from './repos/ReasonRepository'
import { EventRepository } from './repos/EventRepository'
import { WsClient } from './services/WsClient'
import { SyncEngine } from './services/SyncEngine'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
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 });
}
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());
// Initialize Sync
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();
}
};
startSyncIfRemote();
// 监听本地事件创建,触发同步
app.on('score-event-created' as any, () => {
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 }; });
// 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 }; });
// 兼容前端 deleteReason 命名错误
ipcMain.handle('db:deleteReason', async (_, id) => { reasonRepo.delete(id); return { success: true }; });
// Event IPC
ipcMain.handle('db:event:query', async (_, params) => ({ success: true, data: eventRepo.findAll(params?.limit) }));
ipcMain.handle('db:event:create', async (_, data) => {
try {
const id = eventRepo.create(data);
return { success: true, data: id };
} catch (e: any) {
return { success: false, message: e.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 };
});
ipcMain.handle('db:updateSetting', (_event, key, value) => {
dbManager.getDb().prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
if (key === 'sync_mode' || key === 'ws_server') {
startSyncIfRemote();
}
return { success: true };
});
ipcMain.handle('ws:getStatus', () => {
return {
success: true,
data: {
connected: wsClient.isConnected(),
lastSync: new Date().toISOString() // TODO: 记录真正的最后同步时间
}
};
});
ipcMain.handle('ws:triggerSync', async () => {
await syncEngine.triggerFullSync();
return { success: true };
});
createWindow()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
+70
View File
@@ -0,0 +1,70 @@
import { Database } from 'better-sqlite3';
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;
}
export class EventRepository {
constructor(private db: Database) {}
findAll(limit = 100) {
return this.db.prepare(`
SELECT * FROM score_events
ORDER BY event_time DESC
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 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(`
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);
// 3. Update student score
this.db.prepare('UPDATE students SET score = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?')
.run(val_curr, event.student_name);
return info.lastInsertRowid as number;
})();
// 触发同步 (如果已连接)
// @ts-ignore
const { app } = require('electron');
if (app) {
app.emit('score-event-created');
}
return lastInsertRowid;
}
getUnsynced() {
return this.db.prepare('SELECT * FROM score_events WHERE sync_state = 0').all() as ScoreEvent[];
}
markSynced(uuid: string, remote_id?: string) {
this.db.prepare('UPDATE score_events SET sync_state = 1, remote_id = ? WHERE uuid = ?')
.run(remote_id, uuid);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Database } from 'better-sqlite3';
export interface Reason {
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[];
}
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;
}
update(id: number, reason: Partial<Reason>) {
const sets: string[] = [];
const vals: any[] = [];
Object.entries(reason).forEach(([key, val]) => {
if (key !== 'id') {
sets.push(`${key} = ?`);
vals.push(val);
}
});
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);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Database } from 'better-sqlite3';
export interface Student {
id: number;
name: string;
score: number;
extra_json?: string;
}
export class StudentRepository {
constructor(private db: Database) {}
findAll() {
return this.db.prepare('SELECT * FROM students ORDER BY score DESC, name ASC').all() as Student[];
}
create(student: { name: string }) {
const info = this.db.prepare(
'INSERT INTO students (name) VALUES (?)'
).run(student.name);
return info.lastInsertRowid as number;
}
update(id: number, student: Partial<Student>) {
const sets: string[] = [];
const vals: any[] = [];
Object.entries(student).forEach(([key, val]) => {
if (key !== 'id') {
sets.push(`${key} = ?`);
vals.push(val);
}
});
vals.push(id);
this.db.prepare(`UPDATE students SET ${sets.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(...vals);
}
delete(id: number) {
this.db.transaction(() => {
this.db.prepare('DELETE FROM score_events WHERE student_id = ?').run(id);
this.db.prepare('DELETE FROM students WHERE id = ?').run(id);
})();
}
}
+148
View File
@@ -0,0 +1,148 @@
import { WsClient } from './WsClient';
import { DbManager } from '../db/DbManager';
import { StudentRepository } from '../repos/StudentRepository';
import { EventRepository } from '../repos/EventRepository';
import { ReasonRepository } from '../repos/ReasonRepository';
export class SyncEngine {
private wsClient: WsClient;
private db: DbManager;
private _studentRepo: StudentRepository;
private _eventRepo: EventRepository;
private _reasonRepo: ReasonRepository;
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.init();
}
private init() {
// 监听 WS 事件
this.wsClient.on('connected', () => {
this.syncAll();
});
this.wsClient.on('event', (msg) => {
this.handleRemoteEvent(msg);
});
this.wsClient.on('reason_sync', (msg) => {
this.handleReasonSync(msg);
});
}
// 启动同步任务(处理 Outbox)
async startOutboxSync() {
if (this.isSyncing) return;
this.isSyncing = true;
try {
// 1. 获取未同步的事件
const unsyncedEvents = this.db.getDb().prepare(
'SELECT * FROM score_events WHERE sync_state = 0 ORDER BY id ASC'
).all() as any[];
for (const event of unsyncedEvents) {
if (!this.wsClient.isConnected()) break;
try {
await this.wsClient.send({
type: 'score_event',
data: {
uuid: event.uuid,
student_name: event.student_name,
reason_content: event.reason_content,
delta: event.delta,
event_time: event.event_time
}
});
// 标记为已同步
this.db.getDb().prepare(
'UPDATE score_events SET sync_state = 1 WHERE id = ?'
).run(event.id);
} catch (e) {
console.error(`[Sync] Failed to sync event ${event.uuid}`, e);
}
}
} finally {
this.isSyncing = false;
}
}
// 触发全量同步
async triggerFullSync() {
await this.syncAll();
}
// 全量对齐
private async syncAll() {
if (!this.wsClient.isConnected()) return;
console.log('[Sync] Starting full sync...');
try {
// 1. 同步理由
const resReasons = await this.wsClient.send({ type: 'query_reasons' });
if (resReasons.data) {
this.handleReasonSync(resReasons);
}
// 2. 同步学生分值 (Local-first: 本地值为准,除非服务器有更正)
const _resStudents = await this.wsClient.send({ type: 'query_students' });
// TODO: 实现更复杂的对齐逻辑
// 3. 处理 Outbox
await this.startOutboxSync();
console.log('[Sync] Full sync completed');
} catch (e) {
console.error('[Sync] Full sync failed', e);
}
}
private handleRemoteEvent(msg: any) {
// 远程推送的事件,通常是其他客户端的操作
// 本地需要根据此事件更新分值,但不要再产生新的同步事件(避免循环)
const { data } = msg;
if (!data) return;
this.db.getDb().transaction(() => {
// 更新学生分值
this.db.getDb().prepare(
'UPDATE students SET score = score + ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?'
).run(data.delta, data.student_name);
// 记录事件(标记为已同步,防止回环)
this.db.getDb().prepare(`
INSERT INTO score_events (uuid, student_name, reason_content, delta, val_prev, val_curr, event_time, sync_state)
SELECT ?, ?, ?, ?, score - ?, score, ?, 1 FROM students WHERE name = ?
`).run(data.uuid, data.student_name, data.reason_content, data.delta, data.delta, data.event_time || new Date().toISOString(), data.student_name);
})();
}
private handleReasonSync(msg: any) {
const reasons = msg.data;
if (!Array.isArray(reasons)) return;
this.db.getDb().transaction(() => {
for (const r of reasons) {
// 如果是系统理由,且 category 匹配,则归入 __config__ 组 (按照协议要求)
const category = r.is_system ? '__config__' : r.category;
this.db.getDb().prepare(`
INSERT INTO reasons (content, category, delta, is_system)
VALUES (?, ?, ?, ?)
ON CONFLICT(content) DO UPDATE SET
category = excluded.category,
delta = excluded.delta
`).run(r.content, category, r.delta, r.is_system ? 1 : 0);
}
})();
}
}
+98
View File
@@ -0,0 +1,98 @@
import { ipcMain, BrowserWindow } from 'electron';
import fs from 'fs';
import path from 'path';
import chokidar from 'chokidar';
export interface ThemeConfig {
name: string;
id: string;
mode: 'light' | 'dark';
config: {
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';
constructor(themeDir: string) {
this.themeDir = themeDir;
}
public init() {
this.setupWatcher();
this.registerIpc();
}
private setupWatcher() {
if (this.watcher) this.watcher.close();
this.watcher = chokidar.watch(this.themeDir, {
ignored: /(^|[\/\\])\../,
persistent: true
});
this.watcher.on('change', (filePath) => {
if (filePath.endsWith('.json')) {
console.log(`Theme file changed: ${filePath}`);
this.notifyThemeUpdate();
}
});
}
private registerIpc() {
ipcMain.handle('theme:list', async () => {
return { success: true, data: this.getThemeList() };
});
ipcMain.handle('theme:current', async () => {
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 };
});
}
private getThemeList(): ThemeConfig[] {
try {
if (!fs.existsSync(this.themeDir)) return [];
const files = fs.readdirSync(this.themeDir);
return files
.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;
}
})
.filter((t): t is ThemeConfig => t !== null);
} catch (e) {
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;
}
private notifyThemeUpdate() {
const theme = this.getThemeById(this.currentThemeId);
if (!theme) return;
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
win.webContents.send('theme:updated', theme);
}
}
}
+135
View File
@@ -0,0 +1,135 @@
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;
constructor() {
super();
}
connect(url: string) {
this.url = url;
this.isManualClose = false;
this._connect();
}
private _connect() {
if (this.ws) {
this.ws.terminate();
}
console.log(`[WS] Connecting to ${this.url}...`);
this.ws = new WebSocket(this.url);
this.ws.on('open', () => {
console.log('[WS] Connected');
this.emit('connected');
this.startHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
});
this.ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
this.handleMessage(msg);
} catch (e) {
console.error('[WS] Parse error', e);
}
});
this.ws.on('close', () => {
console.log('[WS] Closed');
this.stopHeartbeat();
this.emit('disconnected');
if (!this.isManualClose) {
this.reconnect();
}
});
this.ws.on('error', (err) => {
console.error('[WS] Error', err);
});
}
private handleMessage(msg: any) {
// 1. 处理请求响应 (seq 匹配)
if (msg.seq && this.pendingRequests.has(msg.seq)) {
const { resolve, timeout } = this.pendingRequests.get(msg.seq)!;
clearTimeout(timeout);
this.pendingRequests.delete(msg.seq);
resolve(msg);
return;
}
// 2. 处理服务器主动推送 (事件)
if (msg.type === 'event' || msg.type === 'correction') {
this.emit('event', msg);
} else if (msg.type === 'reason_sync') {
this.emit('reason_sync', msg);
}
}
private reconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect();
}, 5000);
}
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.send({ type: 'heartbeat' }).catch(() => {});
}, 30000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
send(data: any, timeoutMs: number = 5000): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return reject(new Error('WebSocket not connected'));
}
const seq = uuidv4();
const payload = { ...data, seq };
const timeout = setTimeout(() => {
this.pendingRequests.delete(seq);
reject(new Error('Request timeout'));
}, timeoutMs);
this.pendingRequests.set(seq, { resolve, reject, timeout });
this.ws.send(JSON.stringify(payload));
});
}
close() {
this.isManualClose = true;
this.stopHeartbeat();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { ElectronApi } from './types'
declare global {
interface Window {
api: ElectronApi
}
}
+47
View File
@@ -0,0 +1,47 @@
import { contextBridge, ipcRenderer } from 'electron'
import { ThemeConfig } from './types'
const api = {
// Theme
getThemes: () => ipcRenderer.invoke('theme:list'),
getCurrentTheme: () => ipcRenderer.invoke('theme:current'),
setTheme: (themeId: string) => ipcRenderer.invoke('theme:set', themeId),
onThemeChanged: (callback: (theme: ThemeConfig) => void) => {
const subscription = (_event: any, theme: ThemeConfig) => callback(theme)
ipcRenderer.on('theme:updated', subscription)
return () => ipcRenderer.removeListener('theme:updated', subscription)
},
// DB - Student
queryStudents: (params: any) => ipcRenderer.invoke('db:student:query', params),
createStudent: (data: any) => ipcRenderer.invoke('db:student:create', data),
updateStudent: (id: number, data: any) => ipcRenderer.invoke('db:student:update', id, data),
deleteStudent: (id: number) => ipcRenderer.invoke('db:student:delete', id),
// DB - Reason
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),
// DB - Event
queryEvents: (params: any) => ipcRenderer.invoke('db:event:query', params),
createEvent: (data: any) => ipcRenderer.invoke('db:event:create', data),
// 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'),
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.api = api
}
+45
View File
@@ -0,0 +1,45 @@
export interface IpcResponse<T = any> {
success: boolean;
data?: T;
message?: string;
}
export interface ThemeConfig {
name: string;
id: string;
mode: 'light' | 'dark';
config: {
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;
// 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>>;
// 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>>;
// DB - Event
queryEvents: (params?: any) => Promise<IpcResponse<any[]>>;
createEvent: (data: { student_name: string; reason_content: string; delta: number }) => Promise<IpcResponse<number>>;
// Settings & Sync
getSettings: () => Promise<IpcResponse<Record<string, string>>>;
updateSetting: (key: string, value: string) => Promise<IpcResponse<void>>;
getSyncStatus: () => Promise<IpcResponse<{ connected: boolean, lastSync?: string }>>;
triggerSync: () => Promise<IpcResponse<void>>;
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
import { Layout, Menu, Space } from 'tdesign-react'
import { useState, useEffect } from 'react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon } 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 { Wizard } from './components/Wizard'
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
const { Header, Content, Aside } = Layout
function MainContent(): React.JSX.Element {
const [activeMenu, setActiveMenu] = useState('students')
const [wizardVisible, setWizardVisible] = useState(false)
const { currentTheme } = useTheme()
useEffect(() => {
const checkWizard = async () => {
const res = await (window as any).api.getSettings()
if (res.success && res.data && res.data.is_wizard_completed !== '1') {
setWizardVisible(true)
}
}
checkWizard()
}, [])
const renderContent = () => {
switch (activeMenu) {
case 'students':
return <StudentManager />
case 'history':
return <EventHistory />
case 'reasons':
return <ReasonManager />
case 'settings':
return <Settings />
default:
return <StudentManager />
}
}
return (
<Layout style={{ height: '100vh', backgroundColor: 'var(--ss-bg-color)' }}>
<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>
</div>
<Menu
value={activeMenu}
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>
</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'
}}>
<Space>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '14px' }}>
: {currentTheme?.name}
</span>
</Space>
</Header>
<Content style={{ overflowY: 'auto' }}>
{renderContent()}
</Content>
</Layout>
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
</Layout>
)
}
function App(): React.JSX.Element {
return (
<ThemeProvider>
<MainContent />
</ThemeProvider>
)
}
export default App
+10
View File
@@ -0,0 +1,10 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="64" cy="64" r="64" fill="#2F3242"/>
<ellipse cx="63.9835" cy="23.2036" rx="4.48794" ry="4.495" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.6153 43.5751L30.1748 44.4741L30.1748 44.4741L28.6153 43.5751ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM53.7489 81.7014L52.8478 83.2597L53.7489 81.7014ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM99.3169 43.6354L97.7574 44.5344L99.3169 43.6354ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM53.7836 46.3728L54.6847 47.931L53.7836 46.3728ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM84.3832 64.0673H82.5832H84.3832ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077V84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027V89.4027V89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.8501 68.0857C62.6341 68.5652 60.451 67.1547 59.9713 64.9353C59.4934 62.7159 60.9007 60.5293 63.1167 60.0489C65.3326 59.5693 67.5157 60.9798 67.9954 63.1992C68.4742 65.4186 67.066 67.6052 64.8501 68.0857Z" fill="#A2ECFB"/>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+11
View File
@@ -0,0 +1,11 @@
@import 'tdesign-react/dist/reset.css';
@import 'tdesign-react/dist/tdesign.css';
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1422 800" opacity="0.3">
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="oooscillate-grad">
<stop stop-color="hsl(206, 75%, 49%)" stop-opacity="1" offset="0%"></stop>
<stop stop-color="hsl(331, 90%, 56%)" stop-opacity="1" offset="100%"></stop>
</linearGradient>
</defs>
<g stroke-width="1" stroke="url(#oooscillate-grad)" fill="none" stroke-linecap="round">
<path d="M 0 448 Q 355.5 -100 711 400 Q 1066.5 900 1422 448" opacity="0.05"></path>
<path d="M 0 420 Q 355.5 -100 711 400 Q 1066.5 900 1422 420" opacity="0.11"></path>
<path d="M 0 392 Q 355.5 -100 711 400 Q 1066.5 900 1422 392" opacity="0.18"></path>
<path d="M 0 364 Q 355.5 -100 711 400 Q 1066.5 900 1422 364" opacity="0.24"></path>
<path d="M 0 336 Q 355.5 -100 711 400 Q 1066.5 900 1422 336" opacity="0.30"></path>
<path d="M 0 308 Q 355.5 -100 711 400 Q 1066.5 900 1422 308" opacity="0.37"></path>
<path d="M 0 280 Q 355.5 -100 711 400 Q 1066.5 900 1422 280" opacity="0.43"></path>
<path d="M 0 252 Q 355.5 -100 711 400 Q 1066.5 900 1422 252" opacity="0.49"></path>
<path d="M 0 224 Q 355.5 -100 711 400 Q 1066.5 900 1422 224" opacity="0.56"></path>
<path d="M 0 196 Q 355.5 -100 711 400 Q 1066.5 900 1422 196" opacity="0.62"></path>
<path d="M 0 168 Q 355.5 -100 711 400 Q 1066.5 900 1422 168" opacity="0.68"></path>
<path d="M 0 140 Q 355.5 -100 711 400 Q 1066.5 900 1422 140" opacity="0.75"></path>
<path d="M 0 112 Q 355.5 -100 711 400 Q 1066.5 900 1422 112" opacity="0.81"></path>
<path d="M 0 84 Q 355.5 -100 711 400 Q 1066.5 900 1422 84" opacity="0.87"></path>
<path d="M 0 56 Q 355.5 -100 711 400 Q 1066.5 900 1422 56" opacity="0.94"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,69 @@
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;
}
export const EventHistory: React.FC = () => {
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 (res.success && res.data) {
setData(res.data);
}
setLoading(false);
};
useEffect(() => {
fetchEvents();
}, []);
const columns: PrimaryTableCol<ScoreEvent>[] = [
{ colKey: 'student_name', title: '学生姓名', width: 120 },
{ colKey: 'reason_content', title: '积分理由', width: 200 },
{
colKey: 'delta',
title: '分值变动',
width: 100,
cell: ({ row }) => (
<Tag theme={row.delta > 0 ? 'success' : 'danger'} variant="light">
{row.delta > 0 ? `+${row.delta}` : row.delta}
</Tag>
)
},
{ colKey: 'val_prev', title: '原分值', width: 100 },
{ colKey: 'val_curr', title: '新分值', width: 100 },
{
colKey: 'event_time',
title: '发生时间',
width: 180,
cell: ({ row }) => new Date(row.event_time).toLocaleString()
},
];
return (
<div style={{ padding: '24px' }}>
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}></h2>
<Table
data={data}
columns={columns}
rowKey="uuid"
loading={loading}
bordered
hover
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
</div>
);
};
@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react';
import { Table, PrimaryTableCol, Button, Space, Dialog, Form, Input, MessagePlugin, Tag } from 'tdesign-react';
interface Reason {
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();
const fetchReasons = async () => {
setLoading(true);
const res = await (window as any).api.queryReasons();
if (res.success && res.data) {
setData(res.data);
}
setLoading(false);
};
useEffect(() => {
fetchReasons();
}, []);
const handleAdd = async () => {
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();
}
};
const handleDelete = async (id: number) => {
const res = await (window as any).api.deleteReason(id);
if (res.success) {
MessagePlugin.success('删除成功');
fetchReasons();
}
};
const columns: PrimaryTableCol<Reason>[] = [
{ colKey: 'category', title: '分类', width: 120, cell: ({ row }) => <Tag variant="outline">{row.category}</Tag> },
{ colKey: 'content', title: '理由内容', width: 250 },
{
colKey: 'delta',
title: '预设分值',
width: 100,
cell: ({ row }) => (
<span style={{ color: row.delta > 0 ? 'var(--td-success-color)' : 'var(--td-error-color)' }}>
{row.delta > 0 ? `+${row.delta}` : row.delta}
</span>
)
},
{
colKey: 'operation',
title: '操作',
width: 150,
cell: ({ row }) => (
<Space>
<Button
theme="danger"
variant="text"
disabled={row.is_system === 1}
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>
</div>
<Table
data={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
hover
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
<Dialog
header="添加理由"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="分类" name="category" initialData="其他">
<Input placeholder="例如: 学习, 纪律" />
</Form.FormItem>
<Form.FormItem label="理由内容" name="content">
<Input placeholder="请输入理由" />
</Form.FormItem>
<Form.FormItem label="预设分值" name="delta">
<Input type="number" placeholder="例如: 2 或 -2" />
</Form.FormItem>
</Form>
</Dialog>
</div>
);
};
+129
View File
@@ -0,0 +1,129 @@
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';
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);
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();
// 定时刷新同步状态
const timer = setInterval(async () => {
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 (res.success) {
setSettings(prev => ({ ...prev, [key]: value }));
MessagePlugin.success('设置已更新');
}
};
return (
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}></h2>
<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>
<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>
<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);
}
}}
>
</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>
);
};
@@ -0,0 +1,192 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input, Select } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react';
interface Student {
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();
const fetchStudents = async () => {
setLoading(true);
const res = await (window as any).api.queryStudents({});
if (res.success && res.data) {
setData(res.data);
}
setLoading(false);
};
const fetchReasons = async () => {
const res = await (window as any).api.queryReasons();
if (res.success && res.data) {
setReasons(res.data);
}
};
useEffect(() => {
fetchStudents();
fetchReasons();
}, []);
const handleAdd = async () => {
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();
}
};
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 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();
}
};
const columns: PrimaryTableCol<Student>[] = [
{ colKey: 'name', title: '姓名', width: 200 },
{ 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'
}}>
{row.score > 0 ? `+${row.score}` : row.score}
</span>
)
},
{
colKey: 'operation',
title: '操作',
width: 200,
cell: ({ row }) => (
<Space>
<Button theme="primary" variant="text" onClick={() => {
setSelectedStudent(row);
setScoreVisible(true);
}}></Button>
<Button theme="danger" variant="text" 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>
</div>
<Table
data={data}
columns={columns}
rowKey="id"
loading={loading}
bordered
hover
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
{/* 添加学生弹窗 */}
<Dialog
header="添加学生"
visible={visible}
onConfirm={handleAdd}
onClose={() => setVisible(false)}
destroyOnClose
>
<Form form={form} labelWidth={80}>
<Form.FormItem label="姓名" name="name">
<Input placeholder="请输入学生姓名" />
</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>
);
};
+15
View File
@@ -0,0 +1,15 @@
import { useState } from 'react'
function Versions(): React.JSX.Element {
const [versions] = useState(window.electron.process.versions)
return (
<ul className="versions">
<li className="electron-version">Electron v{versions.electron}</li>
<li className="chrome-version">Chromium v{versions.chrome}</li>
<li className="node-version">Node v{versions.node}</li>
</ul>
)
}
export default Versions
+88
View File
@@ -0,0 +1,88 @@
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;
}
export const Wizard: React.FC<WizardProps> = ({ visible, onComplete }) => {
const { themes, currentTheme, setTheme } = useTheme();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
sync_mode: 'local',
ws_server: 'ws://localhost:8080'
});
const handleFinish = async () => {
setLoading(true);
try {
// 1. 保存模式
await (window as any).api.updateSetting('sync_mode', formData.sync_mode);
// 2. 保存服务器地址
await (window as any).api.updateSetting('ws_server', formData.ws_server);
// 3. 标记向导已完成
await (window as any).api.updateSetting('is_wizard_completed', '1');
MessagePlugin.success('配置完成!');
onComplete();
} catch (e) {
MessagePlugin.error('配置保存失败');
} finally {
setLoading(false);
}
};
return (
<Dialog
header="欢迎使用 SecScore 积分管理"
visible={visible}
confirmBtn={{ content: '开启积分之旅', loading }}
cancelBtn={null}
closeOnEscKeydown={false}
closeOnOverlayClick={false}
onConfirm={handleFinish}
width={500}
>
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
SecScore
</Typography.Paragraph>
<Form labelWidth={100}>
<Form.FormItem label="外观主题">
<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>
</Form.FormItem>
<Form.FormItem label="同步模式">
<Space align="center">
<Switch
value={formData.sync_mode === 'remote'}
onChange={(v) => setFormData(prev => ({ ...prev, sync_mode: v ? 'remote' : 'local' }))}
/>
<span style={{ fontSize: '14px' }}>
{formData.sync_mode === 'remote' ? '远程同步模式' : '纯本地模式'}
</span>
</Space>
</Form.FormItem>
{formData.sync_mode === 'remote' && (
<Form.FormItem label="服务器地址">
<Input
value={formData.ws_server}
onChange={(v) => setFormData(prev => ({ ...prev, ws_server: v }))}
placeholder="ws://localhost:8080"
/>
</Form.FormItem>
)}
</Form>
</Dialog>
);
};
@@ -0,0 +1,86 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ConfigProvider } from 'tdesign-react';
import type { ThemeConfig } from '../../../preload/types';
interface ThemeContextType {
currentTheme: ThemeConfig | null;
setTheme: (id: string) => Promise<void>;
themes: ThemeConfig[];
}
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 applyThemeConfig = (theme: ThemeConfig) => {
const { tdesign, custom } = theme.config;
const root = document.documentElement;
// 1. 设置 TDesign 亮/暗模式
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);
// 3. 应用自定义 CSS 变量 (用于业务 UI)
Object.entries(custom).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
};
const loadThemes = async () => {
const res = await (window as any).api.getThemes();
if (res.success && res.data) {
setThemes(res.data);
}
};
const loadCurrentTheme = async () => {
const res = await (window as any).api.getCurrentTheme();
if (res.success && res.data) {
setCurrentTheme(res.data);
applyThemeConfig(res.data);
}
};
useEffect(() => {
loadThemes();
loadCurrentTheme();
const unsubscribe = (window as any).api.onThemeChanged((theme) => {
setCurrentTheme(theme);
applyThemeConfig(theme);
loadThemes(); // Refresh list in case of new files
});
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, []);
const setTheme = async (id: string) => {
const res = await (window as any).api.setTheme(id);
if (res.success) {
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;
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11
View File
@@ -0,0 +1,11 @@
import './assets/main.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+23
View File
@@ -0,0 +1,23 @@
{
"name": "极客深蓝",
"id": "dark-default",
"mode": "dark",
"config": {
"tdesign": {
"brandColor": "#0052D9",
"warningColor": "#E37318",
"errorColor": "#D32029",
"successColor": "#248232"
},
"custom": {
"--ss-bg-color": "#121212",
"--ss-card-bg": "#1e1e1e",
"--ss-text-main": "#ffffff",
"--ss-text-secondary": "#a0a0a0",
"--ss-border-color": "#333333",
"--ss-header-bg": "#1e1e1e",
"--ss-sidebar-bg": "#1e1e1e",
"--ss-item-hover": "#2c2c2c"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "极简浅色",
"id": "light-default",
"mode": "light",
"config": {
"tdesign": {
"brandColor": "#0052D9",
"warningColor": "#ED7B2F",
"errorColor": "#D54941",
"successColor": "#2BA471"
},
"custom": {
"--ss-bg-color": "#f3f3f3",
"--ss-card-bg": "#ffffff",
"--ss-text-main": "#181818",
"--ss-text-secondary": "#666666",
"--ss-border-color": "#dcdcdc",
"--ss-header-bg": "#ffffff",
"--ss-sidebar-bg": "#ffffff",
"--ss-item-hover": "#f3f3f3"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"src/preload/types.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": [
"src/renderer/src/*"
]
}
}
}