diff --git a/.gitignore b/.gitignore index af57abe..fef7ef4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,16 @@ target/ # Logs *.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* # Environment variables .env .env.local *.env.* -# Editor/IDE +# Editor / IDE .vscode/ .idea/ *.swp @@ -39,28 +42,9 @@ pip-delete-this-directory.txt nosetests.xml coverage.xml *.cover -*.db -*.log -*.pot -*.pyc -*.pyo -*.pyd -.MF -.TemporaryItems -.deps -.nfs* .pytest_cache/ .mypy_cache/ -# Node.js -npm-debug.log* -yarn-debug.log* -yarn-error.log* -node_modules/ -jspm_packages/ -*.min.js -*.min.css - # OS generated files .DS_Store .DS_Store? @@ -71,488 +55,6 @@ ehthumbs.db Thumbs.db desktop.ini -# Compiled and binary files -*.exe -*.dll -*.so -*.a -*.o -*.out -*.class -*.jar -*.war -*.ear -*.zip -*.tar -*.gz -*.tgz -*.bz2 -*.xz -*.7z -*.rar -*.zst -*.lz4 -*.lzh -*.cab -*.arj -*.rpm -*.deb -*.Z -*.lz -*.lzo -*.tar.gz -*.tar.bz2 -*.tar.xz -*.tar.zst - -# Go -*.exe -*.exe~ -*.dll -*.so -*.dylib -*.test -*.prof -coverage.out -vendor/ - -# Java -*.class -*.jar -*.war -*.ear -*.zip -hs_err_pid* -replay_pid* - -# C/C++ -*.o -*.a -*.so -*.obj -*.exe -*.dll -*.lib -*.so* -*.dylib* -*.plist -*.swp -*~ -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db -desktop.ini - -# Ruby -*.gem -*.rbc -.bundle/ -.config/byebug_history -.yardoc/ -_yardoc/ -doc/ -.rdoc -.coveralls.yml -.travis.yml -.circleci/ -.semaphore/ -appveyor.yml -.codeclimate.yml -.codacy-coverage.yml -.codacy.yml -.lgtm.yml -.reviewdog.yml -.sider.yml -.styleci.yml -.php_cs -.php_cs.dist -.php-cs-fixer.dist.php -.php-cs-fixer.php -.editorconfig -.eslintrc -.jshintrc -.jscsrc -.babelrc -.postcssrc -.prettierrc -.stylelint -.stylelintrc -.hintrc -.remarkrc -.gitattributes -.gitignore -.pre-commit-config.yaml -.mdlrc -.rubocop.yml -.slim-lint.yml -.rubocop_todo.yml -.slim-lint-report.json -.scss-lint.yml -.scss-lint-report.json -.jshintignore -.eslintignore -.babelignore -.prettierignore -.stylelintignore -.stylelintrcignore -.hintignore -.remarkignore -.gitmodules -.gitreview -.gitlab-ci.yml -.drone.yml -.direnv -.envrc -.secrets.baseline -.checkov.yaml -.tflint.hcl -.kitchen.local.yml -.kitchen.yml -.kitchen/ -.vagrant/ -.terraform/ -.terragrunt-cache/ -.chef/knife.rb -.chef/config.rb -.chef/environment/ -.chef/role/ -.chef/data_bags/ -.chef/cookbooks/ -.chef/site-cookbooks/ -.chef/environments/ -.chef/roles/ -.chef/data_bags/ -.chef/encrypted_data_bag_secret -.chef/trusted_certs/ -.chef/clients/ -.chef/nodes/ -.chef/users/ -.chef/validation.pem -.chef/client.pem -.chef/server.pem -.chef/webui_priv.pem -.chef/webui_pub.pem -.chef/private-chef-secrets.json -.chef/local-mode-cache/ -chefignore -Berksfile.lock -Berksfile -Policyfile.rb -Policyfile.lock.json -Guardfile -Capfile -Rakefile -Vagrantfile -Gemfile -Gemfile.lock -Puppetfile -Puppetfile.lock -.gitlab/issue_templates/ -.gitlab/mr_templates/ -.gitlab-ci/ -.gitlab/ci/ -.gitlab/merge_request_templates/ -.gitlab/issue_templates/ -.gitlab-feature-flag/ -.gitlab-feature-flag.yml -.gitlab-feature-flag.yaml -.gitlab-feature-flag.json -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go -.gitlab-feature-flag.java -.gitlab-feature-flag.cpp -.gitlab-feature-flag.c -.gitlab-feature-flag.h -.gitlab-feature-flag.hpp -.gitlab-feature-flag.cc -.gitlab-feature-flag.hxx -.gitlab-feature-flag.cs -.gitlab-feature-flag.fs -.gitlab-feature-flag.fsi -.gitlab-feature-flag.fsx -.gitlab-feature-flag.fsscript -.gitlab-feature-flag.vb -.gitlab-feature-flag.vbs -.gitlab-feature-flag.asp -.gitlab-feature-flag.aspx -.gitlab-feature-flag.html -.gitlab-feature-flag.htm -.gitlab-feature-flag.xhtml -.gitlab-feature-flag.css -.gitlab-feature-flag.scss -.gitlab-feature-flag.sass -.gitlab-feature-flag.less -.gitlab-feature-flag.styl -.gitlab-feature-flag.json -.gitlab-feature-flag.yaml -.gitlab-feature-flag.yml -.gitlab-feature-flag.toml -.gitlab-feature-flag.ini -.gitlab-feature-flag.cfg -.gitlab-feature-flag.conf -.gitlab-feature-flag.properties -.gitlab-feature-flag.xml -.gitlab-feature-flag.csv -.gitlab-feature-flag.tsv -.gitlab-feature-flag.sql -.gitlab-feature-flag.sh -.gitlab-feature-flag.bat -.gitlab-feature-flag.cmd -.gitlab-feature-flag.ps1 -.gitlab-feature-flag.pl -.gitlab-feature-flag.rb -.gitlab-feature-flag.py -.gitlab-feature-flag.js -.gitlab-feature-flag.ts -.gitlab-feature-flag.go...... \ No newline at end of file +# Tauri local/generated artifacts +src-tauri/data.sql +src-tauri/gen/ diff --git a/public/about-content.json b/public/about-content.json new file mode 100644 index 0000000..c183616 --- /dev/null +++ b/public/about-content.json @@ -0,0 +1,7 @@ +{ + "title": "SecScore", + "description": "教育积分管理软件", + "version": "v1.0.0", + "content": "

SecScore

\r
\r
\r

\r
\"GitHub\r
\"Downloads\"\r
\"License\"\r
\"GitHub\r
\"GitHub\r
\"Electron\"\r
\"React\"\r
\"TypeScript\"\r
\"Vite\"\r

\r
\r
SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。\r
\r

主要功能

\r
\r
\r
- 添加/删除学生\r
- 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)\r
\r
- 选择学生并提交加分/扣分\r
- 支持“预设理由”一键填充理由与分值\r
- 支持撤销最近的积分记录(撤销后学生积分会回滚)\r
\r
- 维护“预设理由”(分类、理由内容、预设分值)\r
\r
- 支持按“今天 / 本周 / 本月”查看积分变化\r
- 支持导出排行榜为 XLSX\r
- 支持查看单个学生的操作记录(文本列表)\r
\r
- “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零\r
- 在“结算历史”查看每个阶段的排行榜\r
\r
- 主题切换\r
- 日志查看/导出/清空\r
- 数据导入/导出(JSON)\r
- 密码保护(管理密码 / 积分密码)与找回字符串\r
\r

使用方法

\r
\r

1. 权限与解锁

\r
\r
\r
\r
- 管理密码:全功能(学生管理、理由管理、结算、数据管理等)\r
- 积分密码:仅允许积分相关操作\r
\r
\r
\r

2. 学生管理(导入名单)

\r
\r
入口:左侧菜单 → 学生管理 → 导入名单\r
\r
\r
1. 选择一个 .xlsx 文件(默认读取第一个工作表)\r
2. 在预览表格里点击表头,选择“姓名列”\r
3. 点击“导入”\r
4. 导入完成后会提示“新增 / 跳过”数量\r
\r
建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。\r
\r

3. 积分管理(加分/扣分)

\r
\r
入口:左侧菜单 → 积分管理\r
\r
1. 在“姓名”选择一个学生(支持搜索)\r
2. 选择“加分/扣分”,并输入分值\r
3. 在“理由内容”填写原因(可手动输入)\r
4. 点击“确认提交”\r
\r
快捷理由:\r
\r
\r
\r
撤销最近记录:\r
\r
\r
\r
\r

4. 排行榜与导出

\r
\r
入口:左侧菜单 → 排行榜\r
\r
\r
\r
\r
\r

5. 结算与数据备份

\r
\r
入口:左侧菜单 → 系统设置 → 数据管理\r
\r
\r
- 会把当前未结算的积分记录归档为一个阶段\r
- 会将所有学生当前积分清零\r
- 学生名单不变;结算后的历史在“结算历史”查看\r
\r
- 导入会覆盖现有学生/理由/积分记录/设置\r
- 安全相关设置(密码等)不会随导入写入\r
\r

开发与运行(面向贡献者)

\r
\r

MCP 文档

\r
\r
\r
\r

环境要求

\r
\r
\r
\r
\r

安装依赖

\r
\r
``bash\r
pnpm install\r
`\r
\r

开发模式

\r
\r
`bash\r
pnpm dev\r
`\r
\r

构建

\r
\r
`bash\r
pnpm build\r
`\r
\r
(可选)打包:\r
\r
`bash\r
pnpm build:win\r
pnpm build:unpack\r
`\r
\r

常用检查

\r
\r
`bash\r
pnpm lint\r
pnpm typecheck\r
``\r
", + "rawMarkdown": "# SecScore\r\n\r\n\r\n

\r\n \"GitHub\r\n \"Downloads\"\r\n \"License\"\r\n \"GitHub\r\n \"GitHub\r\n \"Electron\"\r\n \"React\"\r\n \"TypeScript\"\r\n \"Vite\"\r\n

\r\n\r\nSecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。\r\n\r\n## 主要功能\r\n\r\n- 学生管理\r\n - 添加/删除学生\r\n - 通过 xlsx 批量导入名单(支持导入前预览与选择“姓名列”)\r\n- 积分管理\r\n - 选择学生并提交加分/扣分\r\n - 支持“预设理由”一键填充理由与分值\r\n - 支持撤销最近的积分记录(撤销后学生积分会回滚)\r\n- 理由管理\r\n - 维护“预设理由”(分类、理由内容、预设分值)\r\n- 排行榜\r\n - 支持按“今天 / 本周 / 本月”查看积分变化\r\n - 支持导出排行榜为 XLSX\r\n - 支持查看单个学生的操作记录(文本列表)\r\n- 结算与历史\r\n - “结算并重新开始”:把当前未结算记录归档为一个阶段,并将所有学生当前积分清零\r\n - 在“结算历史”查看每个阶段的排行榜\r\n- 系统设置\r\n - 主题切换\r\n - 日志查看/导出/清空\r\n - 数据导入/导出(JSON)\r\n - 密码保护(管理密码 / 积分密码)与找回字符串\r\n\r\n## 使用方法\r\n\r\n### 1. 权限与解锁\r\n\r\n- 右上角会显示当前权限:管理权限 / 积分权限 / 只读\r\n- 若设置了密码,可通过右上角“输入密码”进行解锁\r\n - 管理密码:全功能(学生管理、理由管理、结算、数据管理等)\r\n - 积分密码:仅允许积分相关操作\r\n- 点击“锁定”可切回只读状态\r\n- 无密码时默认视为管理权限\r\n\r\n### 2. 学生管理(导入名单)\r\n\r\n入口:左侧菜单 → 学生管理 → 导入名单\r\n\r\n- 通过 xlsx 导入\r\n 1. 选择一个 `.xlsx` 文件(默认读取第一个工作表)\r\n 2. 在预览表格里点击表头,选择“姓名列”\r\n 3. 点击“导入”\r\n 4. 导入完成后会提示“新增 / 跳过”数量\r\n\r\n建议:姓名列尽量只包含纯姓名文本;同名学生会被视为重复并跳过。\r\n\r\n### 3. 积分管理(加分/扣分)\r\n\r\n入口:左侧菜单 → 积分管理\r\n\r\n1. 在“姓名”选择一个学生(支持搜索)\r\n2. 选择“加分/扣分”,并输入分值\r\n3. 在“理由内容”填写原因(可手动输入)\r\n4. 点击“确认提交”\r\n\r\n快捷理由:\r\n\r\n- 可在“快捷理由”下拉框中选择预设理由,一键填充理由内容/分值(优先尊重你当前是否已手动输入分值)\r\n\r\n撤销最近记录:\r\n\r\n- “最近记录”默认折叠,展开后可对记录点击“撤销”\r\n- 撤销会回滚该条记录对学生积分的影响\r\n\r\n### 4. 排行榜与导出\r\n\r\n入口:左侧菜单 → 排行榜\r\n\r\n- 可切换统计范围:今天 / 本周 / 本月\r\n- 点击“导出 XLSX”可导出当前排行榜\r\n- 点击某个学生的“查看”可打开该学生的操作记录\r\n\r\n### 5. 结算与数据备份\r\n\r\n入口:左侧菜单 → 系统设置 → 数据管理\r\n\r\n- 结算并重新开始\r\n - 会把当前未结算的积分记录归档为一个阶段\r\n - 会将所有学生当前积分清零\r\n - 学生名单不变;结算后的历史在“结算历史”查看\r\n- 导出 JSON(强烈建议定期备份)\r\n - 导入会覆盖现有学生/理由/积分记录/设置\r\n - 安全相关设置(密码等)不会随导入写入\r\n\r\n## 开发与运行(面向贡献者)\r\n\r\n## MCP 文档\r\n\r\n- [MCP-使用说明](./MCP-使用说明.md)\r\n\r\n### 环境要求\r\n\r\n- Node.js(建议使用 LTS 版本)\r\n- pnpm\r\n\r\n### 安装依赖\r\n\r\n```bash\r\npnpm install\r\n```\r\n\r\n### 开发模式\r\n\r\n```bash\r\npnpm dev\r\n```\r\n\r\n### 构建\r\n\r\n```bash\r\npnpm build\r\n```\r\n\r\n(可选)打包:\r\n\r\n```bash\r\npnpm build:win\r\npnpm build:unpack\r\n```\r\n\r\n### 常用检查\r\n\r\n```bash\r\npnpm lint\r\npnpm typecheck\r\n```\r\n" +} \ No newline at end of file diff --git a/src-tauri/src/commands/board.rs b/src-tauri/src/commands/board.rs index 6459962..43f387b 100644 --- a/src-tauri/src/commands/board.rs +++ b/src-tauri/src/commands/board.rs @@ -1,6 +1,6 @@ use parking_lot::RwLock; -use serde::Deserialize; use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; +use serde::Deserialize; use serde_json::{Map, Value as JsonValue}; use sqlx::{Column, Row, SqlitePool}; use std::collections::HashSet; @@ -155,7 +155,10 @@ async fn get_board_configs_raw(conn: &DatabaseConnection) -> Result Result<(), String> { +async fn upsert_board_configs_raw( + conn: &DatabaseConnection, + config_json: &str, +) -> Result<(), String> { let config_escaped = escape_sql(config_json); let now_escaped = escape_sql(&now_iso()); let sql = format!( diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index dd2f8bb..ec92403 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -1150,7 +1150,10 @@ pub async fn db_switch_connection( let logger = state_guard.logger.read(); logger.log( LogLevel::Info, - &format!("Database switch requested, connection_string length: {}", connection_string.len()), + &format!( + "Database switch requested, connection_string length: {}", + connection_string.len() + ), Some("database"), None, ); @@ -1225,17 +1228,15 @@ pub async fn db_switch_connection( connection_string }; - let conn = create_sqlite_connection(&path) - .await - .map_err(|e| { - logger.log( - LogLevel::Error, - &format!("SQLite connection failed: {}", e), - Some("database"), - None, - ); - e.to_string() - })?; + let conn = create_sqlite_connection(&path).await.map_err(|e| { + logger.log( + LogLevel::Error, + &format!("SQLite connection failed: {}", e), + Some("database"), + None, + ); + e.to_string() + })?; run_migration(&conn, DatabaseType::SQLite) .await .map_err(|e| { diff --git a/src-tauri/src/commands/student.rs b/src-tauri/src/commands/student.rs index 48d3027..65539a4 100644 --- a/src-tauri/src/commands/student.rs +++ b/src-tauri/src/commands/student.rs @@ -313,7 +313,9 @@ async fn post_banyou_action( params: serde_json::Value, ) -> Result { let timestamp = chrono::Utc::now().timestamp_millis(); - let url = format!("https://care.seewo.com/app/apis.json?action={action}×tamp={timestamp}&isAjax=1"); + let url = format!( + "https://care.seewo.com/app/apis.json?action={action}×tamp={timestamp}&isAjax=1" + ); let payload = serde_json::json!({ "action": action, "params": params, @@ -371,7 +373,11 @@ async fn post_banyou_action( "body_preview": first_n_chars(&body, 500), })), ); - return Err(format!("班优接口请求失败({} HTTP {})", action, status.as_u16())); + return Err(format!( + "班优接口请求失败({} HTTP {})", + action, + status.as_u16() + )); } let parsed: BanYouApiResponse = serde_json::from_str(&body).map_err(|e| { @@ -993,7 +999,9 @@ pub async fn student_fetch_banyou_classroom_detail( (Vec::new(), None) }; - let final_team_plan_id = params.team_plan_id.or_else(|| team_plans.first().map(|p| p.team_plan_id)); + let final_team_plan_id = params + .team_plan_id + .or_else(|| team_plans.first().map(|p| p.team_plan_id)); let group_data = if let Some(team_plan_id) = final_team_plan_id { match post_banyou_action::( @@ -1032,7 +1040,10 @@ pub async fn student_fetch_banyou_classroom_detail( let detail = BanYouClassroomDetailData { medals, students: students_data.students, - teams: group_data.as_ref().map(|d| d.teams.clone()).unwrap_or_default(), + teams: group_data + .as_ref() + .map(|d| d.teams.clone()) + .unwrap_or_default(), ungrouped_students: group_data .as_ref() .map(|d| d.students.clone()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ab0ed3..791873a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -318,9 +318,10 @@ fn setup_database(app: &mut App) -> Result<(), Box> { *db_guard = Some(active_conn); } - state_guard.initialize().await.map_err(|e| { - format!("Failed to initialize app state: {}", e) - })?; + state_guard + .initialize() + .await + .map_err(|e| format!("Failed to initialize app state: {}", e))?; Ok::<_, Box>(()) }); diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index 5cf4e03..bfaefc9 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Months, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; @@ -15,6 +15,20 @@ pub struct AutoScoreAction { pub value: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum IntervalUnit { + Minute, + Day, + Month, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct IntervalTriggerValue { + amount: i64, + unit: IntervalUnit, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutoScoreRule { pub id: i32, @@ -171,23 +185,17 @@ impl AutoScoreService { for trigger in &rule.triggers { if trigger.event == "interval_time_passed" { - let minutes = trigger - .value + let interval = parse_interval_trigger_value(trigger.value.as_ref()); + let base_time = rule + .last_executed .as_ref() - .and_then(|v| v.parse::().ok()) - .unwrap_or(30); - let interval_ms = minutes * 60 * 1000; + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or(now); - if let Some(last_executed_str) = &rule.last_executed { - if let Ok(last_executed) = DateTime::parse_from_rfc3339(last_executed_str) { - let last_executed_utc: DateTime = last_executed.with_timezone(&Utc); - let next_execute_time = - last_executed_utc + chrono::Duration::milliseconds(interval_ms); - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); - } - } - return Some(interval_ms); + let next_execute_time = add_interval_to_time(base_time, &interval)?; + let delay_ms = (next_execute_time - now).num_milliseconds(); + return Some(delay_ms.max(0)); } } None @@ -197,3 +205,43 @@ impl AutoScoreService { let _ = app_handle.emit("auto-score:rulesChanged", &self.rules); } } + +fn parse_interval_trigger_value(value: Option<&String>) -> IntervalTriggerValue { + if let Some(raw_value) = value { + if let Ok(minutes) = raw_value.parse::() { + if minutes > 0 { + return IntervalTriggerValue { + amount: minutes, + unit: IntervalUnit::Minute, + }; + } + } + + if let Ok(parsed_value) = serde_json::from_str::(raw_value) { + if parsed_value.amount > 0 { + return parsed_value; + } + } + } + + IntervalTriggerValue { + amount: 30, + unit: IntervalUnit::Minute, + } +} + +fn add_interval_to_time( + base_time: DateTime, + interval: &IntervalTriggerValue, +) -> Option> { + match interval.unit { + IntervalUnit::Minute => { + base_time.checked_add_signed(chrono::Duration::minutes(interval.amount)) + } + IntervalUnit::Day => base_time.checked_add_signed(chrono::Duration::days(interval.amount)), + IntervalUnit::Month => { + let months = u32::try_from(interval.amount).ok()?; + base_time.checked_add_months(Months::new(months)) + } + } +} diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index b41660f..41ea8c2 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -431,9 +431,7 @@ impl SettingsService { "settings", ]; return items.iter().all(|item| { - item.as_str() - .map(|s| allowed.contains(&s)) - .unwrap_or(false) + item.as_str().map(|s| allowed.contains(&s)).unwrap_or(false) }); } false diff --git a/src/assets/main.css b/src/assets/main.css index dac3a70..ac0acb0 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -92,6 +92,9 @@ select { .ss-sidebar .ant-menu-item { color: var(--ss-sidebar-text, var(--ss-text-main)); + height: 40px !important; + line-height: 42px !important; + border-radius: 6px !important; } .ss-sidebar .ant-menu-item-selected { @@ -318,6 +321,11 @@ html.platform-macos #root { min-height: 100%; } +.ss-route-page.is-board-page { + height: 100%; + overflow: hidden; +} + .ss-route-page.is-subpage-enter { animation: ss-subpage-enter 240ms cubic-bezier(0.22, 1, 0.36, 1); } diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx new file mode 100644 index 0000000..cdf527a --- /dev/null +++ b/src/components/AutoScoreManager.tsx @@ -0,0 +1,464 @@ +import React, { useEffect, useMemo, useState } from "react" +import { + Alert, + Button, + Card, + Form, + Input, + Pagination, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + message, +} from "antd" +import { type ImmutableTree } from "@react-awesome-query-builder/antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" +import { fetchAllTags } from "./TagEditorDialog" +import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor" +import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder" +import { + actionDraftsToPayload, + actionsToDrafts, + createDefaultActionDraft, + createEmptyTriggerTree, + createTriggerQueryConfig, + hasUnsupportedTriggerLogic, + queryTreeToTriggers, + triggersToQueryTree, + type ActionDraft, + type AutoScoreRule, + type AutoScoreTagOption, +} from "./AutoScoreManagerPage/AutoScoreUtils" + +interface StudentItem { + id: number + name: string +} + +interface TagItem { + id: number + name: string +} + +interface RuleFormValues { + name?: string + studentNames?: string[] +} + +interface AutoScoreManagerProps { + canEdit: boolean +} + +function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { + const { t, i18n } = useTranslation() + const [form] = Form.useForm() + const [messageApi, contextHolder] = message.useMessage() + const [tags, setTags] = useState([]) + + const tagOptions = useMemo( + () => + tags.map((tag) => ({ + label: tag.name, + value: tag.name, + })), + [tags] + ) + + const triggerConfig = useMemo( + () => createTriggerQueryConfig(t, tagOptions), + [i18n.resolvedLanguage, i18n.language, tagOptions] + ) + const [triggerTree, setTriggerTree] = useState(() => + createEmptyTriggerTree(triggerConfig) + ) + const [actionDrafts, setActionDrafts] = useState([createDefaultActionDraft()]) + const [students, setStudents] = useState([]) + const [rules, setRules] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [editingRuleId, setEditingRuleId] = useState(null) + const [currentPage, setCurrentPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) + if (currentPage > maxPage) { + setCurrentPage(maxPage) + } + }, [currentPage, pageSize, rules.length]) + + const resetEditor = () => { + setEditingRuleId(null) + form.setFieldsValue({ name: "", studentNames: [] }) + setTriggerTree(createEmptyTriggerTree(triggerConfig)) + setActionDrafts([createDefaultActionDraft()]) + } + + const emitDataUpdated = () => { + window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } })) + } + + const fetchStudents = async () => { + const api = (window as any).api + if (!api || !canEdit) return + + try { + const res = await api.queryStudents({}) + if (res.success && Array.isArray(res.data)) { + setStudents(res.data) + } + } catch { + void 0 + } + } + + const fetchTags = async () => { + if (!canEdit) return + + try { + const tagList = await fetchAllTags() + if (Array.isArray(tagList)) { + setTags(tagList) + } + } catch { + void 0 + } + } + + const fetchRules = async () => { + const api = (window as any).api + if (!api || !canEdit) return + + setLoading(true) + try { + const res = await api.autoScoreGetRules() + if (res.success && Array.isArray(res.data)) { + setRules(res.data) + } else { + messageApi.error(res.message || t("autoScore.fetchFailed")) + } + } catch { + messageApi.error(t("autoScore.fetchFailed")) + } finally { + setLoading(false) + } + } + + useEffect(() => { + if (!canEdit) return + fetchTags().catch(() => void 0) + fetchStudents().catch(() => void 0) + fetchRules().catch(() => void 0) + }, [canEdit]) + + const handleSubmit = async () => { + const api = (window as any).api + if (!api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const values = await form.validateFields() + const name = String(values.name || "").trim() + const studentNames = Array.isArray(values.studentNames) ? values.studentNames : [] + + if (!name) { + messageApi.warning(t("autoScore.nameRequired")) + return + } + + if (hasUnsupportedTriggerLogic(triggerTree, triggerConfig)) { + messageApi.warning(t("autoScore.unsupportedLogic")) + return + } + + const triggers = queryTreeToTriggers(triggerTree, triggerConfig) + if (triggers.length === 0) { + messageApi.warning(t("autoScore.triggerRequired")) + return + } + + const actionPayload = actionDraftsToPayload(actionDrafts) + if (actionPayload.error === "score_required") { + messageApi.warning(t("autoScore.scorePlaceholder")) + return + } + if (actionPayload.error === "tag_required") { + messageApi.warning(t("autoScore.tagNamePlaceholder")) + return + } + if (actionPayload.actions.length === 0) { + messageApi.warning(t("autoScore.actionRequired")) + return + } + + setSaving(true) + try { + const payload = { + name, + enabled: true, + studentNames, + triggers, + actions: actionPayload.actions, + } + const res = + editingRuleId === null + ? await api.autoScoreAddRule(payload) + : await api.autoScoreUpdateRule({ ...payload, id: editingRuleId }) + + if (res.success) { + messageApi.success( + editingRuleId === null ? t("autoScore.createSuccess") : t("autoScore.updateSuccess") + ) + resetEditor() + await fetchRules() + emitDataUpdated() + } else { + messageApi.error( + res.message || + (editingRuleId === null ? t("autoScore.createFailed") : t("autoScore.updateFailed")) + ) + } + } catch { + messageApi.error( + editingRuleId === null ? t("autoScore.createFailed") : t("autoScore.updateFailed") + ) + } finally { + setSaving(false) + } + } + + const handleEdit = (rule: AutoScoreRule) => { + setEditingRuleId(rule.id) + form.setFieldsValue({ + name: rule.name, + studentNames: rule.studentNames || [], + }) + setTriggerTree(triggersToQueryTree(triggerConfig, rule.triggers || [])) + setActionDrafts(actionsToDrafts(rule.actions || [])) + } + + const handleDelete = async (ruleId: number) => { + const api = (window as any).api + if (!api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const res = await api.autoScoreDeleteRule(ruleId) + if (res.success) { + messageApi.success(t("autoScore.deleteSuccess")) + if (editingRuleId === ruleId) { + resetEditor() + } + await fetchRules() + emitDataUpdated() + } else { + messageApi.error(res.message || t("autoScore.deleteFailed")) + } + } + + const handleToggle = async (ruleId: number, enabled: boolean) => { + const api = (window as any).api + if (!api) return + if (!canEdit) { + messageApi.error(t("common.readOnly")) + return + } + + const res = await api.autoScoreToggleRule({ ruleId, enabled }) + if (res.success) { + setRules((prevRules) => + prevRules.map((rule) => (rule.id === ruleId ? { ...rule, enabled } : rule)) + ) + messageApi.success(enabled ? t("autoScore.enabled") : t("autoScore.disabled")) + emitDataUpdated() + } else { + messageApi.error( + res.message || (enabled ? t("autoScore.enableFailed") : t("autoScore.disableFailed")) + ) + } + } + + const formatLastExecuted = (value?: string | null) => { + if (!value) return t("autoScore.notExecuted") + const date = new Date(value) + if (Number.isNaN(date.getTime())) return t("autoScore.invalidTime") + return date.toLocaleString() + } + + const columns: ColumnsType = [ + { + title: t("autoScore.name"), + dataIndex: "name", + key: "name", + ellipsis: true, + }, + { + title: t("autoScore.status"), + key: "status", + width: 120, + render: (_, row) => ( + { + handleToggle(row.id, checked).catch(() => void 0) + }} + /> + ), + }, + { + title: t("autoScore.applicableStudents"), + key: "studentNames", + width: 220, + render: (_, row) => + row.studentNames.length > 0 ? ( + {t("autoScore.studentCount", { count: row.studentNames.length })} + ) : ( + {t("autoScore.allStudents")} + ), + }, + { + title: t("autoScore.triggers"), + key: "triggers", + width: 150, + render: (_, row) => {t("autoScore.triggerCount", { count: row.triggers.length })}, + }, + { + title: t("autoScore.actions"), + key: "actions", + width: 140, + render: (_, row) => {t("autoScore.actionCount", { count: row.actions.length })}, + }, + { + title: t("autoScore.lastExecuted"), + dataIndex: "lastExecuted", + key: "lastExecuted", + width: 180, + render: (value: string | null | undefined) => formatLastExecuted(value), + }, + { + title: t("common.operation"), + key: "operation", + width: 160, + render: (_, row) => ( + + + { + handleDelete(row.id).catch(() => void 0) + }} + > + + + + ), + }, + ] + + const pagedRules = rules.slice((currentPage - 1) * pageSize, currentPage * pageSize) + + return ( +
+ {contextHolder} +

{t("autoScore.title")}

+ + {!canEdit && ( + + )} + + +
+
+ + + + + + updateAction(action.id, { + event, + value: event === "add_score" ? "1" : [], + }) + } + /> + {action.event === "add_score" ? ( + + updateAction(action.id, { value: nextValue === null ? "" : String(nextValue) }) + } + /> + ) : ( + +
+ ) +} + +export default IntervalValueWidget diff --git a/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx b/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx new file mode 100644 index 0000000..789405e --- /dev/null +++ b/src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from "react" +import { Card } from "antd" +import { + Builder, + Query, + type BuilderProps, + type Config, + type ImmutableTree, +} from "@react-awesome-query-builder/antd" +import { useTranslation } from "react-i18next" +import "@react-awesome-query-builder/antd/css/styles.css" + +interface TriggerRuleBuilderProps { + config: Config + value: ImmutableTree + canEdit: boolean + onChange: (nextTree: ImmutableTree, config: Config) => void +} + +export const TriggerRuleBuilder: React.FC = ({ + config, + value, + canEdit, + onChange, +}) => { + const { t } = useTranslation() + + const queryConfig = useMemo(() => { + if (canEdit) return config + return { + ...config, + settings: { + ...config.settings, + canReorder: false, + canRegroup: false, + immutableGroupsMode: true, + immutableFieldsMode: true, + immutableOpsMode: true, + immutableValuesMode: true, + }, + } as Config + }, [config, canEdit]) + + return ( + +
+ onChange(immutableTree, config)} + renderBuilder={(props: BuilderProps) => ( +
+ +
+ )} + /> +
+
+ ) +} diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index 3b93ef7..e7ac003 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -17,6 +17,7 @@ const loadStudentManager = () => import("./StudentManager") const loadSettings = () => import("./Settings") const loadReasonManager = () => import("./ReasonManager") const loadScoreManager = () => import("./ScoreManager") +const loadAutoScoreManager = () => import("./AutoScoreManager") const loadLeaderboard = () => import("./Leaderboard") const loadSettlementHistory = () => import("./SettlementHistory") const loadRewardSettings = () => import("./RewardSettings") @@ -27,6 +28,7 @@ const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m const Settings = lazy(() => loadSettings().then((m) => ({ default: m.Settings }))) const ReasonManager = lazy(() => loadReasonManager().then((m) => ({ default: m.ReasonManager }))) const ScoreManager = lazy(() => loadScoreManager().then((m) => ({ default: m.ScoreManager }))) +const AutoScoreManager = lazy(loadAutoScoreManager) const Leaderboard = lazy(() => loadLeaderboard().then((m) => ({ default: m.Leaderboard }))) const SettlementHistory = lazy(() => loadSettlementHistory().then((m) => ({ default: m.SettlementHistory })) @@ -41,6 +43,7 @@ const warmupRouteChunks = () => loadSettings(), loadReasonManager(), loadScoreManager(), + loadAutoScoreManager(), loadLeaderboard(), loadSettlementHistory(), loadRewardSettings(), @@ -92,6 +95,7 @@ export function ContentArea({ const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home") const shouldAnimateSubPage = isPortraitMode && isSubPage const normalizedPath = location.pathname === "/" ? "/home" : location.pathname + const isBoardPage = normalizedPath.startsWith("/boards") const isMobileHeaderMode = isPortraitMode && isMobileDevice && !immersiveMode const isPrimaryMobilePage = normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings") @@ -100,6 +104,7 @@ export function ContentArea({ if (normalizedPath.startsWith("/home")) return t("sidebar.home") if (normalizedPath.startsWith("/students")) return t("sidebar.students") if (normalizedPath.startsWith("/score")) return t("sidebar.score") + if (normalizedPath.startsWith("/auto-score")) return t("sidebar.autoScore") if (normalizedPath.startsWith("/boards")) return t("sidebar.boards") if (normalizedPath.startsWith("/leaderboard")) return t("sidebar.leaderboard") if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements") @@ -317,7 +322,7 @@ export function ContentArea({
} /> + } + /> } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a1eb329..2622605 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -163,6 +163,12 @@ export function Sidebar({ icon: , label: t("sidebar.score"), }, + { + key: "auto-score", + icon: , + label: t("sidebar.autoScore"), + disabled: permission !== "admin", + }, { key: "reward-settings", icon: , diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index dd98fc8..6947474 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -781,6 +781,7 @@ "adminDeleteRequired": "Admin permission required to delete auto score automation", "adminToggleRequired": "Admin permission required to enable/disable auto score automation", "fetchFailed": "Failed to get automation", + "unsupportedLogic": "Saving currently supports AND-only conditions. OR / NOT logic is not wired to backend persistence yet", "triggerRequired": "Please add at least one trigger", "actionRequired": "Please add at least one action", "createSuccess": "Automation created successfully", @@ -813,6 +814,10 @@ "triggerStudentTag": "Student Tag", "actionAddScore": "Add Score", "actionAddTag": "Add Tag", + "intervalAmountPlaceholder": "Enter interval amount", + "intervalUnitMonth": "month(s) later", + "intervalUnitDay": "day(s) later", + "intervalUnitMinute": "minute(s) later", "minutesPlaceholder": "Enter minutes", "minutes": "minutes", "tagsPlaceholder": "Enter tag name", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 1d7162a..5a7266b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -781,6 +781,7 @@ "adminDeleteRequired": "需要管理员权限以删除自动加分自动化", "adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化", "fetchFailed": "获取自动化失败", + "unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储", "triggerRequired": "请至少添加一个触发器", "actionRequired": "请至少添加一个行动", "createSuccess": "自动化创建成功", @@ -811,6 +812,10 @@ "triggerStudentTag": "学生标签", "actionAddScore": "加分", "actionAddTag": "添加标签", + "intervalAmountPlaceholder": "请输入间隔数量", + "intervalUnitMonth": "个月后", + "intervalUnitDay": "天后", + "intervalUnitMinute": "分钟后", "minutesPlaceholder": "请输入分钟数", "minutes": "分钟", "tagsPlaceholder": "请输入标签名称", diff --git a/src/preload/types.ts b/src/preload/types.ts index 5a17ada..1074ef8 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -22,6 +22,26 @@ export interface dataUpdatedEvent { source?: string } +export interface autoScoreTrigger { + event: string + value?: string | null +} + +export interface autoScoreAction { + event: string + value?: string | null +} + +export interface autoScoreRule { + id: number + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + lastExecuted?: string | null +} + export type settingsKey = | "is_wizard_completed" | "log_level" @@ -29,6 +49,8 @@ export type settingsKey = | "search_keyboard_layout" | "disable_search_keyboard" | "themes_custom" + | "auto_score_enabled" + | "auto_score_rules" | "current_theme_id" | "dashboards_config" | "pg_connection_string" @@ -42,6 +64,8 @@ export interface settingsSpec { search_keyboard_layout: "t9" | "qwerty26" disable_search_keyboard: boolean themes_custom: themeConfig[] + auto_score_enabled: boolean + auto_score_rules: autoScoreRule[] current_theme_id: string dashboards_config: any[] pg_connection_string: string @@ -237,6 +261,46 @@ const api = { settlementId: number }): Promise<{ success: boolean; data: any }> => invoke("db_settlement_leaderboard", { params }), + // Auto Score + autoScoreGetRules: (): Promise<{ + success: boolean + data?: autoScoreRule[] + message?: string + }> => invoke("auto_score_get_rules"), + autoScoreAddRule: (rule: { + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + }): Promise<{ success: boolean; data?: number; message?: string }> => + invoke("auto_score_add_rule", { rule }), + autoScoreUpdateRule: (rule: { + id: number + name: string + enabled: boolean + studentNames: string[] + triggers: autoScoreTrigger[] + actions: autoScoreAction[] + }): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_update_rule", { rule }), + autoScoreDeleteRule: (ruleId: number): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_delete_rule", { ruleId }), + autoScoreToggleRule: (params: { + ruleId: number + enabled: boolean + }): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_toggle_rule", { params }), + autoScoreGetStatus: (): Promise<{ + success: boolean + data?: { + enabled: boolean + } + message?: string + }> => invoke("auto_score_get_status"), + autoScoreSortRules: (ruleIds: number[]): Promise<{ success: boolean; data?: boolean; message?: string }> => + invoke("auto_score_sort_rules", { ruleIds }), + // Settings & Sync getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> => invoke("settings_get_all"), @@ -493,7 +557,7 @@ const api = { appRestart: (): Promise => invoke("app_restart"), // Generic invoke wrapper for backward compatibility with callers using `api.invoke` - invoke: async (channel: string): Promise => { + invoke: async (channel: string, ..._args: any[]): Promise => { switch (channel) { default: throw new Error(`Unsupported legacy invoke channel: ${channel}`) diff --git a/src/shared/mobileNavigation.ts b/src/shared/mobileNavigation.ts index ccefe72..0052911 100644 --- a/src/shared/mobileNavigation.ts +++ b/src/shared/mobileNavigation.ts @@ -24,6 +24,7 @@ export const MOBILE_NAV_ITEMS: MobileNavItemConfig[] = [ { key: "home", path: "/home", labelKey: "sidebar.home" }, { key: "students", path: "/students", labelKey: "sidebar.students", adminOnly: true }, { key: "score", path: "/score", labelKey: "sidebar.score" }, + { key: "auto-score", path: "/auto-score", labelKey: "sidebar.autoScore", adminOnly: true }, { key: "reward-settings", path: "/reward-settings",