mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat:android build
This commit is contained in:
+1
-1
@@ -54,6 +54,6 @@ coverage
|
||||
.cache
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
.eslintcache
|
||||
# OS files
|
||||
Thumbs.db
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
description: 任务完成前的检查
|
||||
---
|
||||
|
||||
1.每次完成任务之前必须跑一遍测试(比如说npm系的typecheck/lint)
|
||||
|
||||
2.每次完成任务之前必须跑一遍格式化(比如说npm系的prettier)
|
||||
|
||||
3.任务完成时详细输出你所做的事情和需要用户确认的问题(如有)
|
||||
|
||||
4.每次规划任务必须按照步骤来,每个步骤结束后遵循第三条规定。
|
||||
@@ -1,82 +0,0 @@
|
||||
# Checklist
|
||||
|
||||
## Phase 1: 项目初始化
|
||||
- [x] Tauri 项目在 SecScore-Rust 目录下正确创建
|
||||
- [x] Cargo.toml 包含所有必要依赖
|
||||
- [x] tauri.conf.json 配置正确
|
||||
- [x] 前端代码成功复制并配置
|
||||
- [x] 开发服务器可以正常启动
|
||||
|
||||
## Phase 2: 数据库层
|
||||
- [x] SQLite 数据库可以正常创建和连接
|
||||
- [x] PostgreSQL 数据库可以正常连接
|
||||
- [x] 数据库迁移可以正常执行
|
||||
- [x] 所有数据库表结构与原项目一致
|
||||
- [x] 数据库连接切换功能正常
|
||||
|
||||
## Phase 3: 核心服务
|
||||
- [x] 设置服务:所有设置项可以正常读写
|
||||
- [x] 设置服务:设置变更可以正确通知前端
|
||||
- [x] 安全服务:密码加密解密正确
|
||||
- [x] 权限服务:权限检查逻辑正确
|
||||
- [x] 认证服务:登录登出功能正常
|
||||
- [x] 认证服务:密码设置和恢复功能正常
|
||||
- [x] 主题服务:内置主题正确加载
|
||||
- [x] 主题服务:自定义主题可以保存和删除
|
||||
- [x] 主题服务:主题切换可以正确通知前端
|
||||
- [x] 自动评分服务:规则可以正确添加和执行
|
||||
- [x] 日志服务:日志可以正确写入和查询
|
||||
- [x] 数据服务:数据导入导出功能正常
|
||||
|
||||
## Phase 4: Tauri Commands
|
||||
- [x] 学生相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 标签相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 原因相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 事件相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 结算相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 设置相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 认证相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 主题相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 自动评分相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 日志相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 数据导入导出 Commands 返回数据格式与原项目一致
|
||||
- [x] 窗口相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 数据库连接相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 文件系统相关 Commands 返回数据格式与原项目一致
|
||||
- [x] HTTP 服务器相关 Commands 返回数据格式与原项目一致
|
||||
- [x] 应用相关 Commands 返回数据格式与原项目一致
|
||||
|
||||
## Phase 5: 系统功能
|
||||
- [x] 系统托盘图标正确显示
|
||||
- [x] 托盘菜单功能正常
|
||||
- [x] 双击托盘可以显示主窗口
|
||||
- [x] URL 协议可以正确注册和处理
|
||||
- [x] 单实例运行检测正常
|
||||
|
||||
## Phase 6: 前端适配
|
||||
- [x] 所有 window.api 调用已替换为 Tauri invoke
|
||||
- [x] 事件监听(theme_updated, settings_changed 等)正常工作
|
||||
- [x] 窗口控制按钮功能正常
|
||||
- [x] 窗口拖拽区域正常工作
|
||||
- [x] Electron 特定代码已移除
|
||||
|
||||
## Phase 7: 构建与测试
|
||||
- [ ] 开发环境可以正常运行
|
||||
- [ ] 生产构建成功
|
||||
- [ ] Windows 安装包可以正常安装和运行
|
||||
- [ ] 所有 UI 功能与原项目完全一致
|
||||
- [ ] 学生管理功能正常
|
||||
- [ ] 积分操作功能正常
|
||||
- [ ] 排行榜功能正常
|
||||
- [ ] 结算功能正常
|
||||
- [ ] 原因管理功能正常
|
||||
- [ ] 标签管理功能正常
|
||||
- [ ] 设置功能正常
|
||||
- [ ] 认证功能正常
|
||||
- [ ] 主题切换功能正常
|
||||
- [ ] 自动评分功能正常
|
||||
- [ ] 数据导入导出功能正常
|
||||
- [ ] 日志查看功能正常
|
||||
- [ ] 窗口控制功能正常
|
||||
- [ ] 系统托盘功能正常
|
||||
- [ ] URL 协议功能正常
|
||||
@@ -1,399 +0,0 @@
|
||||
# SecScore Electron 到 Tauri 迁移规格
|
||||
|
||||
## Why
|
||||
将现有的 Electron 项目迁移到 Rust + Tauri 框架,以获得更小的打包体积、更好的性能和更低的资源消耗,同时保持所有现有功能和 UI 逻辑完全一致。
|
||||
|
||||
## What Changes
|
||||
- **BREAKING**: 后端从 Node.js/TypeScript 迁移到 Rust
|
||||
- **BREAKING**: 框架从 Electron 迁移到 Tauri 2.x
|
||||
- 前端 React 代码保持不变,仅修改 IPC 通信层
|
||||
- 数据库层从 TypeORM + better-sqlite3 迁移到 Rust 的 SeaORM + Rusqlite
|
||||
- 所有后端服务用 Rust 重写,功能实现保持一致
|
||||
|
||||
## Impact
|
||||
- Affected specs: 整个后端架构
|
||||
- Affected code:
|
||||
- `src/main/` - 全部重写为 Rust
|
||||
- `src/preload/` - 替换为 Tauri commands
|
||||
- `src/renderer/` - 仅修改 API 调用层
|
||||
- `src/shared/` - 部分逻辑迁移到 Rust
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tauri 项目初始化
|
||||
系统 SHALL 在 `SecScore-Rust` 目录下创建 Tauri 2.x 项目结构,包含:
|
||||
- Rust 后端 (src-tauri/)
|
||||
- React 前端 (复用现有前端代码)
|
||||
- Tauri 配置文件
|
||||
|
||||
#### Scenario: 项目结构创建
|
||||
- **WHEN** 初始化 Tauri 项目
|
||||
- **THEN** 应创建标准 Tauri 2.x 项目结构
|
||||
- **AND** 前端使用现有的 React + Ant Design 技术栈
|
||||
|
||||
### Requirement: 数据库层迁移
|
||||
系统 SHALL 使用 Rust 实现数据库功能,支持 SQLite 和 PostgreSQL:
|
||||
|
||||
#### Scenario: SQLite 支持
|
||||
- **WHEN** 未配置 PostgreSQL 连接字符串
|
||||
- **THEN** 使用 SQLite 作为默认数据库
|
||||
- **AND** 数据库文件路径与原项目一致
|
||||
|
||||
#### Scenario: PostgreSQL 支持
|
||||
- **WHEN** 配置了有效的 PostgreSQL 连接字符串
|
||||
- **THEN** 系统应能连接到 PostgreSQL 数据库
|
||||
- **AND** 支持连接测试和切换
|
||||
|
||||
#### Scenario: 数据库迁移
|
||||
- **WHEN** 系统启动
|
||||
- **THEN** 自动运行数据库迁移
|
||||
- **AND** 表结构与原项目完全一致
|
||||
|
||||
### Requirement: 学生管理功能
|
||||
系统 SHALL 提供完整的学生管理功能:
|
||||
|
||||
#### Scenario: 查询学生列表
|
||||
- **WHEN** 调用查询学生接口
|
||||
- **THEN** 返回所有学生信息,包含 id、name、score、tags
|
||||
- **AND** 按 score DESC, name ASC 排序
|
||||
|
||||
#### Scenario: 创建学生
|
||||
- **WHEN** 调用创建学生接口
|
||||
- **THEN** 创建新学生记录,初始分数为 0
|
||||
- **AND** 返回新创建的学生 ID
|
||||
|
||||
#### Scenario: 更新学生
|
||||
- **WHEN** 调用更新学生接口
|
||||
- **THEN** 更新指定学生的信息
|
||||
- **AND** 自动更新 updated_at 时间戳
|
||||
|
||||
#### Scenario: 删除学生
|
||||
- **WHEN** 调用删除学生接口
|
||||
- **THEN** 删除学生及其关联的积分记录
|
||||
- **AND** 使用事务确保数据一致性
|
||||
|
||||
#### Scenario: 导入学生名单
|
||||
- **WHEN** 调用导入学生接口
|
||||
- **THEN** 批量创建学生记录
|
||||
- **AND** 跳过已存在的学生
|
||||
|
||||
### Requirement: 积分事件管理
|
||||
系统 SHALL 提供完整的积分事件管理功能:
|
||||
|
||||
#### Scenario: 创建积分事件
|
||||
- **WHEN** 调用创建积分事件接口
|
||||
- **THEN** 创建积分记录并更新学生分数
|
||||
- **AND** 使用事务确保数据一致性
|
||||
- **AND** 自动生成 UUID
|
||||
|
||||
#### Scenario: 撤销积分事件
|
||||
- **WHEN** 调用撤销积分事件接口
|
||||
- **THEN** 撤销积分记录并回退学生分数
|
||||
- **AND** 已结算的记录不可撤销
|
||||
|
||||
#### Scenario: 查询积分事件
|
||||
- **WHEN** 调用查询积分事件接口
|
||||
- **THEN** 返回指定条件的积分记录
|
||||
- **AND** 支持按学生、时间范围筛选
|
||||
|
||||
#### Scenario: 查询排行榜
|
||||
- **WHEN** 调用查询排行榜接口
|
||||
- **THEN** 返回指定时间范围的排行榜数据
|
||||
- **AND** 支持 today、week、month 三种范围
|
||||
|
||||
### Requirement: 原因管理功能
|
||||
系统 SHALL 提供完整的积分原因管理功能:
|
||||
|
||||
#### Scenario: 查询原因列表
|
||||
- **WHEN** 调用查询原因接口
|
||||
- **THEN** 返回所有积分原因
|
||||
- **AND** 按 category ASC, content ASC 排序
|
||||
|
||||
#### Scenario: 创建原因
|
||||
- **WHEN** 调用创建原因接口
|
||||
- **THEN** 创建新的积分原因
|
||||
- **AND** 默认 is_system 为 0
|
||||
|
||||
#### Scenario: 更新原因
|
||||
- **WHEN** 调用更新原因接口
|
||||
- **THEN** 更新指定原因的信息
|
||||
|
||||
#### Scenario: 删除原因
|
||||
- **WHEN** 调用删除原因接口
|
||||
- **THEN** 删除指定原因
|
||||
|
||||
### Requirement: 结算功能
|
||||
系统 SHALL 提供完整的结算功能:
|
||||
|
||||
#### Scenario: 查询结算列表
|
||||
- **WHEN** 调用查询结算接口
|
||||
- **THEN** 返回所有结算记录
|
||||
- **AND** 包含每个结算的事件数量
|
||||
|
||||
#### Scenario: 创建结算
|
||||
- **WHEN** 调用创建结算接口
|
||||
- **THEN** 将所有未结算事件标记为已结算
|
||||
- **AND** 重置所有学生分数为 0
|
||||
- **AND** 使用事务确保数据一致性
|
||||
|
||||
#### Scenario: 查询结算排行榜
|
||||
- **WHEN** 调用查询结算排行榜接口
|
||||
- **THEN** 返回指定结算的排行榜数据
|
||||
|
||||
### Requirement: 标签管理功能
|
||||
系统 SHALL 提供完整的学生标签管理功能:
|
||||
|
||||
#### Scenario: 查询所有标签
|
||||
- **WHEN** 调用查询标签接口
|
||||
- **THEN** 返回所有标签列表
|
||||
|
||||
#### Scenario: 创建标签
|
||||
- **WHEN** 调用创建标签接口
|
||||
- **THEN** 创建新标签或返回已存在的标签
|
||||
|
||||
#### Scenario: 删除标签
|
||||
- **WHEN** 调用删除标签接口
|
||||
- **THEN** 删除指定标签及其关联
|
||||
|
||||
#### Scenario: 更新学生标签
|
||||
- **WHEN** 调用更新学生标签接口
|
||||
- **THEN** 更新指定学生的标签关联
|
||||
|
||||
### Requirement: 设置管理功能
|
||||
系统 SHALL 提供完整的设置管理功能:
|
||||
|
||||
#### Scenario: 获取所有设置
|
||||
- **WHEN** 调用获取所有设置接口
|
||||
- **THEN** 返回所有设置项
|
||||
|
||||
#### Scenario: 获取单个设置
|
||||
- **WHEN** 调用获取设置接口
|
||||
- **THEN** 返回指定设置项的值
|
||||
|
||||
#### Scenario: 设置值
|
||||
- **WHEN** 调用设置值接口
|
||||
- **THEN** 更新指定设置项
|
||||
- **AND** 通知所有窗口设置变更
|
||||
|
||||
#### Scenario: 设置项定义
|
||||
- **AND** 支持以下设置项:
|
||||
- is_wizard_completed: boolean
|
||||
- log_level: string (debug/info/warn/error)
|
||||
- window_zoom: number
|
||||
- themes_custom: json array
|
||||
- auto_score_enabled: boolean
|
||||
- auto_score_rules: json array
|
||||
- current_theme_id: string
|
||||
- pg_connection_string: string
|
||||
- pg_connection_status: json object
|
||||
|
||||
### Requirement: 认证与安全功能
|
||||
系统 SHALL 提供完整的认证与安全功能:
|
||||
|
||||
#### Scenario: 获取认证状态
|
||||
- **WHEN** 调用获取认证状态接口
|
||||
- **THEN** 返回当前权限级别和密码设置状态
|
||||
|
||||
#### Scenario: 登录
|
||||
- **WHEN** 调用登录接口
|
||||
- **THEN** 验证密码并设置权限级别
|
||||
- **AND** 支持 admin 和 points 两种密码
|
||||
|
||||
#### Scenario: 登出
|
||||
- **WHEN** 调用登出接口
|
||||
- **THEN** 重置权限级别为默认值
|
||||
|
||||
#### Scenario: 设置密码
|
||||
- **WHEN** 调用设置密码接口
|
||||
- **THEN** 加密存储密码
|
||||
- **AND** 自动生成恢复字符串
|
||||
|
||||
#### Scenario: 恢复密码
|
||||
- **WHEN** 调用恢复密码接口
|
||||
- **THEN** 使用恢复字符串重置所有密码
|
||||
|
||||
#### Scenario: 加密实现
|
||||
- **WHEN** 存储敏感数据
|
||||
- **THEN** 使用 AES-256-CBC 加密
|
||||
- **AND** 使用应用数据目录派生的密钥
|
||||
|
||||
### Requirement: 主题管理功能
|
||||
系统 SHALL 提供完整的主题管理功能:
|
||||
|
||||
#### Scenario: 获取主题列表
|
||||
- **WHEN** 调用获取主题列表接口
|
||||
- **THEN** 返回内置主题和自定义主题
|
||||
|
||||
#### Scenario: 获取当前主题
|
||||
- **WHEN** 调用获取当前主题接口
|
||||
- **THEN** 返回当前激活的主题配置
|
||||
|
||||
#### Scenario: 设置主题
|
||||
- **WHEN** 调用设置主题接口
|
||||
- **THEN** 切换到指定主题
|
||||
- **AND** 通知所有窗口主题变更
|
||||
|
||||
#### Scenario: 保存自定义主题
|
||||
- **WHEN** 调用保存主题接口
|
||||
- **THEN** 保存自定义主题配置
|
||||
- **AND** 不允许覆盖内置主题
|
||||
|
||||
#### Scenario: 删除自定义主题
|
||||
- **WHEN** 调用删除主题接口
|
||||
- **THEN** 删除指定的自定义主题
|
||||
- **AND** 不允许删除内置主题
|
||||
|
||||
### Requirement: 自动评分功能
|
||||
系统 SHALL 提供完整的自动评分功能:
|
||||
|
||||
#### Scenario: 规则管理
|
||||
- **WHEN** 管理自动评分规则
|
||||
- **THEN** 支持添加、更新、删除、排序规则
|
||||
|
||||
#### Scenario: 规则执行
|
||||
- **WHEN** 规则触发条件满足
|
||||
- **THEN** 自动执行规则动作
|
||||
- **AND** 记录执行时间
|
||||
|
||||
#### Scenario: 触发器类型
|
||||
- **AND** 支持以下触发器:
|
||||
- interval_time_passed: 定时触发
|
||||
- student_has_tag: 学生标签触发
|
||||
|
||||
#### Scenario: 动作类型
|
||||
- **AND** 支持以下动作:
|
||||
- add_score: 添加积分
|
||||
- add_tag: 添加标签
|
||||
|
||||
### Requirement: 数据导入导出功能
|
||||
系统 SHALL 提供完整的数据导入导出功能:
|
||||
|
||||
#### Scenario: 导出数据
|
||||
- **WHEN** 调用导出数据接口
|
||||
- **THEN** 导出所有数据为 JSON 格式
|
||||
|
||||
#### Scenario: 导入数据
|
||||
- **WHEN** 调用导入数据接口
|
||||
- **THEN** 从 JSON 导入数据
|
||||
- **AND** 重新加载设置
|
||||
|
||||
### Requirement: 日志管理功能
|
||||
系统 SHALL 提供完整的日志管理功能:
|
||||
|
||||
#### Scenario: 查询日志
|
||||
- **WHEN** 调用查询日志接口
|
||||
- **THEN** 返回最近的日志记录
|
||||
|
||||
#### Scenario: 清除日志
|
||||
- **WHEN** 调用清除日志接口
|
||||
- **THEN** 删除所有日志文件
|
||||
|
||||
#### Scenario: 设置日志级别
|
||||
- **WHEN** 调用设置日志级别接口
|
||||
- **THEN** 更新日志级别
|
||||
|
||||
#### Scenario: 日志文件
|
||||
- **AND** 日志文件按日期滚动
|
||||
- **AND** 保留最近 30 天的日志
|
||||
|
||||
### Requirement: 窗口管理功能
|
||||
系统 SHALL 提供完整的窗口管理功能:
|
||||
|
||||
#### Scenario: 窗口控制
|
||||
- **WHEN** 调用窗口控制接口
|
||||
- **THEN** 支持最小化、最大化、关闭操作
|
||||
|
||||
#### Scenario: 窗口状态
|
||||
- **WHEN** 查询窗口状态
|
||||
- **THEN** 返回窗口最大化状态
|
||||
|
||||
#### Scenario: 窗口缩放
|
||||
- **WHEN** 设置窗口缩放
|
||||
- **THEN** 应用缩放比例到所有窗口
|
||||
|
||||
#### Scenario: 窗口导航
|
||||
- **WHEN** 调用导航接口
|
||||
- **THEN** 在窗口中导航到指定路由
|
||||
|
||||
### Requirement: 系统托盘功能
|
||||
系统 SHALL 提供系统托盘功能:
|
||||
|
||||
#### Scenario: 托盘初始化
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 创建系统托盘图标
|
||||
|
||||
#### Scenario: 托盘菜单
|
||||
- **WHEN** 右键点击托盘图标
|
||||
- **THEN** 显示菜单:显示主窗口、重启应用、关闭应用
|
||||
|
||||
#### Scenario: 双击托盘
|
||||
- **WHEN** 双击托盘图标
|
||||
- **THEN** 显示主窗口
|
||||
|
||||
### Requirement: URL 协议功能
|
||||
系统 SHALL 提供自定义 URL 协议功能:
|
||||
|
||||
#### Scenario: 协议注册
|
||||
- **WHEN** 应用启动(非开发模式)
|
||||
- **THEN** 注册 secscore:// 协议
|
||||
|
||||
#### Scenario: 协议处理
|
||||
- **WHEN** 收到协议 URL
|
||||
- **THEN** 解析并导航到对应路由
|
||||
- **AND** 支持路由:home、students、score、leaderboard、settlements、reasons、settings
|
||||
|
||||
### Requirement: 单实例运行
|
||||
系统 SHALL 确保应用单实例运行:
|
||||
|
||||
#### Scenario: 单实例检查
|
||||
- **WHEN** 启动第二个实例
|
||||
- **THEN** 激活已有实例并退出
|
||||
|
||||
### Requirement: 前端 API 适配
|
||||
系统 SHALL 保持前端 API 调用接口不变:
|
||||
|
||||
#### Scenario: API 兼容
|
||||
- **WHEN** 前端调用 window.api
|
||||
- **THEN** 返回与原项目相同格式的响应
|
||||
- **AND** 所有接口签名保持一致
|
||||
|
||||
### Requirement: 文件系统功能
|
||||
系统 SHALL 提供配置文件管理功能:
|
||||
|
||||
#### Scenario: 获取配置目录结构
|
||||
- **WHEN** 调用获取配置目录结构接口
|
||||
- **THEN** 返回 automatic 和 script 目录路径
|
||||
|
||||
#### Scenario: 读写 JSON 文件
|
||||
- **WHEN** 调用读写 JSON 接口
|
||||
- **THEN** 在指定目录读写 JSON 文件
|
||||
|
||||
#### Scenario: 读写文本文件
|
||||
- **WHEN** 调用读写文本接口
|
||||
- **THEN** 在指定目录读写文本文件
|
||||
|
||||
#### Scenario: 文件列表
|
||||
- **WHEN** 调用文件列表接口
|
||||
- **THEN** 返回指定目录的文件列表
|
||||
|
||||
### Requirement: HTTP 服务器功能
|
||||
系统 SHALL 提供可选的 HTTP API 服务:
|
||||
|
||||
#### Scenario: 启动服务器
|
||||
- **WHEN** 调用启动服务器接口
|
||||
- **THEN** 启动 HTTP 服务器
|
||||
- **AND** 支持自定义端口和 CORS 配置
|
||||
|
||||
#### Scenario: 停止服务器
|
||||
- **WHEN** 调用停止服务器接口
|
||||
- **THEN** 停止 HTTP 服务器
|
||||
|
||||
#### Scenario: 服务器状态
|
||||
- **WHEN** 调用服务器状态接口
|
||||
- **THEN** 返回服务器运行状态
|
||||
|
||||
## MODIFIED Requirements
|
||||
无修改的需求,所有功能保持原有实现逻辑。
|
||||
|
||||
## REMOVED Requirements
|
||||
无移除的需求。
|
||||
@@ -1,244 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
## Phase 1: 项目初始化
|
||||
|
||||
- [x] Task 1: 创建 Tauri 项目结构
|
||||
- [x] SubTask 1.1: 在 SecScore-Rust 目录初始化 Tauri 2.x 项目
|
||||
- [x] SubTask 1.2: 配置 Cargo.toml 依赖(tauri, serde, tokio, sqlx, sea-orm 等)
|
||||
- [x] SubTask 1.3: 配置 tauri.conf.json 基本设置
|
||||
- [x] SubTask 1.4: 复制前端代码到新项目
|
||||
- [x] SubTask 1.5: 配置前端构建脚本
|
||||
|
||||
## Phase 2: 数据库层实现
|
||||
|
||||
- [x] Task 2: 实现数据库实体和迁移
|
||||
- [x] SubTask 2.1: 定义数据库实体结构(Student, Reason, ScoreEvent, Settlement, Setting, Tag, StudentTag)
|
||||
- [x] SubTask 2.2: 实现 SQLite 数据库连接
|
||||
- [x] SubTask 2.3: 实现 PostgreSQL 数据库连接
|
||||
- [x] SubTask 2.4: 实现数据库迁移逻辑
|
||||
- [x] SubTask 2.5: 实现连接切换功能
|
||||
|
||||
- [x] Task 3: 实现数据库仓储层
|
||||
- [x] SubTask 3.1: 实现 StudentRepository(查询、创建、更新、删除、导入)
|
||||
- [x] SubTask 3.2: 实现 EventRepository(查询、创建、删除、排行榜)
|
||||
- [x] SubTask 3.3: 实现 ReasonRepository(查询、创建、更新、删除)
|
||||
- [x] SubTask 3.4: 实现 SettlementRepository(查询、创建、排行榜)
|
||||
- [x] SubTask 3.5: 实现 TagRepository(查询、创建、删除、学生标签关联)
|
||||
|
||||
## Phase 3: 核心服务实现
|
||||
|
||||
- [x] Task 4: 实现设置服务
|
||||
- [x] SubTask 4.1: 定义设置项结构和默认值
|
||||
- [x] SubTask 4.2: 实现设置缓存机制
|
||||
- [x] SubTask 4.3: 实现设置的增删改查
|
||||
- [x] SubTask 4.4: 实现设置变更通知
|
||||
|
||||
- [x] Task 5: 实现安全服务
|
||||
- [x] SubTask 5.1: 实现 AES-256-CBC 加密解密
|
||||
- [x] SubTask 5.2: 实现密码验证
|
||||
- [x] SubTask 5.3: 实现恢复字符串生成
|
||||
|
||||
- [x] Task 6: 实现权限服务
|
||||
- [x] SubTask 6.1: 定义权限级别(admin, points, view)
|
||||
- [x] SubTask 6.2: 实现权限检查逻辑
|
||||
- [x] SubTask 6.3: 实现权限状态管理
|
||||
|
||||
- [x] Task 7: 实现认证服务
|
||||
- [x] SubTask 7.1: 实现登录/登出功能
|
||||
- [x] SubTask 7.2: 实现密码设置功能
|
||||
- [x] SubTask 7.3: 实现恢复功能
|
||||
|
||||
- [x] Task 8: 实现主题服务
|
||||
- [x] SubTask 8.1: 定义内置主题
|
||||
- [x] SubTask 8.2: 实现主题管理功能
|
||||
- [x] SubTask 8.3: 实现主题变更通知
|
||||
|
||||
- [x] Task 9: 实现自动评分服务
|
||||
- [x] SubTask 9.1: 定义规则结构
|
||||
- [x] SubTask 9.2: 实现规则引擎
|
||||
- [x] SubTask 9.3: 实现触发器逻辑(定时、标签)
|
||||
- [x] SubTask 9.4: 实现动作执行(加分、加标签)
|
||||
- [x] SubTask 9.5: 实现规则调度
|
||||
|
||||
- [x] Task 10: 实现日志服务
|
||||
- [x] SubTask 10.1: 配置日志框架
|
||||
- [x] SubTask 10.2: 实现日志文件滚动
|
||||
- [x] SubTask 10.3: 实现日志查询和清除
|
||||
|
||||
- [x] Task 11: 实现数据服务
|
||||
- [x] SubTask 11.1: 实现数据导出为 JSON
|
||||
- [x] SubTask 11.2: 实现数据导入
|
||||
|
||||
## Phase 4: Tauri Commands 实现
|
||||
|
||||
- [x] Task 12: 实现学生相关 Commands
|
||||
- [x] SubTask 12.1: db_student_query
|
||||
- [x] SubTask 12.2: db_student_create
|
||||
- [x] SubTask 12.3: db_student_update
|
||||
- [x] SubTask 12.4: db_student_delete
|
||||
- [x] SubTask 12.5: db_student_import_from_xlsx
|
||||
|
||||
- [x] Task 13: 实现标签相关 Commands
|
||||
- [x] SubTask 13.1: tags_get_all
|
||||
- [x] SubTask 13.2: tags_get_by_student
|
||||
- [x] SubTask 13.3: tags_create
|
||||
- [x] SubTask 13.4: tags_delete
|
||||
- [x] SubTask 13.5: tags_update_student_tags
|
||||
|
||||
- [x] Task 14: 实现原因相关 Commands
|
||||
- [x] SubTask 14.1: db_reason_query
|
||||
- [x] SubTask 14.2: db_reason_create
|
||||
- [x] SubTask 14.3: db_reason_update
|
||||
- [x] SubTask 14.4: db_reason_delete
|
||||
|
||||
- [x] Task 15: 实现事件相关 Commands
|
||||
- [x] SubTask 15.1: db_event_query
|
||||
- [x] SubTask 15.2: db_event_create
|
||||
- [x] SubTask 15.3: db_event_delete
|
||||
- [x] SubTask 15.4: db_event_query_by_student
|
||||
- [x] SubTask 15.5: db_leaderboard_query
|
||||
|
||||
- [x] Task 16: 实现结算相关 Commands
|
||||
- [x] SubTask 16.1: db_settlement_query
|
||||
- [x] SubTask 16.2: db_settlement_create
|
||||
- [x] SubTask 16.3: db_settlement_leaderboard
|
||||
|
||||
- [x] Task 17: 实现设置相关 Commands
|
||||
- [x] SubTask 17.1: settings_get_all
|
||||
- [x] SubTask 17.2: settings_get
|
||||
- [x] SubTask 17.3: settings_set
|
||||
- [x] SubTask 17.4: settings_changed 事件
|
||||
|
||||
- [x] Task 18: 实现认证相关 Commands
|
||||
- [x] SubTask 18.1: auth_get_status
|
||||
- [x] SubTask 18.2: auth_login
|
||||
- [x] SubTask 18.3: auth_logout
|
||||
- [x] SubTask 18.4: auth_set_passwords
|
||||
- [x] SubTask 18.5: auth_generate_recovery
|
||||
- [x] SubTask 18.6: auth_reset_by_recovery
|
||||
- [x] SubTask 18.7: auth_clear_all
|
||||
|
||||
- [x] Task 19: 实现主题相关 Commands
|
||||
- [x] SubTask 19.1: theme_list
|
||||
- [x] SubTask 19.2: theme_current
|
||||
- [x] SubTask 19.3: theme_set
|
||||
- [x] SubTask 19.4: theme_save
|
||||
- [x] SubTask 19.5: theme_delete
|
||||
- [x] SubTask 19.6: theme_updated 事件
|
||||
|
||||
- [x] Task 20: 实现自动评分相关 Commands
|
||||
- [x] SubTask 20.1: auto_score_get_rules
|
||||
- [x] SubTask 20.2: auto_score_add_rule
|
||||
- [x] SubTask 20.3: auto_score_update_rule
|
||||
- [x] SubTask 20.4: auto_score_delete_rule
|
||||
- [x] SubTask 20.5: auto_score_toggle_rule
|
||||
- [x] SubTask 20.6: auto_score_get_status
|
||||
- [x] SubTask 20.7: auto_score_sort_rules
|
||||
|
||||
- [x] Task 21: 实现日志相关 Commands
|
||||
- [x] SubTask 21.1: log_query
|
||||
- [x] SubTask 21.2: log_clear
|
||||
- [x] SubTask 21.3: log_set_level
|
||||
- [x] SubTask 21.4: log_write
|
||||
|
||||
- [x] Task 22: 实现数据导入导出 Commands
|
||||
- [x] SubTask 22.1: data_export_json
|
||||
- [x] SubTask 22.2: data_import_json
|
||||
|
||||
- [x] Task 23: 实现窗口相关 Commands
|
||||
- [x] SubTask 23.1: window_minimize
|
||||
- [x] SubTask 23.2: window_maximize
|
||||
- [x] SubTask 23.3: window_close
|
||||
- [x] SubTask 23.4: window_is_maximized
|
||||
- [x] SubTask 23.5: window_toggle_devtools
|
||||
- [x] SubTask 23.6: window_resize
|
||||
- [x] SubTask 23.7: window_maximized_changed 事件
|
||||
- [x] SubTask 23.8: app_navigate 事件
|
||||
|
||||
- [x] Task 24: 实现数据库连接相关 Commands
|
||||
- [x] SubTask 24.1: db_test_connection
|
||||
- [x] SubTask 24.2: db_switch_connection
|
||||
- [x] SubTask 24.3: db_get_status
|
||||
- [x] SubTask 24.4: db_sync
|
||||
|
||||
- [x] Task 25: 实现文件系统相关 Commands
|
||||
- [x] SubTask 25.1: fs_get_config_structure
|
||||
- [x] SubTask 25.2: fs_read_json
|
||||
- [x] SubTask 25.3: fs_write_json
|
||||
- [x] SubTask 25.4: fs_read_text
|
||||
- [x] SubTask 25.5: fs_write_text
|
||||
- [x] SubTask 25.6: fs_delete_file
|
||||
- [x] SubTask 25.7: fs_list_files
|
||||
- [x] SubTask 25.8: fs_file_exists
|
||||
|
||||
- [x] Task 26: 实现 HTTP 服务器相关 Commands
|
||||
- [x] SubTask 26.1: http_server_start
|
||||
- [x] SubTask 26.2: http_server_stop
|
||||
- [x] SubTask 26.3: http_server_status
|
||||
|
||||
- [x] Task 27: 实现应用相关 Commands
|
||||
- [x] SubTask 27.1: app_register_url_protocol
|
||||
|
||||
## Phase 5: 系统功能实现
|
||||
|
||||
- [x] Task 28: 实现系统托盘
|
||||
- [x] SubTask 28.1: 创建托盘图标
|
||||
- [x] SubTask 28.2: 实现托盘菜单
|
||||
- [x] SubTask 28.3: 实现双击显示窗口
|
||||
|
||||
- [x] Task 29: 实现 URL 协议处理
|
||||
- [x] SubTask 29.1: 注册 secscore:// 协议
|
||||
- [x] SubTask 29.2: 处理协议 URL 并导航
|
||||
|
||||
- [x] Task 30: 实现单实例运行
|
||||
- [x] SubTask 30.1: 检测已有实例
|
||||
- [x] SubTask 30.2: 激活已有实例
|
||||
|
||||
## Phase 6: 前端适配
|
||||
|
||||
- [x] Task 31: 修改前端 API 调用层
|
||||
- [x] SubTask 31.1: 创建 Tauri invoke 封装
|
||||
- [x] SubTask 31.2: 修改所有 window.api 调用为 Tauri invoke
|
||||
- [x] SubTask 31.3: 实现事件监听(theme_updated, settings_changed 等)
|
||||
- [x] SubTask 31.4: 移除 Electron 特定代码
|
||||
|
||||
- [x] Task 32: 适配窗口控制
|
||||
- [x] SubTask 32.1: 修改标题栏组件使用 Tauri API
|
||||
- [x] SubTask 32.2: 实现窗口拖拽区域
|
||||
|
||||
## Phase 7: 构建与测试
|
||||
|
||||
- [ ] Task 33: 配置构建脚本
|
||||
- [ ] SubTask 33.1: 配置开发环境脚本
|
||||
- [ ] SubTask 33.2: 配置生产构建脚本
|
||||
- [ ] SubTask 33.3: 配置 Windows 打包
|
||||
|
||||
- [ ] Task 34: 功能验证
|
||||
- [ ] SubTask 34.1: 验证所有数据库操作
|
||||
- [ ] SubTask 34.2: 验证所有 UI 功能
|
||||
- [ ] SubTask 34.3: 验证主题切换
|
||||
- [ ] SubTask 34.4: 验证认证流程
|
||||
- [ ] SubTask 34.5: 验证自动评分
|
||||
- [ ] SubTask 34.6: 验证数据导入导出
|
||||
- [ ] SubTask 34.7: 验证系统托盘
|
||||
- [ ] SubTask 34.8: 验证 URL 协议
|
||||
|
||||
# Task Dependencies
|
||||
- [Task 2] depends on [Task 1]
|
||||
- [Task 3] depends on [Task 2]
|
||||
- [Task 4] depends on [Task 2]
|
||||
- [Task 5] depends on [Task 4]
|
||||
- [Task 6] depends on [Task 5]
|
||||
- [Task 7] depends on [Task 5, Task 6]
|
||||
- [Task 8] depends on [Task 4]
|
||||
- [Task 9] depends on [Task 3]
|
||||
- [Task 10] depends on [Task 1]
|
||||
- [Task 11] depends on [Task 3]
|
||||
- [Task 12-27] depend on [Task 3-11]
|
||||
- [Task 28] depends on [Task 1]
|
||||
- [Task 29] depends on [Task 1]
|
||||
- [Task 30] depends on [Task 1]
|
||||
- [Task 31] depends on [Task 12-27]
|
||||
- [Task 32] depends on [Task 31]
|
||||
- [Task 33] depends on [Task 32]
|
||||
- [Task 34] depends on [Task 33]
|
||||
@@ -1,15 +0,0 @@
|
||||
# Agent Collaboration Rules
|
||||
|
||||
## 分支定位
|
||||
- 当前分支(`SecScore-tauri`)主要用于推进 Tauri 版本开发与维护。
|
||||
- 不以 Electron 版本作为该分支的实现目标。
|
||||
|
||||
## 提交规范
|
||||
- 每次代码改动都必须使用中文 `commit message`。
|
||||
- 完成提交后必须推送到远程仓库对应分支(`git push`)。
|
||||
|
||||
## 执行流程
|
||||
1. 完成代码修改。
|
||||
2. 自检并确保可构建/可运行。
|
||||
3. 使用中文提交信息执行 `git commit`。
|
||||
4. 立即执行 `git push` 推送远程。
|
||||
+68
-68
@@ -1,127 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync, spawn } = require('node:child_process');
|
||||
const net = require('node:net');
|
||||
const readline = require('node:readline');
|
||||
const { execSync, spawn } = require("node:child_process")
|
||||
const net = require("node:net")
|
||||
const readline = require("node:readline")
|
||||
|
||||
function getAvailableDevices() {
|
||||
const raw = execSync('xcrun simctl list devices available -j', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const parsed = JSON.parse(raw);
|
||||
const devicesByRuntime = parsed.devices || {};
|
||||
const all = [];
|
||||
const raw = execSync("xcrun simctl list devices available -j", {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
const parsed = JSON.parse(raw)
|
||||
const devicesByRuntime = parsed.devices || {}
|
||||
const all = []
|
||||
|
||||
for (const [runtime, devices] of Object.entries(devicesByRuntime)) {
|
||||
for (const device of devices) {
|
||||
if (!device.isAvailable) continue;
|
||||
if (device.name.includes('iPad') || device.name.includes('iPhone')) {
|
||||
if (!device.isAvailable) continue
|
||||
if (device.name.includes("iPad") || device.name.includes("iPhone")) {
|
||||
all.push({
|
||||
name: device.name,
|
||||
udid: device.udid,
|
||||
state: device.state,
|
||||
runtime,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => a.name.localeCompare(b.name) || a.runtime.localeCompare(b.runtime));
|
||||
return all;
|
||||
all.sort((a, b) => a.name.localeCompare(b.name) || a.runtime.localeCompare(b.runtime))
|
||||
return all
|
||||
}
|
||||
|
||||
function promptChoice(max) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
rl.question('请输入要使用的机型编号: ', (answer) => {
|
||||
rl.close();
|
||||
const idx = Number(answer.trim());
|
||||
rl.question("请输入要使用的机型编号: ", (answer) => {
|
||||
rl.close()
|
||||
const idx = Number(answer.trim())
|
||||
if (!Number.isInteger(idx) || idx < 1 || idx > max) {
|
||||
reject(new Error(`无效编号: ${answer}`));
|
||||
return;
|
||||
reject(new Error(`无效编号: ${answer}`))
|
||||
return
|
||||
}
|
||||
resolve(idx - 1);
|
||||
});
|
||||
});
|
||||
resolve(idx - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isPortListening(port, host = '127.0.0.1') {
|
||||
function isPortListening(port, host = "127.0.0.1") {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
const socket = new net.Socket()
|
||||
let settled = false
|
||||
|
||||
const finalize = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
if (settled) return
|
||||
settled = true
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(500);
|
||||
socket.once('connect', () => finalize(true));
|
||||
socket.once('timeout', () => finalize(false));
|
||||
socket.once('error', () => finalize(false));
|
||||
socket.connect(port, host);
|
||||
});
|
||||
socket.setTimeout(500)
|
||||
socket.once("connect", () => finalize(true))
|
||||
socket.once("timeout", () => finalize(false))
|
||||
socket.once("error", () => finalize(false))
|
||||
socket.connect(port, host)
|
||||
})
|
||||
}
|
||||
|
||||
async function hasListeningDevServer(port) {
|
||||
const checks = await Promise.all([
|
||||
isPortListening(port, '127.0.0.1'),
|
||||
isPortListening(port, '::1'),
|
||||
isPortListening(port, 'localhost'),
|
||||
]);
|
||||
return checks.some(Boolean);
|
||||
isPortListening(port, "127.0.0.1"),
|
||||
isPortListening(port, "::1"),
|
||||
isPortListening(port, "localhost"),
|
||||
])
|
||||
return checks.some(Boolean)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const devices = getAvailableDevices();
|
||||
const devices = getAvailableDevices()
|
||||
|
||||
if (devices.length === 0) {
|
||||
throw new Error('未找到可用 iOS 模拟器,请先在 Xcode 安装模拟器运行时。');
|
||||
throw new Error("未找到可用 iOS 模拟器,请先在 Xcode 安装模拟器运行时。")
|
||||
}
|
||||
|
||||
console.log('可用 iOS 模拟器:');
|
||||
console.log("可用 iOS 模拟器:")
|
||||
devices.forEach((d, i) => {
|
||||
const runtime = d.runtime.replace('com.apple.CoreSimulator.SimRuntime.', '');
|
||||
console.log(`${i + 1}. ${d.name} | ${runtime} | ${d.state} | ${d.udid}`);
|
||||
});
|
||||
const runtime = d.runtime.replace("com.apple.CoreSimulator.SimRuntime.", "")
|
||||
console.log(`${i + 1}. ${d.name} | ${runtime} | ${d.state} | ${d.udid}`)
|
||||
})
|
||||
|
||||
const pickedIndex = await promptChoice(devices.length);
|
||||
const picked = devices[pickedIndex];
|
||||
console.log(`\n已选择: ${picked.name} (${picked.runtime})`);
|
||||
const pickedIndex = await promptChoice(devices.length)
|
||||
const picked = devices[pickedIndex]
|
||||
console.log(`\n已选择: ${picked.name} (${picked.runtime})`)
|
||||
|
||||
const args = ['tauri', 'ios', 'dev', picked.name];
|
||||
const tauriDevPort = Number(process.env.TAURI_DEV_PORT || 1420);
|
||||
const hasExistingDevServer = await hasListeningDevServer(tauriDevPort);
|
||||
const args = ["tauri", "ios", "dev", picked.name]
|
||||
const tauriDevPort = Number(process.env.TAURI_DEV_PORT || 1420)
|
||||
const hasExistingDevServer = await hasListeningDevServer(tauriDevPort)
|
||||
if (hasExistingDevServer) {
|
||||
console.log(`检测到 ${tauriDevPort} 端口已有 dev server,复用现有服务并跳过 beforeDevCommand。`);
|
||||
console.log(`检测到 ${tauriDevPort} 端口已有 dev server,复用现有服务并跳过 beforeDevCommand。`)
|
||||
args.push(
|
||||
'--config',
|
||||
"--config",
|
||||
JSON.stringify({
|
||||
build: {
|
||||
beforeDevCommand: `echo using-existing-dev-server-on-${tauriDevPort}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const child = spawn('pnpm', args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
const child = spawn("pnpm", args, {
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`执行失败: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
console.error(`执行失败: ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
+41
-23
@@ -1,21 +1,24 @@
|
||||
const net = require('node:net')
|
||||
const { spawn } = require('node:child_process')
|
||||
const net = require("node:net")
|
||||
const { spawn } = require("node:child_process")
|
||||
const { writeFileSync, unlinkSync, existsSync } = require("node:fs")
|
||||
const { join } = require("node:path")
|
||||
const os = require("node:os")
|
||||
|
||||
const START_PORT = Number.parseInt(process.env.TAURI_DEV_PORT_START || '1420', 10)
|
||||
const START_PORT = Number.parseInt(process.env.TAURI_DEV_PORT_START || "1420", 10)
|
||||
|
||||
function canUsePort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
|
||||
server.once('error', () => {
|
||||
server.once("error", () => {
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
server.once('listening', () => {
|
||||
server.once("listening", () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1')
|
||||
server.listen(port, "127.0.0.1")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,27 +41,42 @@ async function main() {
|
||||
|
||||
console.log(`[tauri:dev] 使用端口 ${port}`)
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const child = spawn(
|
||||
pnpmCmd,
|
||||
['tauri', 'dev', '-c', JSON.stringify(overrideConfig), ...extraArgs],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
}
|
||||
)
|
||||
const tempDir = os.tmpdir()
|
||||
const tempConfigPath = join(tempDir, `tauri-config-${Date.now()}.json`)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
try {
|
||||
writeFileSync(tempConfigPath, JSON.stringify(overrideConfig), "utf-8")
|
||||
|
||||
const extraArgs = process.argv.slice(2)
|
||||
const cmd = "pnpm"
|
||||
const args = ["tauri", "dev", "--config", tempConfigPath, ...extraArgs]
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: true,
|
||||
windowsHide: false,
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (existsSync(tempConfigPath)) {
|
||||
unlinkSync(tempConfigPath)
|
||||
}
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
} catch (error) {
|
||||
if (existsSync(tempConfigPath)) {
|
||||
unlinkSync(tempConfigPath)
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[tauri:dev] 启动失败:', error)
|
||||
console.error("[tauri:dev] 启动失败:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -42,10 +42,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"icon": ["icons/icon.png", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
|
||||
+4
-5
@@ -7,11 +7,10 @@
|
||||
--ss-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--ss-safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--ss-font-family:
|
||||
"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans",
|
||||
"STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC",
|
||||
"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
sans-serif;
|
||||
"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC",
|
||||
"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI",
|
||||
"Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
|
||||
+144
-33
@@ -211,11 +211,15 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
|
||||
|
||||
const normalizedLists = lists.length > 0 ? lists : [createDefaultList()]
|
||||
const fallbackLayout = createLeafForList(normalizedLists[0].id)
|
||||
const layout = board?.layout && typeof board.layout === "object" ? (board.layout as LayoutNode) : fallbackLayout
|
||||
const layout =
|
||||
board?.layout && typeof board.layout === "object"
|
||||
? (board.layout as LayoutNode)
|
||||
: fallbackLayout
|
||||
|
||||
return syncBoardLayout({
|
||||
id: typeof board?.id === "string" && board.id.trim() ? board.id : makeId(),
|
||||
name: typeof board?.name === "string" && board.name.trim() ? board.name.trim() : "未命名看板",
|
||||
name:
|
||||
typeof board?.name === "string" && board.name.trim() ? board.name.trim() : "未命名看板",
|
||||
lists: normalizedLists,
|
||||
layout,
|
||||
})
|
||||
@@ -283,7 +287,9 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
|
||||
|
||||
if (cards.every((item) => item.score === undefined)) return cards
|
||||
|
||||
return cards.sort((a, b) => (b.score ?? Number.MIN_SAFE_INTEGER) - (a.score ?? Number.MIN_SAFE_INTEGER))
|
||||
return cards.sort(
|
||||
(a, b) => (b.score ?? Number.MIN_SAFE_INTEGER) - (a.score ?? Number.MIN_SAFE_INTEGER)
|
||||
)
|
||||
}
|
||||
|
||||
const getAvatarText = (name: string): string => {
|
||||
@@ -312,7 +318,11 @@ const getAvatarColor = (name: string): string => {
|
||||
return palette[hash % palette.length]
|
||||
}
|
||||
|
||||
const replaceLayoutNode = (node: LayoutNode, targetId: string, replacement: LayoutNode): LayoutNode => {
|
||||
const replaceLayoutNode = (
|
||||
node: LayoutNode,
|
||||
targetId: string,
|
||||
replacement: LayoutNode
|
||||
): LayoutNode => {
|
||||
if (node.id === targetId) return replacement
|
||||
if (node.type === "leaf") return node
|
||||
return {
|
||||
@@ -551,8 +561,13 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
useEffect(() => {
|
||||
const onDataUpdated = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ category?: string }>).detail
|
||||
if (!detail?.category || detail.category === "all") runAllInBoard(activeBoard).catch(() => void 0)
|
||||
if (detail.category === "students" || detail.category === "events" || detail.category === "reasons") {
|
||||
if (!detail?.category || detail.category === "all")
|
||||
runAllInBoard(activeBoard).catch(() => void 0)
|
||||
if (
|
||||
detail.category === "students" ||
|
||||
detail.category === "events" ||
|
||||
detail.category === "reasons"
|
||||
) {
|
||||
runAllInBoard(activeBoard).catch(() => void 0)
|
||||
}
|
||||
}
|
||||
@@ -595,7 +610,9 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
|
||||
const updateBoardName = (boardId: string, name: string) => {
|
||||
mutateBoards((prev) =>
|
||||
prev.map((board) => (board.id === boardId ? { ...board, name: name || t("board.untitledBoard") } : board))
|
||||
prev.map((board) =>
|
||||
board.id === boardId ? { ...board, name: name || t("board.untitledBoard") } : board
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -727,7 +744,8 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
dataIndex: key,
|
||||
key,
|
||||
ellipsis: true,
|
||||
render: (value: any) => (value === null || value === undefined || value === "" ? "-" : String(value)),
|
||||
render: (value: any) =>
|
||||
value === null || value === undefined || value === "" ? "-" : String(value),
|
||||
}))
|
||||
: []
|
||||
|
||||
@@ -738,7 +756,11 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
loading={Boolean(runningIds[list.id])}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("common.noData")} /> }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("common.noData")} />
|
||||
),
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
size="small"
|
||||
@@ -777,7 +799,10 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
: null
|
||||
|
||||
return (
|
||||
<div key={item.key} style={{ ...(list.viewMode === "grid" ? { aspectRatio: "1 / 1" } : null) }}>
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ ...(list.viewMode === "grid" ? { aspectRatio: "1 / 1" } : null) }}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -789,13 +814,22 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: list.viewMode === "grid" ? "8px" : list.viewMode === "list" ? "10px 12px" : "12px 14px",
|
||||
padding:
|
||||
list.viewMode === "grid"
|
||||
? "8px"
|
||||
: list.viewMode === "list"
|
||||
? "10px 12px"
|
||||
: "12px 14px",
|
||||
height: list.viewMode === "grid" ? "100%" : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{rankBadge && (
|
||||
<div style={{ position: "absolute", top: "-10px", left: "-10px", fontSize: "24px" }}>{rankBadge}</div>
|
||||
<div
|
||||
style={{ position: "absolute", top: "-10px", left: "-10px", fontSize: "24px" }}
|
||||
>
|
||||
{rankBadge}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.viewMode === "grid" ? (
|
||||
@@ -827,7 +861,15 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
>
|
||||
{avatarText}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
@@ -842,10 +884,17 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
</div>
|
||||
{primaryMetric && (
|
||||
<Tag
|
||||
color={typeof primaryMetric.value === "number" && primaryMetric.value >= 0 ? "success" : "error"}
|
||||
color={
|
||||
typeof primaryMetric.value === "number" && primaryMetric.value >= 0
|
||||
? "success"
|
||||
: "error"
|
||||
}
|
||||
style={{ fontWeight: "bold", marginInlineEnd: 0 }}
|
||||
>
|
||||
{primaryMetric.label}:{primaryMetric.value > 0 ? `+${primaryMetric.value}` : primaryMetric.value}
|
||||
{primaryMetric.label}:
|
||||
{primaryMetric.value > 0
|
||||
? `+${primaryMetric.value}`
|
||||
: primaryMetric.value}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
@@ -885,7 +934,8 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
<div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{item.score !== undefined && (
|
||||
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
||||
{t("board.metrics.totalScore")}: {item.score > 0 ? `+${item.score}` : item.score}
|
||||
{t("board.metrics.totalScore")}:{" "}
|
||||
{item.score > 0 ? `+${item.score}` : item.score}
|
||||
</Tag>
|
||||
)}
|
||||
{item.rewardPoints !== undefined && (
|
||||
@@ -894,8 +944,12 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
</Tag>
|
||||
)}
|
||||
{item.weekChange !== undefined && (
|
||||
<Tag color={item.weekChange >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
||||
{t("board.metrics.weekChange")}: {item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange}
|
||||
<Tag
|
||||
color={item.weekChange >= 0 ? "success" : "error"}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{t("board.metrics.weekChange")}:{" "}
|
||||
{item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange}
|
||||
</Tag>
|
||||
)}
|
||||
{item.weekDeducted !== undefined && (
|
||||
@@ -934,11 +988,15 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
border: isSelected ? "1px solid var(--ant-color-primary, #1677ff)" : "1px solid var(--ss-border-color)",
|
||||
border: isSelected
|
||||
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||
: "1px solid var(--ss-border-color)",
|
||||
boxShadow: isSelected ? "0 8px 18px rgba(22, 119, 255, 0.14)" : undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
styles={{ body: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", padding: 12 } }}
|
||||
styles={{
|
||||
body: { flex: 1, minHeight: 0, display: "flex", flexDirection: "column", padding: 12 },
|
||||
}}
|
||||
title={<span style={{ fontWeight: 600 }}>{list.name}</span>}
|
||||
extra={
|
||||
<Space size={6}>
|
||||
@@ -950,15 +1008,24 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
onConfirm={() => removeList(board.id, list.id)}
|
||||
disabled={!canManage || board.lists.length <= 1}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} disabled={!canManage || board.lists.length <= 1}>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={!canManage || board.lists.length <= 1}
|
||||
>
|
||||
{t("board.removeList")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{errorMap[list.id] && <Alert style={{ marginBottom: 12 }} type="error" message={errorMap[list.id]} showIcon />}
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>{renderStudentView(list)}</div>
|
||||
{errorMap[list.id] && (
|
||||
<Alert style={{ marginBottom: 12 }} type="error" message={errorMap[list.id]} showIcon />
|
||||
)}
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
|
||||
{renderStudentView(list)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -988,7 +1055,9 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: `${node.ratio} 1 0`, minWidth: 0, minHeight: 0 }}>{renderLayoutNode(board, node.first)}</div>
|
||||
<div style={{ flex: `${node.ratio} 1 0`, minWidth: 0, minHeight: 0 }}>
|
||||
{renderLayoutNode(board, node.first)}
|
||||
</div>
|
||||
<div
|
||||
onMouseDown={(event) => {
|
||||
if (!canManage) return
|
||||
@@ -1047,13 +1116,31 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, width: "100%", minHeight: 0, flex: 1 }}>
|
||||
<div style={{ display: "flex", width: "100%", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Space align="center" size={8}>
|
||||
<Typography.Title level={2} style={{ margin: 0, color: "var(--ss-text-main)" }}>
|
||||
{t("board.title")}
|
||||
</Typography.Title>
|
||||
<Tag color={canManage ? "success" : "default"}>{canManage ? t("board.editable") : t("board.readonly")}</Tag>
|
||||
<Tag color={canManage ? "success" : "default"}>
|
||||
{canManage ? t("board.editable") : t("board.readonly")}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -1070,7 +1157,11 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
>
|
||||
{t("board.splitVertical")}
|
||||
</Button>
|
||||
<Button icon={<EditOutlined />} onClick={openRenameModal} disabled={!canManage || !activeBoard}>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={openRenameModal}
|
||||
disabled={!canManage || !activeBoard}
|
||||
>
|
||||
{t("board.renameBoard")}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
@@ -1100,7 +1191,16 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
/>
|
||||
|
||||
{activeBoard ? (
|
||||
<div style={{ width: "100%", minHeight: 0, flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{saving && (
|
||||
<Typography.Text type="secondary" style={{ flexShrink: 0 }}>
|
||||
{t("board.saving")}
|
||||
@@ -1123,7 +1223,11 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
>
|
||||
<Input value={renameValue} onChange={(e) => setRenameValue(e.target.value)} placeholder={t("board.boardNamePlaceholder")} />
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder={t("board.boardNamePlaceholder")}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -1147,14 +1251,19 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
<Select
|
||||
style={{ width: 260 }}
|
||||
placeholder={t("board.applyPreset")}
|
||||
options={presets.map((preset) => ({ value: preset.id, label: `${preset.name} · ${preset.description}` }))}
|
||||
options={presets.map((preset) => ({
|
||||
value: preset.id,
|
||||
label: `${preset.name} · ${preset.description}`,
|
||||
}))}
|
||||
onChange={(presetId) => applyPreset(activeBoard.id, editingList.id, presetId)}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={editingList.viewMode}
|
||||
onChange={(viewMode: BoardStudentViewMode) => updateList(activeBoard.id, editingList.id, { viewMode })}
|
||||
onChange={(viewMode: BoardStudentViewMode) =>
|
||||
updateList(activeBoard.id, editingList.id, { viewMode })
|
||||
}
|
||||
options={[
|
||||
{ value: "list", label: t("board.viewModes.list") },
|
||||
{ value: "card", label: t("board.viewModes.card") },
|
||||
@@ -1170,7 +1279,9 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
disabled={!canManage}
|
||||
spellCheck={false}
|
||||
placeholder={t("board.sqlPlaceholder")}
|
||||
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace" }}
|
||||
style={{
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,11 @@ interface reasonItem {
|
||||
|
||||
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
|
||||
|
||||
const withTimeout = async (promise: Promise<any>, ms: number, timeoutMessage: string): Promise<any> => {
|
||||
const withTimeout = async (
|
||||
promise: Promise<any>,
|
||||
ms: number,
|
||||
timeoutMessage: string
|
||||
): Promise<any> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race<any>([
|
||||
@@ -115,17 +119,22 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const steps: oobeStep[] = ["entry", "language", "theme", "password", "students", "reasons", "start"]
|
||||
const steps: oobeStep[] = [
|
||||
"entry",
|
||||
"language",
|
||||
"theme",
|
||||
"password",
|
||||
"students",
|
||||
"reasons",
|
||||
"start",
|
||||
]
|
||||
const stepIndex = steps.indexOf(currentStep) + 1
|
||||
const totalSteps = steps.length
|
||||
|
||||
const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF"
|
||||
const isDark = workingTheme?.mode === "dark"
|
||||
|
||||
const showOobeMessage = (
|
||||
type: "success" | "error" | "warning" | "info",
|
||||
content: string
|
||||
) => {
|
||||
const showOobeMessage = (type: "success" | "error" | "warning" | "info", content: string) => {
|
||||
messageApi.open({
|
||||
type,
|
||||
content,
|
||||
@@ -392,7 +401,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
"同步预检查超时,请稍后重试"
|
||||
)
|
||||
if (!previewRes?.success || !previewRes?.data?.can_sync) {
|
||||
throw new Error(previewRes?.message || previewRes?.data?.message || t("settings.database.uploadFailed"))
|
||||
throw new Error(
|
||||
previewRes?.message || previewRes?.data?.message || t("settings.database.uploadFailed")
|
||||
)
|
||||
}
|
||||
|
||||
if (previewRes?.data?.need_sync) {
|
||||
@@ -403,7 +414,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
)
|
||||
if (!syncApplyRes?.success || !syncApplyRes?.data?.success) {
|
||||
throw new Error(
|
||||
syncApplyRes?.data?.message || syncApplyRes?.message || t("settings.database.uploadFailed")
|
||||
syncApplyRes?.data?.message ||
|
||||
syncApplyRes?.message ||
|
||||
t("settings.database.uploadFailed")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -942,7 +955,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{currentStep !== "language" && <Button onClick={handlePrev}>{t("common.prev")}</Button>}
|
||||
{currentStep !== "language" && (
|
||||
<Button onClick={handlePrev}>{t("common.prev")}</Button>
|
||||
)}
|
||||
{currentStep !== "start" && <Button onClick={handleSkip}>{t("oobe.skip")}</Button>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}, [fetchData])
|
||||
|
||||
const sortedStudents = useMemo(
|
||||
() => [...students].sort((a, b) => b.reward_points - a.reward_points || a.name.localeCompare(b.name, "zh-CN")),
|
||||
() =>
|
||||
[...students].sort(
|
||||
(a, b) => b.reward_points - a.reward_points || a.name.localeCompare(b.name, "zh-CN")
|
||||
),
|
||||
[students]
|
||||
)
|
||||
|
||||
@@ -128,7 +131,12 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RedemptionItem> = [
|
||||
{ title: t("rewardExchange.recordStudent"), dataIndex: "student_name", key: "student_name", width: 120 },
|
||||
{
|
||||
title: t("rewardExchange.recordStudent"),
|
||||
dataIndex: "student_name",
|
||||
key: "student_name",
|
||||
width: 120,
|
||||
},
|
||||
{ title: t("rewardExchange.recordReward"), dataIndex: "reward_name", key: "reward_name" },
|
||||
{
|
||||
title: t("rewardExchange.recordCost"),
|
||||
@@ -149,7 +157,14 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("rewardExchange.title")}</h2>
|
||||
<Button
|
||||
type={exchangeMode ? "default" : "primary"}
|
||||
@@ -187,7 +202,9 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
onClick={() => handleStudentClick(student)}
|
||||
style={{
|
||||
cursor: exchangeMode ? "pointer" : "default",
|
||||
border: exchangeMode ? "1px solid var(--ant-color-primary, #1677ff)" : "1px solid var(--ss-border-color)",
|
||||
border: exchangeMode
|
||||
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||
: "1px solid var(--ss-border-color)",
|
||||
}}
|
||||
bodyStyle={{ padding: "12px" }}
|
||||
>
|
||||
@@ -196,7 +213,9 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
{points > 0 ? `+${points}` : points}
|
||||
</Tag>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{exchangeMode ? t("rewardExchange.remainingRewardPoints") : t("rewardExchange.currentScore")}
|
||||
{exchangeMode
|
||||
? t("rewardExchange.remainingRewardPoints")
|
||||
: t("rewardExchange.currentScore")}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
@@ -228,11 +247,15 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
{t("rewardExchange.currentRewardPoints", { points: selectedStudent.reward_points })}
|
||||
</div>
|
||||
{affordableRewards.length === 0 ? (
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>{t("rewardExchange.noAffordableRewards")}</div>
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>
|
||||
{t("rewardExchange.noAffordableRewards")}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{affordableRewards
|
||||
.sort((a, b) => a.cost_points - b.cost_points || a.name.localeCompare(b.name, "zh-CN"))
|
||||
.sort(
|
||||
(a, b) => a.cost_points - b.cost_points || a.name.localeCompare(b.name, "zh-CN")
|
||||
)
|
||||
.map((reward) => (
|
||||
<div
|
||||
key={reward.id}
|
||||
|
||||
@@ -105,14 +105,19 @@ export const RewardSettings: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
: await (window as any).api.rewardSettingCreate(payload)
|
||||
|
||||
if (res.success) {
|
||||
messageApi.success(editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess"))
|
||||
messageApi.success(
|
||||
editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess")
|
||||
)
|
||||
setVisible(false)
|
||||
form.resetFields()
|
||||
setEditing(null)
|
||||
fetchRewards()
|
||||
emitDataUpdated()
|
||||
} else {
|
||||
messageApi.error(res.message || (editing ? t("rewardSettings.updateFailed") : t("rewardSettings.createFailed")))
|
||||
messageApi.error(
|
||||
res.message ||
|
||||
(editing ? t("rewardSettings.updateFailed") : t("rewardSettings.createFailed"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +201,11 @@ export const RewardSettings: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
name="cost_points"
|
||||
rules={[{ required: true, message: t("rewardSettings.costRequired") }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: "100%" }} placeholder={t("rewardSettings.costPlaceholder")} />
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: "100%" }}
|
||||
placeholder={t("rewardSettings.costPlaceholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -296,7 +296,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleClearSelectedStudents}
|
||||
disabled={!selectedStudentNames || selectedStudentNames.length === 0 || !canEdit}
|
||||
disabled={
|
||||
!selectedStudentNames || selectedStudentNames.length === 0 || !canEdit
|
||||
}
|
||||
>
|
||||
{t("score.clearSelectedStudents")}
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,11 @@ type appSettings = {
|
||||
auto_score_enabled?: boolean
|
||||
}
|
||||
|
||||
const withTimeout = async (promise: Promise<any>, ms: number, timeoutMessage: string): Promise<any> => {
|
||||
const withTimeout = async (
|
||||
promise: Promise<any>,
|
||||
ms: number,
|
||||
timeoutMessage: string
|
||||
): Promise<any> => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race<any>([
|
||||
@@ -397,7 +401,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
if (!(window as any).api) return
|
||||
try {
|
||||
const statusRes = await (window as any).api.dbGetStatus()
|
||||
if (!statusRes.success || !statusRes.data?.connected || statusRes.data?.type !== "postgresql") {
|
||||
if (
|
||||
!statusRes.success ||
|
||||
!statusRes.data?.connected ||
|
||||
statusRes.data?.type !== "postgresql"
|
||||
) {
|
||||
messageApi.warning(t("settings.database.uploadNeedRemote"))
|
||||
return
|
||||
}
|
||||
@@ -408,7 +416,9 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
"同步预检查超时,请稍后重试"
|
||||
)
|
||||
if (!previewRes.success || !previewRes.data?.can_sync) {
|
||||
messageApi.error(previewRes.message || previewRes.data?.message || t("settings.database.uploadFailed"))
|
||||
messageApi.error(
|
||||
previewRes.message || previewRes.data?.message || t("settings.database.uploadFailed")
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!previewRes.data.need_sync) {
|
||||
@@ -434,9 +444,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
"执行同步超时,请稍后重试"
|
||||
)
|
||||
if (applyRes.success && applyRes.data?.success) {
|
||||
messageApi.success(
|
||||
applyRes.data?.message || t("settings.database.uploadSuccess")
|
||||
)
|
||||
messageApi.success(applyRes.data?.message || t("settings.database.uploadSuccess"))
|
||||
emitDataUpdated("all")
|
||||
await loadAll()
|
||||
} else {
|
||||
@@ -1060,7 +1068,9 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.app.title")}
|
||||
</div>
|
||||
<div style={{ color: "var(--ss-text-secondary)", marginBottom: "12px", fontSize: "12px" }}>
|
||||
<div
|
||||
style={{ color: "var(--ss-text-secondary)", marginBottom: "12px", fontSize: "12px" }}
|
||||
>
|
||||
{t("settings.app.description")}
|
||||
</div>
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
|
||||
@@ -124,7 +124,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
;(async () => {
|
||||
await loadThemes()
|
||||
await loadCurrentTheme()
|
||||
|
||||
@@ -103,7 +103,8 @@ const api = {
|
||||
deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }),
|
||||
|
||||
// DB - Reward
|
||||
rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"),
|
||||
rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("reward_setting_query"),
|
||||
rewardSettingCreate: (data: {
|
||||
name: string
|
||||
cost_points: number
|
||||
@@ -121,8 +122,9 @@ const api = {
|
||||
data?: { redemption_id: number; remaining_reward_points: number }
|
||||
message?: string
|
||||
}> => invoke("reward_redeem", { data }),
|
||||
rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("reward_redemption_query", { params }),
|
||||
rewardRedemptionQuery: (params?: {
|
||||
limit?: number
|
||||
}): Promise<{ success: boolean; data: any[] }> => invoke("reward_redemption_query", { params }),
|
||||
|
||||
// DB - Event
|
||||
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||
@@ -273,7 +275,9 @@ const api = {
|
||||
message?: string
|
||||
}
|
||||
}> => invoke("db_sync_preview"),
|
||||
dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{
|
||||
dbSyncApply: (
|
||||
strategy: "keep_local" | "keep_remote"
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string }
|
||||
}> => invoke("db_sync_apply", { strategy }),
|
||||
|
||||
Reference in New Issue
Block a user