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 .cache
.temp .temp
.tmp .tmp
.eslintcache
# OS files # OS files
Thumbs.db 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 #!/usr/bin/env node
const { execSync, spawn } = require('node:child_process'); const { execSync, spawn } = require("node:child_process")
const net = require('node:net'); const net = require("node:net")
const readline = require('node:readline'); const readline = require("node:readline")
function getAvailableDevices() { function getAvailableDevices() {
const raw = execSync('xcrun simctl list devices available -j', { const raw = execSync("xcrun simctl list devices available -j", {
encoding: 'utf8', encoding: "utf8",
stdio: ['ignore', 'pipe', 'pipe'], stdio: ["ignore", "pipe", "pipe"],
}); })
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw)
const devicesByRuntime = parsed.devices || {}; const devicesByRuntime = parsed.devices || {}
const all = []; const all = []
for (const [runtime, devices] of Object.entries(devicesByRuntime)) { for (const [runtime, devices] of Object.entries(devicesByRuntime)) {
for (const device of devices) { for (const device of devices) {
if (!device.isAvailable) continue; if (!device.isAvailable) continue
if (device.name.includes('iPad') || device.name.includes('iPhone')) { if (device.name.includes("iPad") || device.name.includes("iPhone")) {
all.push({ all.push({
name: device.name, name: device.name,
udid: device.udid, udid: device.udid,
state: device.state, state: device.state,
runtime, runtime,
}); })
} }
} }
} }
all.sort((a, b) => a.name.localeCompare(b.name) || a.runtime.localeCompare(b.runtime)); all.sort((a, b) => a.name.localeCompare(b.name) || a.runtime.localeCompare(b.runtime))
return all; return all
} }
function promptChoice(max) { function promptChoice(max) {
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}); })
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
rl.question('请输入要使用的机型编号: ', (answer) => { rl.question("请输入要使用的机型编号: ", (answer) => {
rl.close(); rl.close()
const idx = Number(answer.trim()); const idx = Number(answer.trim())
if (!Number.isInteger(idx) || idx < 1 || idx > max) { if (!Number.isInteger(idx) || idx < 1 || idx > max) {
reject(new Error(`无效编号: ${answer}`)); reject(new Error(`无效编号: ${answer}`))
return; 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) => { return new Promise((resolve) => {
const socket = new net.Socket(); const socket = new net.Socket()
let settled = false; let settled = false
const finalize = (result) => { const finalize = (result) => {
if (settled) return; if (settled) return
settled = true; settled = true
socket.destroy(); socket.destroy()
resolve(result); resolve(result)
}; }
socket.setTimeout(500); socket.setTimeout(500)
socket.once('connect', () => finalize(true)); socket.once("connect", () => finalize(true))
socket.once('timeout', () => finalize(false)); socket.once("timeout", () => finalize(false))
socket.once('error', () => finalize(false)); socket.once("error", () => finalize(false))
socket.connect(port, host); socket.connect(port, host)
}); })
} }
async function hasListeningDevServer(port) { async function hasListeningDevServer(port) {
const checks = await Promise.all([ const checks = await Promise.all([
isPortListening(port, '127.0.0.1'), isPortListening(port, "127.0.0.1"),
isPortListening(port, '::1'), isPortListening(port, "::1"),
isPortListening(port, 'localhost'), isPortListening(port, "localhost"),
]); ])
return checks.some(Boolean); return checks.some(Boolean)
} }
async function main() { async function main() {
const devices = getAvailableDevices(); const devices = getAvailableDevices()
if (devices.length === 0) { if (devices.length === 0) {
throw new Error('未找到可用 iOS 模拟器,请先在 Xcode 安装模拟器运行时。'); throw new Error("未找到可用 iOS 模拟器,请先在 Xcode 安装模拟器运行时。")
} }
console.log('可用 iOS 模拟器:'); console.log("可用 iOS 模拟器:")
devices.forEach((d, i) => { devices.forEach((d, i) => {
const runtime = d.runtime.replace('com.apple.CoreSimulator.SimRuntime.', ''); const runtime = d.runtime.replace("com.apple.CoreSimulator.SimRuntime.", "")
console.log(`${i + 1}. ${d.name} | ${runtime} | ${d.state} | ${d.udid}`); console.log(`${i + 1}. ${d.name} | ${runtime} | ${d.state} | ${d.udid}`)
}); })
const pickedIndex = await promptChoice(devices.length); const pickedIndex = await promptChoice(devices.length)
const picked = devices[pickedIndex]; const picked = devices[pickedIndex]
console.log(`\n已选择: ${picked.name} (${picked.runtime})`); console.log(`\n已选择: ${picked.name} (${picked.runtime})`)
const args = ['tauri', 'ios', 'dev', picked.name]; const args = ["tauri", "ios", "dev", picked.name]
const tauriDevPort = Number(process.env.TAURI_DEV_PORT || 1420); const tauriDevPort = Number(process.env.TAURI_DEV_PORT || 1420)
const hasExistingDevServer = await hasListeningDevServer(tauriDevPort); const hasExistingDevServer = await hasListeningDevServer(tauriDevPort)
if (hasExistingDevServer) { if (hasExistingDevServer) {
console.log(`检测到 ${tauriDevPort} 端口已有 dev server,复用现有服务并跳过 beforeDevCommand。`); console.log(`检测到 ${tauriDevPort} 端口已有 dev server,复用现有服务并跳过 beforeDevCommand。`)
args.push( args.push(
'--config', "--config",
JSON.stringify({ JSON.stringify({
build: { build: {
beforeDevCommand: `echo using-existing-dev-server-on-${tauriDevPort}`, beforeDevCommand: `echo using-existing-dev-server-on-${tauriDevPort}`,
}, },
}) })
); )
} }
const child = spawn('pnpm', args, { const child = spawn("pnpm", args, {
stdio: 'inherit', stdio: "inherit",
shell: process.platform === 'win32', shell: process.platform === "win32",
}); })
child.on('exit', (code) => { child.on("exit", (code) => {
process.exit(code ?? 1); process.exit(code ?? 1)
}); })
} }
main().catch((err) => { main().catch((err) => {
console.error(`执行失败: ${err.message}`); console.error(`执行失败: ${err.message}`)
process.exit(1); process.exit(1)
}); })
+36 -18
View File
@@ -1,21 +1,24 @@
const net = require('node:net') const net = require("node:net")
const { spawn } = require('node:child_process') 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) { function canUsePort(port) {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer() const server = net.createServer()
server.once('error', () => { server.once("error", () => {
resolve(false) resolve(false)
}) })
server.once('listening', () => { server.once("listening", () => {
server.close(() => resolve(true)) 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}`) console.log(`[tauri:dev] 使用端口 ${port}`)
const extraArgs = process.argv.slice(2) const tempDir = os.tmpdir()
const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const tempConfigPath = join(tempDir, `tauri-config-${Date.now()}.json`)
const child = spawn(
pnpmCmd,
['tauri', 'dev', '-c', JSON.stringify(overrideConfig), ...extraArgs],
{
stdio: 'inherit',
env: process.env,
}
)
child.on('exit', (code, signal) => { 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) { if (signal) {
process.kill(process.pid, signal) process.kill(process.pid, signal)
return return
} }
process.exit(code ?? 1) process.exit(code ?? 1)
}) })
} catch (error) {
if (existsSync(tempConfigPath)) {
unlinkSync(tempConfigPath)
}
throw error
}
} }
main().catch((error) => { main().catch((error) => {
console.error('[tauri:dev] 启动失败:', error) console.error("[tauri:dev] 启动失败:", error)
process.exit(1) process.exit(1)
}) })
+1 -4
View File
@@ -42,10 +42,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": ["icons/icon.png", "icons/icon.ico"],
"icons/icon.png",
"icons/icon.ico"
],
"windows": { "windows": {
"certificateThumbprint": null, "certificateThumbprint": null,
"digestAlgorithm": "sha256", "digestAlgorithm": "sha256",
+4 -5
View File
@@ -7,11 +7,10 @@
--ss-safe-area-bottom: env(safe-area-inset-bottom, 0px); --ss-safe-area-bottom: env(safe-area-inset-bottom, 0px);
--ss-safe-area-left: env(safe-area-inset-left, 0px); --ss-safe-area-left: env(safe-area-inset-left, 0px);
--ss-font-family: --ss-font-family:
"PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "PingFang SC", "PingFangTC-Regular", "Hiragino Sans GB", "Hiragino Sans", "STHeiti", "Heiti SC",
"STHeiti", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI",
"Microsoft YaHei UI", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif;
sans-serif;
} }
@supports (height: 100dvh) { @supports (height: 100dvh) {
+144 -33
View File
@@ -211,11 +211,15 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
const normalizedLists = lists.length > 0 ? lists : [createDefaultList()] const normalizedLists = lists.length > 0 ? lists : [createDefaultList()]
const fallbackLayout = createLeafForList(normalizedLists[0].id) 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({ return syncBoardLayout({
id: typeof board?.id === "string" && board.id.trim() ? board.id : makeId(), 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, lists: normalizedLists,
layout, layout,
}) })
@@ -283,7 +287,9 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
if (cards.every((item) => item.score === undefined)) return cards 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 => { const getAvatarText = (name: string): string => {
@@ -312,7 +318,11 @@ const getAvatarColor = (name: string): string => {
return palette[hash % palette.length] 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.id === targetId) return replacement
if (node.type === "leaf") return node if (node.type === "leaf") return node
return { return {
@@ -551,8 +561,13 @@ ORDER BY reward_points DESC, score DESC`,
useEffect(() => { useEffect(() => {
const onDataUpdated = (e: Event) => { const onDataUpdated = (e: Event) => {
const detail = (e as CustomEvent<{ category?: string }>).detail const detail = (e as CustomEvent<{ category?: string }>).detail
if (!detail?.category || detail.category === "all") runAllInBoard(activeBoard).catch(() => void 0) if (!detail?.category || detail.category === "all")
if (detail.category === "students" || detail.category === "events" || detail.category === "reasons") { runAllInBoard(activeBoard).catch(() => void 0)
if (
detail.category === "students" ||
detail.category === "events" ||
detail.category === "reasons"
) {
runAllInBoard(activeBoard).catch(() => void 0) runAllInBoard(activeBoard).catch(() => void 0)
} }
} }
@@ -595,7 +610,9 @@ ORDER BY reward_points DESC, score DESC`,
const updateBoardName = (boardId: string, name: string) => { const updateBoardName = (boardId: string, name: string) => {
mutateBoards((prev) => 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, dataIndex: key,
key, key,
ellipsis: true, 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} dataSource={rows}
columns={columns} columns={columns}
loading={Boolean(runningIds[list.id])} 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 }} scroll={{ x: true }}
pagination={{ pageSize: 20, showSizeChanger: false }} pagination={{ pageSize: 20, showSizeChanger: false }}
size="small" size="small"
@@ -777,7 +799,10 @@ ORDER BY reward_points DESC, score DESC`,
: null : null
return ( 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 <Card
style={{ style={{
width: "100%", width: "100%",
@@ -789,13 +814,22 @@ ORDER BY reward_points DESC, score DESC`,
}} }}
styles={{ styles={{
body: { 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, height: list.viewMode === "grid" ? "100%" : undefined,
}, },
}} }}
> >
{rankBadge && ( {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" ? ( {list.viewMode === "grid" ? (
@@ -827,7 +861,15 @@ ORDER BY reward_points DESC, score DESC`,
> >
{avatarText} {avatarText}
</div> </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 <div
style={{ style={{
fontWeight: 600, fontWeight: 600,
@@ -842,10 +884,17 @@ ORDER BY reward_points DESC, score DESC`,
</div> </div>
{primaryMetric && ( {primaryMetric && (
<Tag <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 }} style={{ fontWeight: "bold", marginInlineEnd: 0 }}
> >
{primaryMetric.label}:{primaryMetric.value > 0 ? `+${primaryMetric.value}` : primaryMetric.value} {primaryMetric.label}:
{primaryMetric.value > 0
? `+${primaryMetric.value}`
: primaryMetric.value}
</Tag> </Tag>
)} )}
</div> </div>
@@ -885,7 +934,8 @@ ORDER BY reward_points DESC, score DESC`,
<div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}> <div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
{item.score !== undefined && ( {item.score !== undefined && (
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}> <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> </Tag>
)} )}
{item.rewardPoints !== undefined && ( {item.rewardPoints !== undefined && (
@@ -894,8 +944,12 @@ ORDER BY reward_points DESC, score DESC`,
</Tag> </Tag>
)} )}
{item.weekChange !== undefined && ( {item.weekChange !== undefined && (
<Tag color={item.weekChange >= 0 ? "success" : "error"} style={{ margin: 0 }}> <Tag
{t("board.metrics.weekChange")}: {item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange} color={item.weekChange >= 0 ? "success" : "error"}
style={{ margin: 0 }}
>
{t("board.metrics.weekChange")}:{" "}
{item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange}
</Tag> </Tag>
)} )}
{item.weekDeducted !== undefined && ( {item.weekDeducted !== undefined && (
@@ -934,11 +988,15 @@ ORDER BY reward_points DESC, score DESC`,
flexDirection: "column", flexDirection: "column",
overflow: "hidden", overflow: "hidden",
backgroundColor: "var(--ss-card-bg)", 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, boxShadow: isSelected ? "0 8px 18px rgba(22, 119, 255, 0.14)" : undefined,
cursor: "pointer", 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>} title={<span style={{ fontWeight: 600 }}>{list.name}</span>}
extra={ extra={
<Space size={6}> <Space size={6}>
@@ -950,15 +1008,24 @@ ORDER BY reward_points DESC, score DESC`,
onConfirm={() => removeList(board.id, list.id)} onConfirm={() => removeList(board.id, list.id)}
disabled={!canManage || board.lists.length <= 1} 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")} {t("board.removeList")}
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
} }
> >
{errorMap[list.id] && <Alert style={{ marginBottom: 12 }} type="error" message={errorMap[list.id]} showIcon />} {errorMap[list.id] && (
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>{renderStudentView(list)}</div> <Alert style={{ marginBottom: 12 }} type="error" message={errorMap[list.id]} showIcon />
)}
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
{renderStudentView(list)}
</div>
</Card> </Card>
) )
} }
@@ -988,7 +1055,9 @@ ORDER BY reward_points DESC, score DESC`,
minHeight: 200, 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 <div
onMouseDown={(event) => { onMouseDown={(event) => {
if (!canManage) return if (!canManage) return
@@ -1047,13 +1116,31 @@ ORDER BY reward_points DESC, score DESC`,
}} }}
> >
{contextHolder} {contextHolder}
<div style={{ display: "flex", flexDirection: "column", gap: 16, width: "100%", minHeight: 0, flex: 1 }}> <div
<div style={{ display: "flex", width: "100%", justifyContent: "space-between", alignItems: "center" }}> 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}> <Space align="center" size={8}>
<Typography.Title level={2} style={{ margin: 0, color: "var(--ss-text-main)" }}> <Typography.Title level={2} style={{ margin: 0, color: "var(--ss-text-main)" }}>
{t("board.title")} {t("board.title")}
</Typography.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>
<Space> <Space>
<Button <Button
@@ -1070,7 +1157,11 @@ ORDER BY reward_points DESC, score DESC`,
> >
{t("board.splitVertical")} {t("board.splitVertical")}
</Button> </Button>
<Button icon={<EditOutlined />} onClick={openRenameModal} disabled={!canManage || !activeBoard}> <Button
icon={<EditOutlined />}
onClick={openRenameModal}
disabled={!canManage || !activeBoard}
>
{t("board.renameBoard")} {t("board.renameBoard")}
</Button> </Button>
<Popconfirm <Popconfirm
@@ -1100,7 +1191,16 @@ ORDER BY reward_points DESC, score DESC`,
/> />
{activeBoard ? ( {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 && ( {saving && (
<Typography.Text type="secondary" style={{ flexShrink: 0 }}> <Typography.Text type="secondary" style={{ flexShrink: 0 }}>
{t("board.saving")} {t("board.saving")}
@@ -1123,7 +1223,11 @@ ORDER BY reward_points DESC, score DESC`,
okText={t("common.confirm")} okText={t("common.confirm")}
cancelText={t("common.cancel")} 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>
<Modal <Modal
@@ -1147,14 +1251,19 @@ ORDER BY reward_points DESC, score DESC`,
<Select <Select
style={{ width: 260 }} style={{ width: 260 }}
placeholder={t("board.applyPreset")} 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)} onChange={(presetId) => applyPreset(activeBoard.id, editingList.id, presetId)}
disabled={!canManage} disabled={!canManage}
/> />
<Select <Select
style={{ width: 160 }} style={{ width: 160 }}
value={editingList.viewMode} value={editingList.viewMode}
onChange={(viewMode: BoardStudentViewMode) => updateList(activeBoard.id, editingList.id, { viewMode })} onChange={(viewMode: BoardStudentViewMode) =>
updateList(activeBoard.id, editingList.id, { viewMode })
}
options={[ options={[
{ value: "list", label: t("board.viewModes.list") }, { value: "list", label: t("board.viewModes.list") },
{ value: "card", label: t("board.viewModes.card") }, { value: "card", label: t("board.viewModes.card") },
@@ -1170,7 +1279,9 @@ ORDER BY reward_points DESC, score DESC`,
disabled={!canManage} disabled={!canManage}
spellCheck={false} spellCheck={false}
placeholder={t("board.sqlPlaceholder")} 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> </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 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 let timer: ReturnType<typeof setTimeout> | undefined
try { try {
return await Promise.race<any>([ return await Promise.race<any>([
@@ -115,17 +119,22 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
const fileInputRef = useRef<HTMLInputElement>(null) 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 stepIndex = steps.indexOf(currentStep) + 1
const totalSteps = steps.length const totalSteps = steps.length
const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF" const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF"
const isDark = workingTheme?.mode === "dark" const isDark = workingTheme?.mode === "dark"
const showOobeMessage = ( const showOobeMessage = (type: "success" | "error" | "warning" | "info", content: string) => {
type: "success" | "error" | "warning" | "info",
content: string
) => {
messageApi.open({ messageApi.open({
type, type,
content, content,
@@ -392,7 +401,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
"同步预检查超时,请稍后重试" "同步预检查超时,请稍后重试"
) )
if (!previewRes?.success || !previewRes?.data?.can_sync) { 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) { if (previewRes?.data?.need_sync) {
@@ -403,7 +414,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
) )
if (!syncApplyRes?.success || !syncApplyRes?.data?.success) { if (!syncApplyRes?.success || !syncApplyRes?.data?.success) {
throw new Error( 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 }}> <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>} {currentStep !== "start" && <Button onClick={handleSkip}>{t("oobe.skip")}</Button>}
</div> </div>
+30 -7
View File
@@ -78,7 +78,10 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}, [fetchData]) }, [fetchData])
const sortedStudents = useMemo( 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] [students]
) )
@@ -128,7 +131,12 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
const columns: ColumnsType<RedemptionItem> = [ 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.recordReward"), dataIndex: "reward_name", key: "reward_name" },
{ {
title: t("rewardExchange.recordCost"), title: t("rewardExchange.recordCost"),
@@ -149,7 +157,14 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return ( return (
<div style={{ padding: "24px" }}> <div style={{ padding: "24px" }}>
{contextHolder} {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> <h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("rewardExchange.title")}</h2>
<Button <Button
type={exchangeMode ? "default" : "primary"} type={exchangeMode ? "default" : "primary"}
@@ -187,7 +202,9 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
onClick={() => handleStudentClick(student)} onClick={() => handleStudentClick(student)}
style={{ style={{
cursor: exchangeMode ? "pointer" : "default", 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" }} bodyStyle={{ padding: "12px" }}
> >
@@ -196,7 +213,9 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{points > 0 ? `+${points}` : points} {points > 0 ? `+${points}` : points}
</Tag> </Tag>
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ss-text-secondary)" }}> <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> </div>
</Card> </Card>
) )
@@ -228,11 +247,15 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{t("rewardExchange.currentRewardPoints", { points: selectedStudent.reward_points })} {t("rewardExchange.currentRewardPoints", { points: selectedStudent.reward_points })}
</div> </div>
{affordableRewards.length === 0 ? ( {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 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{affordableRewards {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) => ( .map((reward) => (
<div <div
key={reward.id} 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) : await (window as any).api.rewardSettingCreate(payload)
if (res.success) { if (res.success) {
messageApi.success(editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess")) messageApi.success(
editing ? t("rewardSettings.updateSuccess") : t("rewardSettings.createSuccess")
)
setVisible(false) setVisible(false)
form.resetFields() form.resetFields()
setEditing(null) setEditing(null)
fetchRewards() fetchRewards()
emitDataUpdated() emitDataUpdated()
} else { } 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" name="cost_points"
rules={[{ required: true, message: t("rewardSettings.costRequired") }]} 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.Item>
</Form> </Form>
</Modal> </Modal>
+3 -1
View File
@@ -296,7 +296,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<Button <Button
size="small" size="small"
onClick={handleClearSelectedStudents} onClick={handleClearSelectedStudents}
disabled={!selectedStudentNames || selectedStudentNames.length === 0 || !canEdit} disabled={
!selectedStudentNames || selectedStudentNames.length === 0 || !canEdit
}
> >
{t("score.clearSelectedStudents")} {t("score.clearSelectedStudents")}
</Button> </Button>
+17 -7
View File
@@ -27,7 +27,11 @@ type appSettings = {
auto_score_enabled?: boolean 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 let timer: ReturnType<typeof setTimeout> | undefined
try { try {
return await Promise.race<any>([ return await Promise.race<any>([
@@ -397,7 +401,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
if (!(window as any).api) return if (!(window as any).api) return
try { try {
const statusRes = await (window as any).api.dbGetStatus() 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")) messageApi.warning(t("settings.database.uploadNeedRemote"))
return return
} }
@@ -408,7 +416,9 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
"同步预检查超时,请稍后重试" "同步预检查超时,请稍后重试"
) )
if (!previewRes.success || !previewRes.data?.can_sync) { 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 return
} }
if (!previewRes.data.need_sync) { if (!previewRes.data.need_sync) {
@@ -434,9 +444,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
"执行同步超时,请稍后重试" "执行同步超时,请稍后重试"
) )
if (applyRes.success && applyRes.data?.success) { if (applyRes.success && applyRes.data?.success) {
messageApi.success( messageApi.success(applyRes.data?.message || t("settings.database.uploadSuccess"))
applyRes.data?.message || t("settings.database.uploadSuccess")
)
emitDataUpdated("all") emitDataUpdated("all")
await loadAll() await loadAll()
} else { } else {
@@ -1060,7 +1068,9 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}> <div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
{t("settings.app.title")} {t("settings.app.title")}
</div> </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")} {t("settings.app.description")}
</div> </div>
<div style={{ marginTop: "8px" }}> <div style={{ marginTop: "8px" }}>
-1
View File
@@ -124,7 +124,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
useEffect(() => { useEffect(() => {
const api = (window as any).api const api = (window as any).api
if (!api) return if (!api) return
;(async () => { ;(async () => {
await loadThemes() await loadThemes()
await loadCurrentTheme() await loadCurrentTheme()
+8 -4
View File
@@ -103,7 +103,8 @@ const api = {
deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }), deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }),
// DB - Reward // DB - Reward
rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> => invoke("reward_setting_query"), rewardSettingQuery: (): Promise<{ success: boolean; data: any[] }> =>
invoke("reward_setting_query"),
rewardSettingCreate: (data: { rewardSettingCreate: (data: {
name: string name: string
cost_points: number cost_points: number
@@ -121,8 +122,9 @@ const api = {
data?: { redemption_id: number; remaining_reward_points: number } data?: { redemption_id: number; remaining_reward_points: number }
message?: string message?: string
}> => invoke("reward_redeem", { data }), }> => invoke("reward_redeem", { data }),
rewardRedemptionQuery: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => rewardRedemptionQuery: (params?: {
invoke("reward_redemption_query", { params }), limit?: number
}): Promise<{ success: boolean; data: any[] }> => invoke("reward_redemption_query", { params }),
// DB - Event // DB - Event
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> => queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
@@ -273,7 +275,9 @@ const api = {
message?: string message?: string
} }
}> => invoke("db_sync_preview"), }> => invoke("db_sync_preview"),
dbSyncApply: (strategy: "keep_local" | "keep_remote"): Promise<{ dbSyncApply: (
strategy: "keep_local" | "keep_remote"
): Promise<{
success: boolean success: boolean
data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string } data: { success: boolean; synced_records: number; resolved_conflicts: number; message?: string }
}> => invoke("db_sync_apply", { strategy }), }> => invoke("db_sync_apply", { strategy }),