feat:android build

This commit is contained in:
Fox_block
2026-03-20 21:34:17 +08:00
parent 5a483f781e
commit 77acab3562
18 changed files with 353 additions and 918 deletions
+1 -1
View File
@@ -54,6 +54,6 @@ coverage
.cache
.temp
.tmp
.eslintcache
# OS files
Thumbs.db
-12
View File
@@ -1,12 +0,0 @@
---
alwaysApply: false
description: 任务完成前的检查
---
1.每次完成任务之前必须跑一遍测试(比如说npm系的typecheck/lint
2.每次完成任务之前必须跑一遍格式化(比如说npm系的prettier
3.任务完成时详细输出你所做的事情和需要用户确认的问题(如有)
4.每次规划任务必须按照步骤来,每个步骤结束后遵循第三条规定。
-82
View File
@@ -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 协议功能正常
-399
View File
@@ -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
无移除的需求。
-244
View File
@@ -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]
-15
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
+1 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
+24 -9
View File
@@ -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>
+30 -7
View File
@@ -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}
+12 -3
View File
@@ -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>
+3 -1
View File
@@ -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>
+17 -7
View File
@@ -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" }}>
-1
View File
@@ -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()
+8 -4
View File
@@ -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 }),