From 3facd398de87ce16c3a9437abc15777edff43d8a Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 28 Mar 2026 15:16:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=E9=99=90=E5=88=B6=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E9=A1=B5=E4=BB=85=E5=AD=A6=E7=94=9F=E5=88=97=E8=A1=A8=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/main.css | 5 +++++ src/components/ContentArea.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/assets/main.css b/src/assets/main.css index dac3a70..f517b87 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -318,6 +318,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/ContentArea.tsx b/src/components/ContentArea.tsx index ca2684d..0557604 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -92,6 +92,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") @@ -317,7 +318,7 @@ export function ContentArea({
Date: Sat, 28 Mar 2026 15:18:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dgitignore=E5=B9=B6?= =?UTF-8?q?=E6=81=A2=E5=A4=8Dtauri=E7=94=9F=E6=88=90=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 512 +---------------------------------------------------- 1 file changed, 7 insertions(+), 505 deletions(-) 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/ From 2c8cc775bbbfe94bd7522774a8a84eff3061414b Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sun, 29 Mar 2026 17:08:44 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8A=A0=E5=88=86=E8=A7=84=E5=88=99=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AutoScoreManager.tsx | 432 ++++++++++++++++++ .../AutoScoreManagerPage/ActionEditor.tsx | 97 ++++ .../AutoScoreManagerPage/AutoScoreUtils.ts | 262 +++++++++++ .../TriggerRuleBuilder.tsx | 66 +++ src/components/ContentArea.tsx | 8 + src/components/Sidebar.tsx | 6 + src/i18n/locales/en-US.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/preload/types.ts | 66 ++- src/shared/mobileNavigation.ts | 2 + 10 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 src/components/AutoScoreManager.tsx create mode 100644 src/components/AutoScoreManagerPage/ActionEditor.tsx create mode 100644 src/components/AutoScoreManagerPage/AutoScoreUtils.ts create mode 100644 src/components/AutoScoreManagerPage/TriggerRuleBuilder.tsx diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx new file mode 100644 index 0000000..5eeda21 --- /dev/null +++ b/src/components/AutoScoreManager.tsx @@ -0,0 +1,432 @@ +import React, { useEffect, useMemo, useState } from "react" +import { + Alert, + Button, + Card, + Form, + Input, + Pagination, + Popconfirm, + Select, + Space, + Switch, + Table, + Tag, + message, +} from "antd" +import { Utils as QbUtils, type ImmutableTree } from "@react-awesome-query-builder/antd" +import type { ColumnsType } from "antd/es/table" +import { useTranslation } from "react-i18next" +import { ActionEditor } from "./AutoScoreManagerPage/ActionEditor" +import { TriggerRuleBuilder } from "./AutoScoreManagerPage/TriggerRuleBuilder" +import { + actionDraftsToPayload, + actionsToDrafts, + createDefaultActionDraft, + createEmptyTriggerTree, + createTriggerQueryConfig, + hasUnsupportedTriggerLogic, + queryTreeToTriggers, + triggersToQueryTree, + type ActionDraft, + type AutoScoreRule, +} from "./AutoScoreManagerPage/AutoScoreUtils" + +interface StudentItem { + 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 triggerConfig = useMemo( + () => createTriggerQueryConfig(t), + [i18n.resolvedLanguage, i18n.language] + ) + 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(() => { + setTriggerTree((prevTree) => QbUtils.checkTree(prevTree, triggerConfig)) + }, [triggerConfig]) + + 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 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 + 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) }) + } + /> + ) : ( + updateAction(action.id, { value: event.target.value })} + /> + )} +
+ ))} + + + + ) +} diff --git a/src/components/AutoScoreManagerPage/AutoScoreUtils.ts b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts new file mode 100644 index 0000000..f160ffc --- /dev/null +++ b/src/components/AutoScoreManagerPage/AutoScoreUtils.ts @@ -0,0 +1,262 @@ +import { + AntdConfig, + Utils as QbUtils, + type Config, + type ImmutableTree, + type JsonGroup, + type JsonItem, + type JsonRule, +} from "@react-awesome-query-builder/antd" +import type { TFunction } from "i18next" + +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 ActionEvent = "add_score" | "add_tag" + +export interface ActionDraft { + id: string + event: ActionEvent + value: string +} + +export type ActionDraftError = "score_required" | "tag_required" | null + +const TRIGGER_FIELD_INTERVAL = "interval_minutes" +const TRIGGER_FIELD_TAG = "student_tag" +const OP_EQUAL = "equal" + +const buildEmptyGroup = (): JsonGroup => ({ + id: QbUtils.uuid(), + type: "group", + properties: { + conjunction: "AND", + }, + children1: [], +}) + +const toFiniteNumber = (value: unknown): number | null => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim()) { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +const toStringValue = (value: unknown): string => { + if (value === null || value === undefined) return "" + return String(value) +} + +const ruleFromTrigger = (trigger: AutoScoreTrigger): JsonRule | null => { + if (trigger.event === "interval_time_passed") { + const minutes = toFiniteNumber(trigger.value) + if (!minutes || minutes <= 0) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_INTERVAL, + operator: OP_EQUAL, + value: [Math.floor(minutes)], + }, + } + } + + if (trigger.event === "student_has_tag") { + const tagName = toStringValue(trigger.value).trim() + if (!tagName) return null + return { + id: QbUtils.uuid(), + type: "rule", + properties: { + field: TRIGGER_FIELD_TAG, + operator: OP_EQUAL, + value: [tagName], + }, + } + } + + return null +} + +const triggerFromRule = (rule: JsonRule): AutoScoreTrigger | null => { + const field = typeof rule.properties?.field === "string" ? rule.properties.field : "" + const value = Array.isArray(rule.properties?.value) ? rule.properties.value[0] : undefined + + if (field === TRIGGER_FIELD_INTERVAL) { + const minutes = toFiniteNumber(value) + if (!minutes || minutes <= 0) return null + return { + event: "interval_time_passed", + value: String(Math.floor(minutes)), + } + } + + if (field === TRIGGER_FIELD_TAG) { + const tagName = toStringValue(value).trim() + if (!tagName) return null + return { + event: "student_has_tag", + value: tagName, + } + } + + return null +} + +const collectTriggersFromItems = (items: JsonItem[] | undefined): AutoScoreTrigger[] => { + if (!Array.isArray(items)) return [] + const collected: AutoScoreTrigger[] = [] + for (const item of items) { + if (item.type === "rule") { + const trigger = triggerFromRule(item) + if (trigger) collected.push(trigger) + continue + } + if (item.type === "group") { + collected.push(...collectTriggersFromItems(item.children1)) + } + } + return collected +} + +export const createTriggerQueryConfig = (t: TFunction): Config => + ({ + ...AntdConfig, + fields: { + [TRIGGER_FIELD_INTERVAL]: { + label: t("autoScore.triggerIntervalTime"), + type: "number", + operators: [OP_EQUAL], + valueSources: ["value"], + preferWidgets: ["number"], + fieldSettings: { min: 1 }, + }, + [TRIGGER_FIELD_TAG]: { + label: t("autoScore.triggerStudentTag"), + type: "text", + operators: [OP_EQUAL], + valueSources: ["value"], + preferWidgets: ["text"], + }, + }, + settings: { + ...AntdConfig.settings, + liteMode: false, + compactMode: false, + renderSize: "medium", + showNot: true, + forceShowConj: true, + canLeaveEmptyGroup: false, + canReorder: true, + canRegroup: true, + }, + }) as Config + +export const createEmptyTriggerTree = (config: Config): ImmutableTree => + QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config) + +export const triggersToQueryTree = (config: Config, triggers: AutoScoreTrigger[]): ImmutableTree => { + const children = triggers.map(ruleFromTrigger).filter((item): item is JsonRule => Boolean(item)) + const group: JsonGroup = { + ...buildEmptyGroup(), + children1: children, + } + return QbUtils.checkTree(QbUtils.loadTree(group), config) +} + +export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoScoreTrigger[] => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return [] + return collectTriggersFromItems(jsonTree.children1) +} + +const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => { + const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND" + const not = Boolean(group.properties?.not) + + if (conjunction !== "AND" || not) { + return true + } + + const children = Array.isArray(group.children1) ? group.children1 : [] + for (const child of children) { + if (child.type === "group" && hasUnsupportedLogicInGroup(child)) { + return true + } + } + + return false +} + +export const hasUnsupportedTriggerLogic = (tree: ImmutableTree, config: Config): boolean => { + const checkedTree = QbUtils.checkTree(tree, config) + const jsonTree = QbUtils.getTree(checkedTree, false, true) + if (!jsonTree || jsonTree.type !== "group") return false + return hasUnsupportedLogicInGroup(jsonTree) +} + +export const createDefaultActionDraft = (): ActionDraft => ({ + id: QbUtils.uuid(), + event: "add_score", + value: "1", +}) + +export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => { + const mapped = actions + .map((action) => { + if (action.event !== "add_score" && action.event !== "add_tag") return null + return { + id: QbUtils.uuid(), + event: action.event, + value: toStringValue(action.value), + } satisfies ActionDraft + }) + .filter((item): item is ActionDraft => Boolean(item)) + + return mapped.length > 0 ? mapped : [createDefaultActionDraft()] +} + +export const actionDraftsToPayload = ( + drafts: ActionDraft[] +): { actions: AutoScoreAction[]; error: ActionDraftError } => { + const actions: AutoScoreAction[] = [] + for (const draft of drafts) { + if (draft.event === "add_score") { + const score = toFiniteNumber(draft.value) + if (!score || score === 0) { + return { actions: [], error: "score_required" } + } + actions.push({ event: draft.event, value: String(score) }) + continue + } + + const tagName = draft.value.trim() + if (!tagName) { + return { actions: [], error: "tag_required" } + } + actions.push({ event: draft.event, value: tagName }) + } + + return { actions, error: null } +} 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 0557604..2edec49 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(), @@ -101,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") @@ -364,6 +368,10 @@ 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 2e63f10..3734b7f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -770,6 +770,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", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 398f832..52754aa 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -770,6 +770,7 @@ "adminDeleteRequired": "需要管理员权限以删除自动加分自动化", "adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化", "fetchFailed": "获取自动化失败", + "unsupportedLogic": "当前版本保存规则时仅支持 AND 条件,OR / NOT 逻辑暂未接入后端存储", "triggerRequired": "请至少添加一个触发器", "actionRequired": "请至少添加一个行动", "createSuccess": "自动化创建成功", diff --git a/src/preload/types.ts b/src/preload/types.ts index 8b2e3dd..b1f6d55 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 @@ -235,6 +259,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"), @@ -442,7 +506,7 @@ const api = { appRestart: (): Promise => invoke("app_restart"), // Generic invoke wrapper for backward compatibility with callers using `api.invoke` - invoke: async (channel: string, ...args: any[]): 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 63a1abf..0052911 100644 --- a/src/shared/mobileNavigation.ts +++ b/src/shared/mobileNavigation.ts @@ -2,6 +2,7 @@ export const MOBILE_NAV_ALL_KEYS = [ "home", "students", "score", + "auto-score", "reward-settings", "boards", "leaderboard", @@ -23,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", From a930e4843ce69bc3b2c4f303bb59576d68598010 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sun, 29 Mar 2026 17:11:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E4=B8=8D=E7=9F=A5=E9=81=93=E8=BF=99?= =?UTF-8?q?=E6=98=AF=E4=BB=80=E4=B9=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/about-content.json | 7 +++++++ src/components/Settings.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 public/about-content.json 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
  • 通过 xlsx 导入
\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
  • 点击“导出 XLSX”可导出当前排行榜
\r
  • 点击某个学生的“查看”可打开该学生的操作记录
\r
\r

5. 结算与数据备份

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

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

\r
\r

MCP 文档

\r
\r
  • [MCP-使用说明](./MCP-使用说明.md)
\r
\r

环境要求

\r
\r
  • Node.js(建议使用 LTS 版本)
\r
  • pnpm
\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/components/Settings.tsx b/src/components/Settings.tsx index d60fb58..9f44a90 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -76,7 +76,13 @@ export const Settings: React.FC<{ const [recoveryDialogString, setRecoveryDialogString] = useState("") const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("") - const [aboutContent, setAboutContent] = useState<{ title: string; description: string; content: string; rawMarkdown: string } | null>(null) + const [aboutContent, setAboutContent] = useState<{ + title: string + description: string + content: string + rawMarkdown: string + version?: string + } | null>(null) const [logsDialogVisible, setLogsDialogVisible] = useState(false) const [logsText, setLogsText] = useState("") From e4d9ee0c851582a905a3c2f5e8a6844cd5bab185 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sun, 29 Mar 2026 18:17:54 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E9=97=B4=E9=9A=94=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=8A=A0=E5=88=86=E5=8F=AF=E4=BB=A5=E6=8C=89=E7=85=A7?= =?UTF-8?q?=E5=A4=9A=E5=B0=91=E5=A4=A9=E3=80=81=E6=9C=88=E3=80=81=E5=88=86?= =?UTF-8?q?=E9=92=9F=E5=90=8E=E5=8A=A0=E5=88=86=20fix:=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=8B=A5=E5=B9=B2=E9=97=AE=E9=A2=98=20=E4=B8=8B?= =?UTF-8?q?=E9=9B=A8=E4=BA=86=E5=A5=BD=E5=AE=B3=E6=80=95=E6=88=91=E9=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/board.rs | 7 +- src-tauri/src/commands/database.rs | 25 +-- src-tauri/src/commands/student.rs | 19 ++- src-tauri/src/lib.rs | 7 +- src-tauri/src/services/auto_score.rs | 80 ++++++++-- src-tauri/src/services/settings.rs | 4 +- src/assets/main.css | 3 + src/components/AutoScoreManager.tsx | 50 ++++-- .../AutoScoreManagerPage/ActionEditor.tsx | 47 +++++- .../AutoScoreManagerPage/AutoScoreUtils.ts | 143 ++++++++++++++---- .../IntervalValueCodec.ts | 79 ++++++++++ .../IntervalValueWidget.tsx | 118 +++++++++++++++ src/i18n/locales/en-US.json | 4 + src/i18n/locales/zh-CN.json | 4 + 14 files changed, 507 insertions(+), 83 deletions(-) create mode 100644 src/components/AutoScoreManagerPage/IntervalValueCodec.ts create mode 100644 src/components/AutoScoreManagerPage/IntervalValueWidget.tsx 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 15b3e06..68f2407 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -292,9 +292,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 f517b87..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 { diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 5eeda21..cdf527a 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -14,9 +14,10 @@ import { Tag, message, } from "antd" -import { Utils as QbUtils, type ImmutableTree } from "@react-awesome-query-builder/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 { @@ -30,6 +31,7 @@ import { triggersToQueryTree, type ActionDraft, type AutoScoreRule, + type AutoScoreTagOption, } from "./AutoScoreManagerPage/AutoScoreUtils" interface StudentItem { @@ -37,6 +39,11 @@ interface StudentItem { name: string } +interface TagItem { + id: number + name: string +} + interface RuleFormValues { name?: string studentNames?: string[] @@ -50,10 +57,20 @@ 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), - [i18n.resolvedLanguage, i18n.language] + () => createTriggerQueryConfig(t, tagOptions), + [i18n.resolvedLanguage, i18n.language, tagOptions] ) const [triggerTree, setTriggerTree] = useState(() => createEmptyTriggerTree(triggerConfig) @@ -67,10 +84,6 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(10) - useEffect(() => { - setTriggerTree((prevTree) => QbUtils.checkTree(prevTree, triggerConfig)) - }, [triggerConfig]) - useEffect(() => { const maxPage = Math.max(1, Math.ceil(rules.length / pageSize)) if (currentPage > maxPage) { @@ -103,6 +116,19 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element } } + 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 @@ -124,6 +150,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element useEffect(() => { if (!canEdit) return + fetchTags().catch(() => void 0) fetchStudents().catch(() => void 0) fetchRules().catch(() => void 0) }, [canEdit]) @@ -387,10 +414,15 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element config={triggerConfig} value={triggerTree} canEdit={canEdit} - onChange={(nextTree, config) => setTriggerTree(QbUtils.checkTree(nextTree, config))} + onChange={(nextTree) => setTriggerTree(nextTree)} /> - +