diff --git a/.gitignore b/.gitignore index c09cea9..f07c258 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,6 @@ coverage .cache .temp .tmp - +.eslintcache # OS files Thumbs.db diff --git a/.trae/rules/rule.md b/.trae/rules/rule.md deleted file mode 100644 index 80d5c94..0000000 --- a/.trae/rules/rule.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -alwaysApply: false -description: 任务完成前的检查 ---- - -1.每次完成任务之前必须跑一遍测试(比如说npm系的typecheck/lint) - -2.每次完成任务之前必须跑一遍格式化(比如说npm系的prettier) - -3.任务完成时详细输出你所做的事情和需要用户确认的问题(如有) - -4.每次规划任务必须按照步骤来,每个步骤结束后遵循第三条规定。 diff --git a/.trae/specs/migrate-to-tauri/checklist.md b/.trae/specs/migrate-to-tauri/checklist.md deleted file mode 100644 index 3ab71e6..0000000 --- a/.trae/specs/migrate-to-tauri/checklist.md +++ /dev/null @@ -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 协议功能正常 diff --git a/.trae/specs/migrate-to-tauri/spec.md b/.trae/specs/migrate-to-tauri/spec.md deleted file mode 100644 index be9f822..0000000 --- a/.trae/specs/migrate-to-tauri/spec.md +++ /dev/null @@ -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 -无移除的需求。 diff --git a/.trae/specs/migrate-to-tauri/tasks.md b/.trae/specs/migrate-to-tauri/tasks.md deleted file mode 100644 index 58f671d..0000000 --- a/.trae/specs/migrate-to-tauri/tasks.md +++ /dev/null @@ -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] diff --git a/agents.md b/agents.md deleted file mode 100644 index 4791322..0000000 --- a/agents.md +++ /dev/null @@ -1,15 +0,0 @@ -# Agent Collaboration Rules - -## 分支定位 -- 当前分支(`SecScore-tauri`)主要用于推进 Tauri 版本开发与维护。 -- 不以 Electron 版本作为该分支的实现目标。 - -## 提交规范 -- 每次代码改动都必须使用中文 `commit message`。 -- 完成提交后必须推送到远程仓库对应分支(`git push`)。 - -## 执行流程 -1. 完成代码修改。 -2. 自检并确保可构建/可运行。 -3. 使用中文提交信息执行 `git commit`。 -4. 立即执行 `git push` 推送远程。 diff --git a/scripts/ios-sim.js b/scripts/ios-sim.js index 5099603..d611147 100755 --- a/scripts/ios-sim.js +++ b/scripts/ios-sim.js @@ -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) +}) diff --git a/scripts/tauri-dev.cjs b/scripts/tauri-dev.cjs index d1ad7f0..608c935 100644 --- a/scripts/tauri-dev.cjs +++ b/scripts/tauri-dev.cjs @@ -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) }) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3bade52..323ba90 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/assets/main.css b/src/assets/main.css index 9fc0c22..37c3c53 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -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) { diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx index 4538812..4a7eee9 100644 --- a/src/components/BoardManager.tsx +++ b/src/components/BoardManager.tsx @@ -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: }} + locale={{ + emptyText: ( + + ), + }} scroll={{ x: true }} pagination={{ pageSize: 20, showSizeChanger: false }} size="small" @@ -777,7 +799,10 @@ ORDER BY reward_points DESC, score DESC`, : null return ( -
+
{rankBadge && ( -
{rankBadge}
+
+ {rankBadge} +
)} {list.viewMode === "grid" ? ( @@ -827,7 +861,15 @@ ORDER BY reward_points DESC, score DESC`, > {avatarText}
-
+
{primaryMetric && ( = 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} )}
@@ -885,7 +934,8 @@ ORDER BY reward_points DESC, score DESC`,
{item.score !== undefined && ( = 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} )} {item.rewardPoints !== undefined && ( @@ -894,8 +944,12 @@ ORDER BY reward_points DESC, score DESC`, )} {item.weekChange !== undefined && ( - = 0 ? "success" : "error"} style={{ margin: 0 }}> - {t("board.metrics.weekChange")}: {item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange} + = 0 ? "success" : "error"} + style={{ margin: 0 }} + > + {t("board.metrics.weekChange")}:{" "} + {item.weekChange > 0 ? `+${item.weekChange}` : item.weekChange} )} {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={{list.name}} extra={ @@ -950,15 +1008,24 @@ ORDER BY reward_points DESC, score DESC`, onConfirm={() => removeList(board.id, list.id)} disabled={!canManage || board.lists.length <= 1} > - } > - {errorMap[list.id] && } -
{renderStudentView(list)}
+ {errorMap[list.id] && ( + + )} +
+ {renderStudentView(list)} +
) } @@ -988,7 +1055,9 @@ ORDER BY reward_points DESC, score DESC`, minHeight: 200, }} > -
{renderLayoutNode(board, node.first)}
+
+ {renderLayoutNode(board, node.first)} +
{ if (!canManage) return @@ -1047,13 +1116,31 @@ ORDER BY reward_points DESC, score DESC`, }} > {contextHolder} -
-
+
+
{t("board.title")} - {canManage ? t("board.editable") : t("board.readonly")} + + {canManage ? t("board.editable") : t("board.readonly")} + - {activeBoard ? ( -
+
{saving && ( {t("board.saving")} @@ -1123,7 +1223,11 @@ ORDER BY reward_points DESC, score DESC`, okText={t("common.confirm")} cancelText={t("common.cancel")} > - setRenameValue(e.target.value)} placeholder={t("board.boardNamePlaceholder")} /> + setRenameValue(e.target.value)} + placeholder={t("board.boardNamePlaceholder")} + /> ({ 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} />