diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0005c85..f6ed8e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,7 @@ name: build on: + push: workflow_dispatch: inputs: version: @@ -39,6 +40,17 @@ jobs: echo "${{ github.event.inputs.version }} ${{ github.event.inputs.build }}" echo "__EOF__" } >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "push" ]; then + VERSION="$(sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -n 1)" + if [ -z "$VERSION" ]; then + echo "无法从 package.json 读取 version 字段" >&2 + exit 1 + fi + { + echo "message<<__EOF__" + echo "$VERSION build:all" + echo "__EOF__" + } >> "$GITHUB_OUTPUT" else { echo "message<<__EOF__" diff --git a/AGENTS.md b/AGENTS.md index 5ccefd3..0b1dd8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,11 @@ ## 代码规范 - 所有代码必须符合 Rust 语言规范。 -- 所有代码跑`pnpm run typecheck` or `cargo typecheck`必须通过。 +- 所有代码跑`pnpm run typecheck` or `cargo typecheck` 必须通过。 ## 提交与推送要求 -- 每次代码修改后,必须进行一次 Git 提交(commit)并推送(git push)。 +- 每次代码修改后,除非用户说明进行一次 Git 提交(commit)并推送(git push)否则不进行。 +- 用户只说 `cp` 时,表示执行一次 Git 提交(commit)并推送(git push)。 - 提交信息必须使用中文。 - 提交信息必须遵循 Conventional Commits 规范,格式示例:`feat: 新增登录页表单校验`、`fix: 修复导出报告空指针问题`。 - 每次提交完成后,必须将对应提交推送到远端仓库(`git push`)。 diff --git a/package.json b/package.json index 8aebb7b..9db5c3a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "dependencies": { "@ant-design/icons": "^6.1.0", "@react-awesome-query-builder/antd": "6.7.0-alpha.0", - "@react-querybuilder/antd": "^8.14.0", - "@react-querybuilder/dnd": "^8.14.0", "@tauri-apps/api": "^2.5.0", "antd": "^6.3.1", "dayjs": "^1.11.20", @@ -28,9 +26,6 @@ "json-rules-engine": "^7.3.1", "pinyin-pro": "^3.27.0", "react": "^19.2.1", - "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", - "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.2.1", "react-i18next": "^16.5.6", "react-querybuilder": "^8.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d409fb3..8b2c5bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,6 @@ importers: '@react-awesome-query-builder/antd': specifier: 6.7.0-alpha.0 version: 6.7.0-alpha.0(@ant-design/icons@6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(antd@6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(dayjs@1.11.20)(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@react-querybuilder/antd': - specifier: ^8.14.0 - version: 8.14.0(@ant-design/icons@6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(antd@6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(dayjs@1.11.20)(react-querybuilder@8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1))(react@19.2.4) - '@react-querybuilder/dnd': - specifier: ^8.14.0 - version: 8.14.0(react-dnd-html5-backend@16.0.1)(react-dnd-touch-backend@16.0.1)(react-dnd@16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4))(react-querybuilder@8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1))(react@19.2.4) '@tauri-apps/api': specifier: ^2.5.0 version: 2.10.1 @@ -41,15 +35,6 @@ importers: react: specifier: ^19.2.1 version: 19.2.4 - react-dnd: - specifier: ^16.0.1 - version: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4) - react-dnd-html5-backend: - specifier: ^16.0.1 - version: 16.0.1 - react-dnd-touch-backend: - specifier: ^16.0.1 - version: 16.0.1 react-dom: specifier: ^19.2.1 version: 19.2.4(react@19.2.4) @@ -791,24 +776,6 @@ packages: react: ^16.8.4 || ^17.0.1 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.4 || ^17.0.1 || ^18.0.0 || ^19.0.0 - '@react-dnd/asap@5.0.2': - resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==} - - '@react-dnd/invariant@4.0.2': - resolution: {integrity: sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==} - - '@react-dnd/shallowequal@4.0.2': - resolution: {integrity: sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==} - - '@react-querybuilder/antd@8.14.0': - resolution: {integrity: sha512-H6SMCmKtciOIJyXo29oLMMDIG1+ydE7eoIqAIhItsvJN7iLcAPhvKd5+CuHyOWj9tokEAHGpdm4BP+KPFAxTNw==} - peerDependencies: - '@ant-design/icons': '>=5' - antd: '>=5.11.0' - dayjs: '>=1' - react: '>=18' - react-querybuilder: 8.14.0 - '@react-querybuilder/core@8.14.0': resolution: {integrity: sha512-j1pIY0Yyn/dXu9ZST/DVY7TqRmIO1hY/mZ8653DaeHaDzUF37tOdkm/NQDU9RfM0KXIWsJY5zlvYAR1DypZ+7g==} peerDependencies: @@ -823,20 +790,6 @@ packages: sequelize: optional: true - '@react-querybuilder/dnd@8.14.0': - resolution: {integrity: sha512-g0BHs9Vtbm7OhnhIq1pxJNY3JXNmSbA7NVTlOuqoAfFl0D46rZfGInVzoBfpwVpoLtQIXPO25Y5N/1/LcWuSNg==} - peerDependencies: - react: '>=18' - react-dnd: '>=14.0.0' - react-dnd-html5-backend: '>=14.0.0' - react-dnd-touch-backend: '>=14.0.0' - react-querybuilder: 8.14.0 - peerDependenciesMeta: - react-dnd-html5-backend: - optional: true - react-dnd-touch-backend: - optional: true - '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} peerDependencies: @@ -889,66 +842,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1012,30 +978,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.1': resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.1': resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.1': resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.1': resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} @@ -1372,9 +1343,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - dnd-core@16.0.1: - resolution: {integrity: sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==} - doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2029,27 +1997,6 @@ packages: react: '>=16.9.0' react-dom: '>=16.9.0' - react-dnd-html5-backend@16.0.1: - resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} - - react-dnd-touch-backend@16.0.1: - resolution: {integrity: sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==} - - react-dnd@16.0.1: - resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} - peerDependencies: - '@types/hoist-non-react-statics': '>= 3.3.1' - '@types/node': '>= 12' - '@types/react': '>= 16' - react: '>= 16.14' - peerDependenciesMeta: - '@types/hoist-non-react-statics': - optional: true - '@types/node': - optional: true - '@types/react': - optional: true - react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -3210,20 +3157,6 @@ snapshots: - '@types/react-dom' - react-native - '@react-dnd/asap@5.0.2': {} - - '@react-dnd/invariant@4.0.2': {} - - '@react-dnd/shallowequal@4.0.2': {} - - '@react-querybuilder/antd@8.14.0(@ant-design/icons@6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(antd@6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(dayjs@1.11.20)(react-querybuilder@8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': - dependencies: - '@ant-design/icons': 6.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - antd: 6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - dayjs: 1.11.20 - react: 19.2.4 - react-querybuilder: 8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1) - '@react-querybuilder/core@8.14.0(json-logic-js@2.0.5)': dependencies: '@ts-jison/lexer': 0.4.1-alpha.1 @@ -3233,15 +3166,6 @@ snapshots: optionalDependencies: json-logic-js: 2.0.5 - '@react-querybuilder/dnd@8.14.0(react-dnd-html5-backend@16.0.1)(react-dnd-touch-backend@16.0.1)(react-dnd@16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4))(react-querybuilder@8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': - dependencies: - react: 19.2.4 - react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4) - react-querybuilder: 8.14.0(@types/react@19.2.14)(json-logic-js@2.0.5)(react@19.2.4)(redux@5.0.1) - optionalDependencies: - react-dnd-html5-backend: 16.0.1 - react-dnd-touch-backend: 16.0.1 - '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': dependencies: '@standard-schema/spec': 1.1.0 @@ -3811,12 +3735,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - dnd-core@16.0.1: - dependencies: - '@react-dnd/asap': 5.0.2 - '@react-dnd/invariant': 4.0.2 - redux: 4.2.1 - doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4594,28 +4512,6 @@ snapshots: react-dom: 19.2.4(react@19.2.4) react-is: 18.3.1 - react-dnd-html5-backend@16.0.1: - dependencies: - dnd-core: 16.0.1 - - react-dnd-touch-backend@16.0.1: - dependencies: - '@react-dnd/invariant': 4.0.2 - dnd-core: 16.0.1 - - react-dnd@16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4): - dependencies: - '@react-dnd/invariant': 4.0.2 - '@react-dnd/shallowequal': 4.0.2 - dnd-core: 16.0.1 - fast-deep-equal: 3.1.3 - hoist-non-react-statics: 3.3.2 - react: 19.2.4 - optionalDependencies: - '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14) - '@types/node': 22.19.15 - '@types/react': 19.2.14 - react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/resources/看板SQL生成提示词.md b/resources/看板SQL生成提示词.md index a38ab20..be906a4 100644 --- a/resources/看板SQL生成提示词.md +++ b/resources/看板SQL生成提示词.md @@ -85,6 +85,44 @@ 3. 对可能为空的数据优先使用 `COALESCE`。 4. 默认不写 `LIMIT`(系统外层会限制);除非用户明确要求更小结果集。 +## 7.1) 积分符号语义(高优先级强制规则) +`score_events.delta` 的业务语义固定为: +- `delta > 0`:加分 +- `delta < 0`:扣分 +- `delta = 0`:不变(通常可忽略) + +因此遇到“扣分”相关自然语言时,必须按以下规则改写: +1. “扣分记录” => `delta < 0` +2. “扣分大于 N 分 / 扣了超过 N 分” => `delta < -N`(或等价写法 `delta < 0 AND ABS(delta) > N`) +3. “扣分至少 N 分” => `delta <= -N`(或等价写法 `delta < 0 AND ABS(delta) >= N`) +4. “加分大于 N 分” => `delta > N` + +错误示例(禁止生成): +- “扣分 > 5” 写成 `delta > 5` + +正确示例(优先生成): +- “扣分 > 5” 写成 `delta < -5` +- “只看扣分” 写成 `delta < 0` + +## 7.2) 积分相关查询结果字段强制规则 +若用户意图与“积分”相关(例如:积分排行、积分变化、扣分/加分统计、学生积分看板),最终结果中必须包含“学生当前总积分”字段: +- 字段名统一输出为:`score` +- 来源必须是 `students.score`(不是 `SUM(score_events.delta)`) + +生成要求: +1. 若主表是 `students`:直接选择 `students.score AS score`(或 `s.score AS score`) +2. 若主表不是 `students`(如 `score_events`):必须关联 `students` 并带出总积分 + 标准关联:`LEFT JOIN students s ON s.name = <学生名字段>` +3. 若查询按学生聚合,仍需在结果中包含 `score`,不要省略 + +错误示例(禁止生成): +- 积分相关 SQL 只返回 `student_name`、`week_change`,但没有 `score` +- 用 `SUM(delta)` 命名为 `score` 冒充当前总积分 + +正确示例(优先生成): +- `SELECT e.student_name, COALESCE(s.score, 0) AS score, SUM(e.delta) AS week_change ... LEFT JOIN students s ON s.name = e.student_name ...` +- `SELECT s.name AS student_name, s.score AS score FROM students s ...` + ## 8) 自然周规则(允许生成上个自然周 SQL) 当用户要求“上个自然周/本自然周”时,必须使用模板变量区间表达,不要自行计算数据库日期函数: - 上个自然周:`event_time >= '{ {last_week_start} }' AND event_time < '{ {this_week_start} }'` @@ -104,6 +142,8 @@ - 是否没有系统表(如 `sqlite_master`)? - 是否只用了给定业务表? - 是否所有字段都能在对应表字段清单中找到(尤其检查 `students.name` vs `students.student_name`)? +- 若需求涉及“扣分”,是否已使用负数语义(如 `delta < 0`、`delta < -N`)而非 `delta > N`? +- 若需求涉及“积分”,结果中是否包含 `students.score` 且命名为 `score`? 用户需求: { {在这里粘贴用户需求} } diff --git a/scripts/tauri-dev.cjs b/scripts/tauri-dev.cjs index 608c935..2b723ce 100644 --- a/scripts/tauri-dev.cjs +++ b/scripts/tauri-dev.cjs @@ -34,7 +34,7 @@ async function main() { const port = await findAvailablePort(START_PORT) const overrideConfig = { build: { - beforeDevCommand: `npm run dev -- --host --port ${port} --strictPort`, + beforeDevCommand: `npm run dev -- --host --port ${port}`, devUrl: `http://localhost:${port}`, }, } diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 6dd6909..dd2f8bb 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -828,139 +828,227 @@ async fn db_sync_apply_internal( }; let local_students = load_students(&local_conn).await?; + let remote_students = load_students(&remote_conn).await?; let local_reasons = load_reasons(&local_conn).await?; + let remote_reasons = load_reasons(&remote_conn).await?; let local_tags = load_tags(&local_conn).await?; + let remote_tags = load_tags(&remote_conn).await?; let local_events = load_events(&local_conn).await?; + let remote_events = load_events(&remote_conn).await?; let local_reward_settings = load_reward_settings(&local_conn).await?; + let remote_reward_settings = load_reward_settings(&remote_conn).await?; let local_reward_redemptions = load_reward_redemptions(&local_conn).await?; + let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?; let local_pairs = load_student_tag_pairs(&local_conn).await?; let remote_pairs = load_student_tag_pairs(&remote_conn).await?; - let (preferred, target) = if strategy == ConflictStrategy::KeepLocal { - (&local_conn, &remote_conn) - } else { - (&remote_conn, &local_conn) - }; - let mut synced_records = 0usize; let mut resolved_conflicts = 0usize; - for student in local_students.values() { - if upsert_student(&remote_conn, student).await? { - synced_records += 1; - } - } - let remote_students_after = load_students(&remote_conn).await?; - for student in remote_students_after.values() { - if upsert_student(&local_conn, student).await? { - synced_records += 1; - } - } - for reason in local_reasons.values() { - if upsert_reason(&remote_conn, reason).await? { - synced_records += 1; - } - } - let remote_reasons_after = load_reasons(&remote_conn).await?; - for reason in remote_reasons_after.values() { - if upsert_reason(&local_conn, reason).await? { - synced_records += 1; - } - } - for tag in local_tags.values() { - if upsert_tag(&remote_conn, tag).await? { - synced_records += 1; - } - } - let remote_tags_after = load_tags(&remote_conn).await?; - for tag in remote_tags_after.values() { - if upsert_tag(&local_conn, tag).await? { - synced_records += 1; - } - } - for event in local_events.values() { - if upsert_event(&remote_conn, event).await? { - synced_records += 1; - } - } - let remote_events_after = load_events(&remote_conn).await?; - for event in remote_events_after.values() { - if upsert_event(&local_conn, event).await? { - synced_records += 1; - } - } - for reward in local_reward_settings.values() { - if upsert_reward_setting(&remote_conn, reward).await? { - synced_records += 1; - } - } - let remote_reward_settings_after = load_reward_settings(&remote_conn).await?; - for reward in remote_reward_settings_after.values() { - if upsert_reward_setting(&local_conn, reward).await? { - synced_records += 1; - } - } - for redemption in local_reward_redemptions.values() { - if upsert_reward_redemption(&remote_conn, redemption).await? { - synced_records += 1; - } - } - let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?; - for redemption in remote_reward_redemptions_after.values() { - if upsert_reward_redemption(&local_conn, redemption).await? { - synced_records += 1; + // Deterministic per-key merge: + // 1) local_only => remote + // 2) remote_only => local + // 3) conflict => resolve by strategy (winner -> loser) + let student_keys: std::collections::HashSet = local_students + .keys() + .chain(remote_students.keys()) + .cloned() + .collect(); + for key in student_keys { + match (local_students.get(&key), remote_students.get(&key)) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_student(&remote_conn, local).await? + } else { + upsert_student(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_student(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_student(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} } } - for pair in local_pairs.union(&remote_pairs) { - if ensure_student_tag_pair(&local_conn, pair).await? { - synced_records += 1; + let reason_keys: std::collections::HashSet = local_reasons + .keys() + .chain(remote_reasons.keys()) + .cloned() + .collect(); + for key in reason_keys { + match (local_reasons.get(&key), remote_reasons.get(&key)) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_reason(&remote_conn, local).await? + } else { + upsert_reason(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_reason(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_reason(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} } + } + + let tag_keys: std::collections::HashSet = local_tags + .keys() + .chain(remote_tags.keys()) + .cloned() + .collect(); + for key in tag_keys { + match (local_tags.get(&key), remote_tags.get(&key)) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_tag(&remote_conn, local).await? + } else { + upsert_tag(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_tag(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_tag(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} + } + } + + let event_keys: std::collections::HashSet = local_events + .keys() + .chain(remote_events.keys()) + .cloned() + .collect(); + for key in event_keys { + match (local_events.get(&key), remote_events.get(&key)) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_event(&remote_conn, local).await? + } else { + upsert_event(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_event(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_event(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} + } + } + + let reward_setting_keys: std::collections::HashSet = local_reward_settings + .keys() + .chain(remote_reward_settings.keys()) + .cloned() + .collect(); + for key in reward_setting_keys { + match ( + local_reward_settings.get(&key), + remote_reward_settings.get(&key), + ) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_reward_setting(&remote_conn, local).await? + } else { + upsert_reward_setting(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_reward_setting(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_reward_setting(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} + } + } + + let redemption_keys: std::collections::HashSet = local_reward_redemptions + .keys() + .chain(remote_reward_redemptions.keys()) + .cloned() + .collect(); + for key in redemption_keys { + match ( + local_reward_redemptions.get(&key), + remote_reward_redemptions.get(&key), + ) { + (Some(local), Some(remote)) if local != remote => { + let changed = if strategy == ConflictStrategy::KeepLocal { + upsert_reward_redemption(&remote_conn, local).await? + } else { + upsert_reward_redemption(&local_conn, remote).await? + }; + if changed { + resolved_conflicts += 1; + } + } + (Some(local), None) => { + if upsert_reward_redemption(&remote_conn, local).await? { + synced_records += 1; + } + } + (None, Some(remote)) => { + if upsert_reward_redemption(&local_conn, remote).await? { + synced_records += 1; + } + } + _ => {} + } + } + + for pair in local_pairs.difference(&remote_pairs) { if ensure_student_tag_pair(&remote_conn, pair).await? { synced_records += 1; } } - - let preferred_students = load_students(preferred).await?; - for student in preferred_students.values() { - if upsert_student(target, student).await? { - resolved_conflicts += 1; - } - } - let preferred_reasons = load_reasons(preferred).await?; - for reason in preferred_reasons.values() { - if upsert_reason(target, reason).await? { - resolved_conflicts += 1; - } - } - let preferred_tags = load_tags(preferred).await?; - for tag in preferred_tags.values() { - if upsert_tag(target, tag).await? { - resolved_conflicts += 1; - } - } - let preferred_events = load_events(preferred).await?; - for event in preferred_events.values() { - if upsert_event(target, event).await? { - resolved_conflicts += 1; - } - } - let preferred_reward_settings = load_reward_settings(preferred).await?; - for reward in preferred_reward_settings.values() { - if upsert_reward_setting(target, reward).await? { - resolved_conflicts += 1; - } - } - let preferred_reward_redemptions = load_reward_redemptions(preferred).await?; - for redemption in preferred_reward_redemptions.values() { - if upsert_reward_redemption(target, redemption).await? { - resolved_conflicts += 1; - } - } - let preferred_pairs = load_student_tag_pairs(preferred).await?; - for pair in &preferred_pairs { - if ensure_student_tag_pair(target, pair).await? { - resolved_conflicts += 1; + for pair in remote_pairs.difference(&local_pairs) { + if ensure_student_tag_pair(&local_conn, pair).await? { + synced_records += 1; } } diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 770fa93..0b29a1b 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -100,7 +100,7 @@ pub async fn toggle_devtools( let _ = app; } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, desktop))] if let Some(window) = app.get_webview_window("main") { if window.is_devtools_open() { window.close_devtools(); diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 670cecf..b41660f 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -17,6 +17,7 @@ pub struct SettingsSpec { pub dashboards_config: JsonValue, pub pg_connection_string: String, pub pg_connection_status: JsonValue, + pub mobile_bottom_nav_items: JsonValue, } impl Default for SettingsSpec { @@ -34,6 +35,18 @@ impl Default for SettingsSpec { dashboards_config: JsonValue::Array(vec![]), pg_connection_string: String::new(), pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}), + mobile_bottom_nav_items: serde_json::json!([ + "home", + "students", + "score", + "auto-score", + "reward-settings", + "boards", + "leaderboard", + "settlements", + "reasons", + "settings" + ]), } } } @@ -52,6 +65,7 @@ pub enum SettingsKey { DashboardsConfig, PgConnectionString, PgConnectionStatus, + MobileBottomNavItems, } impl SettingsKey { @@ -69,6 +83,7 @@ impl SettingsKey { SettingsKey::DashboardsConfig => "dashboards_config", SettingsKey::PgConnectionString => "pg_connection_string", SettingsKey::PgConnectionStatus => "pg_connection_status", + SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items", } } @@ -86,6 +101,7 @@ impl SettingsKey { "dashboards_config" => Some(SettingsKey::DashboardsConfig), "pg_connection_string" => Some(SettingsKey::PgConnectionString), "pg_connection_status" => Some(SettingsKey::PgConnectionStatus), + "mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems), _ => None, } } @@ -383,6 +399,48 @@ impl SettingsService { }, ); + defs.insert( + SettingsKey::MobileBottomNavItems, + SettingDefinition { + kind: SettingValueKind::Json, + default_value: SettingsValue::Json(serde_json::json!([ + "home", + "students", + "score", + "auto-score", + "reward-settings", + "boards", + "leaderboard", + "settlements", + "reasons", + "settings" + ])), + write_permission: PermissionRequirement::Admin, + validate: Some(|v| { + if let SettingsValue::Json(JsonValue::Array(items)) = v { + let allowed = [ + "home", + "students", + "score", + "auto-score", + "reward-settings", + "boards", + "leaderboard", + "settlements", + "reasons", + "settings", + ]; + return items.iter().all(|item| { + item.as_str() + .map(|s| allowed.contains(&s)) + .unwrap_or(false) + }); + } + false + }), + }, + ); + defs } @@ -521,6 +579,21 @@ impl SettingsService { SettingsValue::Json(j) => j, _ => serde_json::json!({"connected": false, "type": "sqlite"}), }, + mobile_bottom_nav_items: match self.get_value(SettingsKey::MobileBottomNavItems) { + SettingsValue::Json(j) => j, + _ => serde_json::json!([ + "home", + "students", + "score", + "auto-score", + "reward-settings", + "boards", + "leaderboard", + "settlements", + "reasons", + "settings" + ]), + }, } } diff --git a/src/App.tsx b/src/App.tsx index 6c8462e..da93423 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,16 @@ import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd" +import { + HomeOutlined, + SettingOutlined, + UserOutlined, + HistoryOutlined, + SyncOutlined, + AppstoreAddOutlined, + ApartmentOutlined, + UnorderedListOutlined, + FileTextOutlined, + MoreOutlined, +} from "@ant-design/icons" import { useEffect, useMemo, useRef, useState } from "react" import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom" import { useTranslation } from "react-i18next" @@ -6,6 +18,13 @@ import { Sidebar } from "./components/Sidebar" import { ContentArea } from "./components/ContentArea" import { OOBE } from "./components/OOBE/OOBE" import { ThemeProvider, useTheme } from "./contexts/ThemeContext" +import { + MOBILE_NAV_ITEMS, + MobileNavKey, + sanitizeMobileNavKeys, +} from "./shared/mobileNavigation" + +const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) function MainContent(): React.JSX.Element { const { t } = useTranslation() @@ -14,6 +33,7 @@ function MainContent(): React.JSX.Element { const { currentTheme } = useTheme() const [messageApi, contextHolder] = message.useMessage() const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, []) + const [immersiveMode, setImmersiveMode] = useState(false) useEffect(() => { const api = (window as any).api @@ -27,6 +47,11 @@ function MainContent(): React.JSX.Element { const currentPath = location.pathname === "/" ? "/home" : location.pathname const targetPath = route === "/" ? "/home" : route + if (immersiveMode && !targetPath.startsWith("/home")) { + navigate("/", { replace: true }) + return + } + if (currentPath !== targetPath) { navigate(route) } @@ -44,7 +69,7 @@ function MainContent(): React.JSX.Element { disposed = true if (unlisten) unlisten() } - }, [navigate, location.pathname]) + }, [navigate, location.pathname, immersiveMode]) const [wizardVisible, setWizardVisible] = useState(false) const [permission, setPermission] = useState<"admin" | "points" | "view">("view") @@ -52,6 +77,10 @@ function MainContent(): React.JSX.Element { const [authVisible, setAuthVisible] = useState(false) const [authPassword, setAuthPassword] = useState("") const [authLoading, setAuthLoading] = useState(false) + const [mobileBottomNavItems, setMobileBottomNavItems] = useState( + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ) + const [moreNavVisible, setMoreNavVisible] = useState(false) const [isPortraitMode] = useState(defaultPortraitMode) const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode) const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false) @@ -100,11 +129,49 @@ function MainContent(): React.JSX.Element { setHasAnyPassword(anyPwd) if (anyPwd && authRes.data.permission === "view") setAuthVisible(true) } + const settingsRes = await (window as any).api.getAllSettings() + if (settingsRes?.success && settingsRes.data) { + setMobileBottomNavItems( + sanitizeMobileNavKeys( + settingsRes.data.mobile_bottom_nav_items, + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ) + ) + } } loadAuthAndSettings() }, []) + useEffect(() => { + const api = (window as any).api + if (!api || typeof api.onSettingChanged !== "function") return + + let disposed = false + let unlisten: (() => void) | null = null + + api + .onSettingChanged((change: { key?: string; value?: unknown }) => { + if (change?.key !== "mobile_bottom_nav_items") return + setMobileBottomNavItems( + sanitizeMobileNavKeys(change.value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS) + ) + }) + .then((fn: () => void) => { + if (disposed) { + fn() + return + } + unlisten = fn + }) + .catch(() => void 0) + + return () => { + disposed = true + if (unlisten) unlisten() + } + }, []) + useEffect(() => { const api = (window as any).api if (!api || !isIosDevice) return @@ -302,6 +369,8 @@ function MainContent(): React.JSX.Element { const onMenuChange = (v: string) => { const key = String(v) + setMoreNavVisible(false) + if (immersiveMode && key !== "home") return if (key === "home") navigate("/") if (key === "students") navigate("/students") if (key === "score") navigate("/score") @@ -315,6 +384,7 @@ function MainContent(): React.JSX.Element { } const toggleSidebar = () => { + if (immersiveMode) return if (isPortraitMode && sidebarCollapsed) { setFloatingSidebarExpanded((prev) => !prev) return @@ -322,8 +392,69 @@ function MainContent(): React.JSX.Element { setSidebarCollapsed((prev) => !prev) } + useEffect(() => { + if (!immersiveMode) return + if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { + navigate("/", { replace: true }) + } + }, [immersiveMode, location.pathname, navigate]) + + const toggleImmersiveMode = () => { + setImmersiveMode((prev) => { + const next = !prev + if (next) { + setFloatingSidebarExpanded(false) + if (location.pathname !== "/" && !location.pathname.startsWith("/home")) { + navigate("/", { replace: true }) + } + } + return next + }) + } + const isDark = currentTheme?.mode === "dark" const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9" + const isMobileDevice = isIosDevice || isAndroidDevice + const showMobileBottomNav = isPortraitMode && !immersiveMode + const mobileBottomNavIconMap: Record = { + home: , + students: , + score: , + "auto-score": , + "reward-settings": , + boards: , + leaderboard: , + settlements: , + reasons: , + settings: , + } + + const mobileBottomVisibleItems = useMemo(() => { + const preferredKeys = sanitizeMobileNavKeys( + mobileBottomNavItems, + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ).slice() + for (const baseKey of ["home", "settings"] as const) { + if (!preferredKeys.includes(baseKey)) { + preferredKeys.push(baseKey) + } + } + + return preferredKeys + .map((key) => MOBILE_NAV_ITEMS.find((item) => item.key === key)) + .filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item)) + .filter((item) => !(item.adminOnly && permission !== "admin")) + }, [mobileBottomNavItems, permission]) + + const mobileBottomPrimaryItems = useMemo( + () => mobileBottomVisibleItems.slice(0, 4), + [mobileBottomVisibleItems] + ) + const mobileBottomOverflowItems = useMemo( + () => mobileBottomVisibleItems.slice(4), + [mobileBottomVisibleItems] + ) + const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu) return ( {contextHolder} - - + + {!isPortraitMode && ( +
+ +
+ )} + {showMobileBottomNav && ( +
+
+ {mobileBottomPrimaryItems.map((item) => ( + + ))} + {mobileBottomOverflowItems.length > 0 && ( + + )} +
+
+ )} + + setMoreNavVisible(false)} + footer={null} + width={360} + > +
+ {mobileBottomOverflowItems.map((item) => ( + + ))} +
+
setWizardVisible(false)} /> @@ -485,13 +733,18 @@ function getArchitecture(): string { if (userAgent.includes("arm64") || userAgent.includes("aarch64")) { return "ARM64" - } else if (userAgent.includes("x64") || userAgent.includes("amd64")) { + } else if ( + userAgent.includes("x64") || + userAgent.includes("amd64") || + userAgent.includes("x86_64") || + userAgent.includes("intel") + ) { return "x64" } else if (userAgent.includes("i386") || userAgent.includes("i686")) { return "x86" } - return userAgent + return "Unknown" } function getPlatform(): string { diff --git a/src/assets/main.css b/src/assets/main.css index 8b85e9a..dac3a70 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -225,3 +225,119 @@ html.platform-macos #root { .ss-group-board-modal .ant-modal-body { overflow: hidden; } + +.ss-immersive-sidebar { + --ss-sidebar-width: 200px; + height: 100%; + display: flex; + flex-shrink: 0; + width: var(--ss-sidebar-width); + overflow: hidden; + background: var(--ss-sidebar-bg); + opacity: 1; + transform: translateX(0); + transition: + width 260ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 220ms ease, + transform 260ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: width, opacity, transform; +} + +.ss-immersive-sidebar .ant-layout-sider { + height: 100% !important; +} + +.ss-immersive-sidebar.is-hidden { + width: 0; + opacity: 0; + transform: translateX(-10px); + pointer-events: none; +} + +.ss-immersive-toolbar { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + transition: + width 420ms cubic-bezier(0.2, 1.2, 0.26, 1), + opacity 200ms ease, + transform 320ms cubic-bezier(0.2, 1.2, 0.26, 1); + will-change: width, opacity, transform; +} + +.ss-immersive-toolbar.is-hidden { + opacity: 0; + transform: translateX(-50%) translateY(16px) scale(0.98); + pointer-events: none; +} + +.ss-settings-mobile-nav-list { + display: flex; + flex-direction: column; +} + +.ss-settings-mobile-nav-item.ant-btn { + min-height: 48px; + width: 100%; + border-radius: 0; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + color: var(--ss-text-main); + border-bottom: 1px solid var(--ss-divider, rgba(5, 5, 5, 0.06)); +} + +.ss-settings-mobile-nav-item.ant-btn:last-child { + border-bottom: none; +} + +.ss-settings-mobile-nav-item.ant-btn:hover, +.ss-settings-mobile-nav-item.ant-btn:focus-visible { + background: var(--ss-item-hover, rgba(0, 0, 0, 0.04)); +} + +.ss-settings-mobile-nav-item.ant-btn.is-active { + color: var(--ant-color-primary, #1677ff); + background: color-mix(in srgb, var(--ant-color-primary, #1677ff) 10%, transparent); +} + +.ss-settings-mobile-nav-item.ant-btn[disabled] { + color: var(--ss-text-secondary); + opacity: 0.65; +} + +.ss-settings-mobile-nav-item-label { + font-size: 15px; + font-weight: 500; +} + +.ss-settings-mobile-nav-item-arrow { + font-size: 12px; +} + +.ss-route-page { + min-height: 100%; +} + +.ss-route-page.is-subpage-enter { + animation: ss-subpage-enter 240ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes ss-subpage-enter { + from { + opacity: 0; + transform: translate3d(20px, 0, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@media (prefers-reduced-motion: reduce) { + .ss-immersive-sidebar, + .ss-immersive-toolbar, + .ss-route-page.is-subpage-enter { + transition: none !important; + animation: none !important; + } +} diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index 3ac5a76..20c3e8f 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react" import { HolderOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" -import RuleComponent from "./autoScore/ruleComponent" +import Rule from "./autoScore/Rule" import { Card, Form, @@ -426,43 +426,7 @@ export const AutoScoreManager: React.FC = () => { - - - {/* - - {triggerList.map((trigger, index) => renderTriggerItem(trigger, index))} - {' '} - - - - */} - - {/* - - {actionList.map((action) => renderActionItem(action))} - - - */} +
- {item.score !== undefined && ( + {(list.scoreDisplayMode === "total" || + (list.scoreDisplayMode === "split" && !hasSplitScore)) && + item.score !== undefined && ( = 0 ? "success" : "error"} style={{ margin: 0 }}> {t("board.metrics.totalScore")}:{" "} {item.score > 0 ? `+${item.score}` : item.score} + )} + {list.scoreDisplayMode === "split" && item.addScore !== undefined && ( + + {t("board.metrics.addScore")}:{" "} + {item.addScore > 0 ? `+${item.addScore}` : item.addScore} + + )} + {list.scoreDisplayMode === "split" && item.deductScore !== undefined && ( + + {t("board.metrics.deductScore")}: {item.deductScore} + )} {item.rewardPoints !== undefined && ( @@ -1258,7 +1359,15 @@ ORDER BY reward_points DESC, score DESC`, title={t("board.sqlEditorTitle")} open={Boolean(editingList)} onCancel={() => setEditingListId(null)} - onOk={() => setEditingListId(null)} + onOk={() => { + if (editingList && activeBoard && canManage) { + const nextSql = trimSqlTailSemicolon(editingList.sql) + if (nextSql !== editingList.sql) { + updateList(activeBoard.id, editingList.id, { sql: nextSql }) + } + } + setEditingListId(null) + }} okText={t("common.confirm")} cancelText={t("common.cancel")} width={860} @@ -1295,6 +1404,18 @@ ORDER BY reward_points DESC, score DESC`, ]} disabled={!canManage} /> + setSearchKeyword(e.target.value)} - onFocus={() => { - if (!disableSearchKeyboard) setShowPinyinKeyboard(true) + { - if (!disableSearchKeyboard) setShowPinyinKeyboard(true) - }} - onKeyDown={(e) => { - if (e.key === "Escape") setShowPinyinKeyboard(false) - }} - placeholder={t("home.searchPlaceholder")} - prefix={} - allowClear - style={{ width: "100%" }} - /> - {!disableSearchKeyboard && showPinyinKeyboard && ( + >
-
- {searchKeyboardLayout === "qwerty26" - ? qwertyKeyRows.map((row, rowIndex) => ( -
- {row.map((keyItem) => ( - - ))} -
- )) - : t9KeyRows.map((row, rowIndex) => ( -
- {row.map((keyItem) => ( - + ))} +
+ )) + : t9KeyRows.map((row, rowIndex) => ( +
- {keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`} - + {row.map((keyItem) => ( + + ))} +
))} -
- ))} -
+
+ + )} - )} - - setLayoutType(v as LayoutType)} - style={{ - width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px", - minWidth: isMobile ? "100px" : "120px", - flexShrink: isMobile ? 1 : 0, - }} - options={[ - { value: "grouped", label: t("home.layoutBy.grouped") }, - { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, - ]} - /> - - - {batchToolbar} - + setLayoutType(v as LayoutType)} + style={{ + width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px", + minWidth: isMobile ? "100px" : "120px", + flexShrink: isMobile ? 1 : 0, + }} + options={[ + { value: "grouped", label: t("home.layoutBy.grouped") }, + { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, + ]} + /> + + + {batchToolbar} + + + )} {renderQuickNav()} @@ -2449,6 +2520,172 @@ export const Home: React.FC = ({ canEdit, isPortraitMode = false }) = )} +
+
{ + searchAreaRef.current = node + immersiveToolbarContentRef.current = node + }} + style={{ + display: "inline-flex", + alignItems: "center", + gap: "8px", + overflowX: "visible", + justifyContent: "flex-start", + }} + > + setSearchKeyword(e.target.value)} + onFocus={() => { + if (canShowSearchKeyboard) setShowPinyinKeyboard(true) + }} + onClick={() => { + if (canShowSearchKeyboard) setShowPinyinKeyboard(true) + }} + onKeyDown={(e) => { + if (e.key === "Escape") setShowPinyinKeyboard(false) + }} + placeholder={t("home.searchPlaceholder")} + prefix={} + allowClear + style={{ width: isPortraitMode ? "170px" : "220px", borderRadius: "999px", flexShrink: 0 }} + /> + setLayoutType(v as LayoutType)} + getPopupContainer={getImmersivePopupContainer} + style={{ width: 126, flexShrink: 0 }} + options={[ + { value: "grouped", label: t("home.layoutBy.grouped") }, + { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, + ]} + /> + + +
{batchToolbar}
+ {canShowSearchKeyboard && showPinyinKeyboard && ( +
+
+ {searchKeyboardLayout === "qwerty26" + ? qwertyKeyRows.map((row, rowIndex) => ( +
+ {row.map((keyItem) => ( + + ))} +
+ )) + : t9KeyRows.map((row, rowIndex) => ( +
+ {row.map((keyItem) => ( + + ))} +
+ ))} +
+
+ )} +
+
+ {isPortraitMode ? ( item.key) + const withTimeout = async ( promise: Promise, ms: number, @@ -46,8 +56,13 @@ const withTimeout = async ( } } -export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { +export const Settings: React.FC<{ + permission: permissionLevel + mobileNavigationEnabled?: boolean +}> = ({ permission, mobileNavigationEnabled = false }) => { const { t } = useTranslation() + const navigate = useNavigate() + const location = useLocation() const breakpoint = useResponsive() const isMobile = breakpoint === "xs" || breakpoint === "sm" const [activeTab, setActiveTab] = useState("appearance") @@ -58,6 +73,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission window_zoom: "1.0", search_keyboard_layout: "qwerty26", disable_search_keyboard: false, + mobile_bottom_nav_items: DEFAULT_MOBILE_BOTTOM_NAV_ITEMS, }) const [securityStatus, setSecurityStatus] = useState<{ @@ -132,6 +148,19 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission ) }, [permission, t]) + const mobilePageItems = useMemo( + () => + MOBILE_NAV_ITEMS.filter((item) => item.key !== "home" && item.key !== "settings").map( + (item) => ({ + key: item.key, + path: item.path, + label: t(item.labelKey), + disabled: Boolean(item.adminOnly) && !canAdmin, + }) + ), + [canAdmin, t] + ) + const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } @@ -155,7 +184,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission if (!(window as any).api) return const res = await (window as any).api.getAllSettings() if (res.success && res.data) { - setSettings(res.data) + setSettings({ + ...res.data, + mobile_bottom_nav_items: sanitizeMobileNavKeys( + res.data.mobile_bottom_nav_items, + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ), + }) setPgConnectionString(res.data.pg_connection_string || "") setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) } @@ -185,6 +220,15 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission return { ...prev, disable_search_keyboard: Boolean(change.value) } if (change?.key === "auto_score_enabled") return { ...prev, auto_score_enabled: change.value } + if (change?.key === "mobile_bottom_nav_items") { + return { + ...prev, + mobile_bottom_nav_items: sanitizeMobileNavKeys( + change.value, + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ), + } + } return prev }) }) @@ -593,6 +637,18 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) } + const handleMobileBottomNavChange = async (value: string[]) => { + if (!(window as any).api) return + const next = sanitizeMobileNavKeys(value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS) + const res = await (window as any).api.setSetting("mobile_bottom_nav_items", next) + if (res.success) { + setSettings((prev) => ({ ...prev, mobile_bottom_nav_items: next })) + messageApi.success(t("settings.general.saved")) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + } + const tabItems = [ { key: "appearance", @@ -629,58 +685,62 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
- - { + if (!(window as any).api) return + const next = String(v) as "t9" | "qwerty26" + const res = await (window as any).api.setSetting("search_keyboard_layout", next) + if (res.success) { + setSettings((prev) => ({ ...prev, search_keyboard_layout: next })) + messageApi.success(t("settings.general.saved")) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + }} + style={{ width: "320px" }} + disabled={!canAdmin} + options={[ + { value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") }, + { value: "t9", label: t("settings.searchKeyboard.options.t9") }, + ]} + /> +
+ {t("settings.searchKeyboard.hint")} +
+
- - { - if (!(window as any).api) return - const res = await (window as any).api.setSetting( - "disable_search_keyboard", - checked - ) - if (res.success) { - setSettings((prev) => ({ ...prev, disable_search_keyboard: checked })) - messageApi.success(t("settings.general.saved")) - } else { - messageApi.error(res.message || t("settings.general.saveFailed")) - } - }} - disabled={!canAdmin} - /> -
- {t("settings.searchKeyboard.disableHint")} -
-
+ + { + if (!(window as any).api) return + const res = await (window as any).api.setSetting( + "disable_search_keyboard", + checked + ) + if (res.success) { + setSettings((prev) => ({ ...prev, disable_search_keyboard: checked })) + messageApi.success(t("settings.general.saved")) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + }} + disabled={!canAdmin} + /> +
+ {t("settings.searchKeyboard.disableHint")} +
+
+ + )} handleMobileBottomNavChange(v as string[])} + style={{ width: "100%", maxWidth: "520px" }} + disabled={!canAdmin} + options={MOBILE_NAV_ITEMS.map((item) => ({ + value: item.key, + label: t(item.labelKey), + disabled: Boolean(item.adminOnly) && !canAdmin, + }))} + /> +
+ {t("settings.mobile.bottomNav.hint")} +
+
+ )}
), @@ -1277,6 +1359,35 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission {permissionTag} + {mobileNavigationEnabled && isMobile && ( + +
+ {mobilePageItems.map((item) => ( + + ))} +
+
+ )} + { + return ruleConfigs.find((r) => r.type === type) +} + +export const validateQuery = (tree: any, config: Config): boolean => { + try { + return QbUtils.isValidTree(tree, config) + } catch { + return false + } +} + +export const exportQueryToJsonLogic = (tree: any, config: Config): string => { + try { + const result = QbUtils.jsonLogicFormat(tree, config) + return JSON.stringify(result.logic, null, 2) + } catch { + return "{}" + } +} diff --git a/src/components/autoScore/Rule.tsx b/src/components/autoScore/Rule.tsx new file mode 100644 index 0000000..73f76ae --- /dev/null +++ b/src/components/autoScore/Rule.tsx @@ -0,0 +1,204 @@ +import React, { useState, useCallback } from "react" +import { Card, Tabs, Button, Space, message, Form, Input, Select } from "antd" +import { useTranslation } from "react-i18next" +import type { + Config, + ImmutableTree, + BuilderProps, + JsonGroup, +} from "@react-awesome-query-builder/antd" +import { Utils as QbUtils, Query, Builder } from "@react-awesome-query-builder/antd" +import "@react-awesome-query-builder/antd/css/styles.css" +import { ruleConfigs, getRuleConfig, exportQueryToJsonLogic } from "./BuilderUtils" + +interface RuleProps { + onRuleSubmit?: (ruleData: { + type: string + name: string + query: JsonGroup + jsonLogic: string + studentNames: string[] + }) => void +} + +export const Rule: React.FC = ({ onRuleSubmit }) => { + const { t } = useTranslation() + const [messageApi, contextHolder] = message.useMessage() + const [form] = Form.useForm() + const [activeTab, setActiveTab] = useState("interval_time") + const [students, setStudents] = useState<{ id: number; name: string }[]>([]) + + const [intervalTimeState, setIntervalTimeState] = useState(() => { + const config = getRuleConfig("interval_time") + return { + tree: QbUtils.loadTree(config?.defaultQuery || { id: QbUtils.uuid(), type: "group" }), + config: config?.config || ({} as Config), + } + }) + + const [studentTagState, setStudentTagState] = useState(() => { + const config = getRuleConfig("student_tag") + return { + tree: QbUtils.loadTree(config?.defaultQuery || { id: QbUtils.uuid(), type: "group" }), + config: config?.config || ({} as Config), + } + }) + + const fetchStudents = useCallback(async () => { + if (!(window as any).api) return + try { + const res = await (window as any).api.queryStudents({}) + if (res.success) { + setStudents(res.data) + } + } catch (error) { + console.error("Failed to fetch students:", error) + } + }, []) + + React.useEffect(() => { + fetchStudents() + }, [fetchStudents]) + + const handleIntervalTimeChange = useCallback((immutableTree: ImmutableTree, config: Config) => { + setIntervalTimeState((prev) => ({ ...prev, tree: immutableTree, config })) + }, []) + + const handleStudentTagChange = useCallback((immutableTree: ImmutableTree, config: Config) => { + setStudentTagState((prev) => ({ ...prev, tree: immutableTree, config })) + }, []) + + const renderBuilder = useCallback( + (props: BuilderProps) => ( +
+
+ +
+
+ ), + [] + ) + + const handleSubmit = async () => { + const values = form.getFieldsValue(true) + if (!values.name) { + messageApi.warning(t("autoScore.nameRequired")) + return + } + + const ruleConfig = getRuleConfig(activeTab as "interval_time" | "student_tag") + if (!ruleConfig) { + messageApi.error(t("autoScore.ruleConfigNotFound")) + return + } + + const currentState = activeTab === "interval_time" ? intervalTimeState : studentTagState + const jsonLogic = exportQueryToJsonLogic(currentState.tree, currentState.config) + + if (onRuleSubmit) { + onRuleSubmit({ + type: activeTab, + name: values.name, + query: QbUtils.getTree(currentState.tree) as unknown as JsonGroup, + jsonLogic, + studentNames: values.studentNames || [], + }) + } + + messageApi.success(t("autoScore.ruleSaved")) + } + + const handleReset = () => { + form.resetFields() + const intervalConfig = getRuleConfig("interval_time") + const tagConfig = getRuleConfig("student_tag") + + setIntervalTimeState({ + tree: QbUtils.loadTree(intervalConfig?.defaultQuery || { id: QbUtils.uuid(), type: "group" }), + config: intervalConfig?.config || ({} as Config), + }) + + setStudentTagState({ + tree: QbUtils.loadTree(tagConfig?.defaultQuery || { id: QbUtils.uuid(), type: "group" }), + config: tagConfig?.config || ({} as Config), + }) + } + + const tabItems = [ + { + key: "interval_time", + label: t("autoScore.intervalTimeRule"), + children: ( +
+

+ {ruleConfigs[0].description} +

+ +
+ ), + }, + { + key: "student_tag", + label: t("autoScore.studentTagRule"), + children: ( +
+

+ {ruleConfigs[1].description} +

+ +
+ ), + }, + ] + + return ( +
+ {contextHolder} + +
+
+ + + + + + handleActionChange(action.id, "eventName", value)} - style={{ width: 150 }} - options={ACTION_DEFINITIONS.map((d) => ({ - label: t(d.labelKey), - value: d.eventName, - }))} - /> - - {action.eventName === "add_score" && ( - handleActionChange(action.id, "value", String(value || 0))} - placeholder={t("autoScore.scorePlaceholder")} - min={SCORE_RANGE.MIN} - max={SCORE_RANGE.MAX} - style={{ width: 120 }} - /> - )} - - {action.eventName === "add_tag" && ( - handleActionChange(action.id, "value", e.target.value)} - placeholder={t("autoScore.tagNamePlaceholder")} - style={{ width: 200 }} - /> - )} - - handleActionChange(action.id, "reason", e.target.value)} - placeholder={t("autoScore.reasonPlaceholder")} - style={{ flex: 1, minWidth: 200 }} - /> - -
- ))} - - - -
- ) -} - -export default ActionComponent diff --git a/src/components/autoScore/ruleBuilderOverride.css b/src/components/autoScore/ruleBuilderOverride.css deleted file mode 100644 index 045ac08..0000000 --- a/src/components/autoScore/ruleBuilderOverride.css +++ /dev/null @@ -1,251 +0,0 @@ -.ruleGroup { - background-color: var(--ss-card-bg, #fff); - border: 1px solid var(--ss-border-color, #d9d9d9); - border-radius: 8px; - padding: 16px; - margin-bottom: 12px; -} - -.ruleGroup-header { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 12px; - flex-wrap: wrap; -} - -.ruleGroup-combinators, -.ruleGroup-addRule, -.ruleGroup-addGroup { - margin-right: 8px; -} - -.ruleGroup-body { - padding-left: 12px; - border-left: 2px solid var(--ss-primary-color, #1677ff); - margin-left: 4px; -} - -.rule { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background-color: var(--ss-item-bg, rgba(0, 0, 0, 0.02)); - border-radius: 6px; - margin-bottom: 8px; - flex-wrap: wrap; -} - -.rule:hover { - background-color: var(--ss-item-hover, rgba(0, 0, 0, 0.04)); -} - -.rule-fields, -.rule-operators, -.rule-value, -.rule-subfields { - min-width: 120px; -} - -.rule-fields .ant-select-selector, -.rule-operators .ant-select-selector, -.rule-subfields .ant-select-selector { - background-color: var(--ss-input-bg, #fff) !important; - border-color: var(--ss-border-color, #d9d9d9) !important; - color: var(--ss-text-main, rgba(0, 0, 0, 0.88)) !important; -} - -.rule-fields .ant-select-selector:hover, -.rule-operators .ant-select-selector:hover, -.rule-subfields .ant-select-selector:hover { - border-color: var(--ss-primary-color, #1677ff) !important; -} - -.rule-fields .ant-select-focused .ant-select-selector, -.rule-operators .ant-select-focused .ant-select-selector, -.rule-subfields .ant-select-focused .ant-select-selector { - border-color: var(--ss-primary-color, #1677ff) !important; - box-shadow: 0 0 0 2px rgba(var(--ss-primary-rgb, 22, 119, 255), 0.1) !important; -} - -.rule-value .ant-input, -.rule-value .ant-input-number, -.rule-value .ant-select-selector { - background-color: var(--ss-input-bg, #fff) !important; - border-color: var(--ss-border-color, #d9d9d9) !important; - color: var(--ss-text-main, rgba(0, 0, 0, 0.88)) !important; -} - -.rule-value .ant-input:hover, -.rule-value .ant-input-number:hover, -.rule-value .ant-select-selector:hover { - border-color: var(--ss-primary-color, #1677ff) !important; -} - -.rule-value .ant-input:focus, -.rule-value .ant-input-number-focused, -.rule-value .ant-select-focused .ant-select-selector { - border-color: var(--ss-primary-color, #1677ff) !important; - box-shadow: 0 0 0 2px rgba(var(--ss-primary-rgb, 22, 119, 255), 0.1) !important; -} - -.rule-value .ant-input-number-input { - color: var(--ss-text-main, rgba(0, 0, 0, 0.88)) !important; -} - -.rule-remove, -.ruleGroup-remove { - color: #fff !important; - background-color: var(--ant-color-error, #ff4d4f) !important; - border-color: var(--ant-color-error, #ff4d4f) !important; - transition: all 0.2s; -} - -.rule-remove:hover, -.ruleGroup-remove:hover { - color: #fff !important; - background-color: #ff7875 !important; - border-color: #ff7875 !important; -} - -.ruleGroup-addRule, -.ruleGroup-addGroup { - color: var(--ss-primary-color, #1677ff) !important; - border-color: var(--ss-primary-color, #1677ff) !important; - background-color: transparent !important; -} - -.ruleGroup-addRule:hover, -.ruleGroup-addGroup:hover { - color: #fff !important; - background-color: var(--ss-primary-color, #1677ff) !important; -} - -.betweenRules { - display: flex; - align-items: center; - justify-content: center; - margin: 8px 0; - color: var(--ss-text-secondary, rgba(0, 0, 0, 0.45)); - font-size: 12px; -} - -.betweenRules::before, -.betweenRules::after { - content: ""; - flex: 1; - height: 1px; - background-color: var(--ss-border-color, #d9d9d9); -} - -.betweenRules::before { - margin-right: 12px; -} - -.betweenRules::after { - margin-left: 12px; -} - -.dragHandle { - cursor: grab; - color: var(--ss-text-secondary, rgba(0, 0, 0, 0.45)); - padding: 4px; - margin-right: 4px; -} - -.dragHandle:hover { - color: var(--ss-text-main, rgba(0, 0, 0, 0.88)); -} - -.queryBuilder .ant-select-dropdown { - background-color: var(--ss-card-bg, #fff); -} - -.queryBuilder .ant-select-item { - color: var(--ss-text-main, rgba(0, 0, 0, 0.88)); -} - -.queryBuilder .ant-select-item-option-active { - background-color: var(--ss-item-hover, rgba(0, 0, 0, 0.04)); -} - -.queryBuilder .ant-select-item-option-selected { - background-color: rgba(var(--ss-primary-rgb, 22, 119, 255), 0.1); - color: var(--ss-primary-color, #1677ff); -} - -.queryBuilder .ant-btn { - border-radius: 6px; -} - -.queryBuilder .ant-select-selector, -.queryBuilder .ant-input, -.queryBuilder .ant-input-number { - border-radius: 6px; -} - -[theme-mode="dark"] .ruleGroup { - background-color: var(--ss-card-bg, #1f1f1f); - border-color: var(--ss-border-color, #424242); -} - -[theme-mode="dark"] .rule { - background-color: var(--ss-item-bg, rgba(255, 255, 255, 0.04)); -} - -[theme-mode="dark"] .rule:hover { - background-color: var(--ss-item-hover, rgba(255, 255, 255, 0.08)); -} - -[theme-mode="dark"] .rule-fields .ant-select-selector, -[theme-mode="dark"] .rule-operators .ant-select-selector, -[theme-mode="dark"] .rule-subfields .ant-select-selector, -[theme-mode="dark"] .rule-value .ant-input, -[theme-mode="dark"] .rule-value .ant-input-number, -[theme-mode="dark"] .rule-value .ant-select-selector { - background-color: var(--ss-input-bg, #141414) !important; - border-color: var(--ss-border-color, #424242) !important; - color: var(--ss-text-main, rgba(255, 255, 255, 0.85)) !important; -} - -[theme-mode="dark"] .rule-value .ant-input-number-input { - color: var(--ss-text-main, rgba(255, 255, 255, 0.85)) !important; -} - -[theme-mode="dark"] .betweenRules { - color: var(--ss-text-secondary, rgba(255, 255, 255, 0.45)); -} - -[theme-mode="dark"] .betweenRules::before, -[theme-mode="dark"] .betweenRules::after { - background-color: var(--ss-border-color, #424242); -} - -[theme-mode="dark"] .dragHandle { - color: var(--ss-text-secondary, rgba(255, 255, 255, 0.45)); -} - -[theme-mode="dark"] .dragHandle:hover { - color: var(--ss-text-main, rgba(255, 255, 255, 0.85)); -} - -[theme-mode="dark"] .queryBuilder .ant-select-dropdown { - background-color: var(--ss-card-bg, #1f1f1f); -} - -[theme-mode="dark"] .queryBuilder .ant-select-item { - color: var(--ss-text-main, rgba(255, 255, 255, 0.85)); -} - -[theme-mode="dark"] .queryBuilder .ant-select-item-option-active { - background-color: var(--ss-item-hover, rgba(255, 255, 255, 0.08)); -} - -[theme-mode="dark"] .queryBuilder .ant-select-item-option-selected { - background-color: rgba(var(--ss-primary-rgb, 22, 119, 255), 0.2); -} - -.ruleGroup-header .ant-select-selector { - min-width: 80px; -} diff --git a/src/components/autoScore/ruleBuilderUtils.ts b/src/components/autoScore/ruleBuilderUtils.ts deleted file mode 100644 index 4b9c01d..0000000 --- a/src/components/autoScore/ruleBuilderUtils.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { RuleGroupType, Field, RuleType } from "react-querybuilder" -import { defaultOperators } from "react-querybuilder" -import { fetchAllTags } from "../TagEditorDialog" - -const tags = await fetchAllTags() - -export interface AutoScoreTrigger { - event: string - value?: string - relation?: "AND" | "OR" -} - -export interface AutoScoreAction { - event: string - value?: string - reason?: string -} - -export interface AutoScoreRuleData { - triggers: AutoScoreTrigger[] - actions: AutoScoreAction[] -} - -export const validator = (r: RuleType) => !!r.value - -export const getFields = (t: (key: string) => string): Field[] => [ - { - name: "student_has_tag", - label: t("triggers.studentTag.label"), - placeholder: t("triggers.studentTag.placeholder"), - valueEditorType: "multiselect", - values: tags.map((tag: { id: number; name: string }) => tag.name), - defaultValue: tags.length > 0 ? [tags[0].name] : [], - operators: defaultOperators.filter((op) => op.name === "in"), - }, - { - name: "interval_time_passed", - label: t("triggers.intervalTime.label"), - matchModes: ["all"], - subproperties: [ - { - name: "month", - label: t("triggers.intervalTime.monthLabel"), - inputType: "number", - datatype: "month", - operators: ["="], - }, - /* { name: 'week', label: t('triggers.intervalTime.weekLabel'), inputType: 'week', datatype: 'week', operators: ['='] }, - */ { - name: "time", - label: t("triggers.intervalTime.timeLabel"), - inputType: "time", - datatype: "time", - operators: ["="], - }, - ], - }, -] - -export const defaultQuery: RuleGroupType = { - combinator: "and", - rules: [{ field: "interval_time_passed", operator: "=", value: "1440" }], -} - -export function queryToAutoScoreRule(): AutoScoreRuleData { - const triggers: AutoScoreTrigger[] = [] - - /* const processRuleGroup = (group: RuleGroupType, relation: 'AND' | 'OR' = 'AND') => { - group.rules.forEach((rule, index) => { - if ('rules' in rule) { - processRuleGroup(rule, group.combinator === 'and' ? 'AND' : 'OR') - } else { - const trigger: AutoScoreTrigger = { - event: rule.field, - value: rule.value as string - } - - if (index > 0) { - trigger.relation = relation - } - - triggers.push(trigger) - } - }) - } */ - - /* processRuleGroup(query) */ - - return { - triggers, - actions: [], - } -} - -export function autoScoreRuleToQuery(ruleData: AutoScoreRuleData): RuleGroupType { - const rules: any[] = [] - let currentCombinator: "and" | "or" = "and" - - ruleData.triggers.forEach((trigger, index) => { - if (index === 0) { - currentCombinator = "and" - } else if (trigger.relation === "OR") { - currentCombinator = "or" - } - - rules.push({ - field: trigger.event, - operator: "=", - value: trigger.value || "", - }) - }) - - return { - combinator: currentCombinator, - rules: rules.length > 0 ? rules : defaultQuery.rules, - } -} diff --git a/src/components/autoScore/ruleComponent.tsx b/src/components/autoScore/ruleComponent.tsx deleted file mode 100644 index b9da023..0000000 --- a/src/components/autoScore/ruleComponent.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { formatQuery, QueryBuilder, RuleGroupType } from "react-querybuilder" -import { useState, useEffect } from "react" -import { useTranslation } from "react-i18next" -import { QueryBuilderDnD } from "@react-querybuilder/dnd" -import * as ReactDnD from "react-dnd" -import { QueryBuilderAntD } from "@react-querybuilder/antd" -import * as ReactDndHtml5Backend from "react-dnd-html5-backend" -import * as ReactDndTouchBackend from "react-dnd-touch-backend" -import { - getFields, - defaultQuery, - autoScoreRuleToQuery, - queryToAutoScoreRule, - type AutoScoreRuleData, -} from "./ruleBuilderUtils" -import { Card } from "antd" - -import "./ruleBuilderOverride.css" - -interface RuleComponentProps { - initialData?: AutoScoreRuleData - onChange?: (data: AutoScoreRuleData) => void -} - -export const RuleComponent: React.FC = ({ initialData, onChange }) => { - const { t } = useTranslation() - const [query, setQuery] = useState( - initialData ? autoScoreRuleToQuery(initialData) : defaultQuery - ) - - useEffect(() => { - if (initialData) { - setQuery(autoScoreRuleToQuery(initialData)) - } - }, [initialData]) - - const handleQueryChange = (newQuery: RuleGroupType) => { - setQuery(newQuery) - if (onChange) { - const ruleData = queryToAutoScoreRule() - onChange(ruleData) - } - } - - return ( -
- - - - - - -
-
{formatQuery(query, "json")}
-
-
-
- ) -} - -export default RuleComponent diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 2cde674..842b71f 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -34,7 +34,8 @@ "pleaseSelect": "Please select", "pleaseEnter": "Please enter", "readOnly": "Read-only mode", - "none": "None" + "none": "None", + "more": "More" }, "oobe": { "title": "Welcome to SecScore", @@ -161,6 +162,13 @@ "saveFailed": "Save failed" }, "zoomHint": "Adjust the overall size of the application interface", + "mobile": { + "navigationTitle": "Page Shortcuts", + "bottomNav": { + "label": "Bottom Navigation Features", + "hint": "Choose which features appear in the mobile bottom bar. The bar shows up to 4 items; extra items go under \"More\"." + } + }, "zoomUpdated": "Interface zoom updated", "updateFailed": "Update failed", "security": { @@ -660,8 +668,14 @@ "card": "Card View", "grid": "Grid View" }, + "scoreDisplayModes": { + "total": "Show Total Score", + "split": "Show Add & Deduct" + }, "metrics": { "totalScore": "Score", + "addScore": "Added", + "deductScore": "Deducted", "rewardPoints": "Reward", "weekChange": "7d", "weekDeducted": "7d Deduct", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 282bdc8..3037249 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -34,7 +34,8 @@ "pleaseSelect": "请选择", "pleaseEnter": "请输入", "readOnly": "当前为只读权限", - "none": "无" + "none": "无", + "more": "更多" }, "oobe": { "title": "欢迎使用 SecScore", @@ -161,6 +162,13 @@ "saveFailed": "保存失败" }, "zoomHint": "调节应用界面的整体大小", + "mobile": { + "navigationTitle": "页面入口", + "bottomNav": { + "label": "底部导航功能", + "hint": "可选择要出现在手机底部导航的功能。底栏最多展示 4 项,其余会自动收纳到“更多”。" + } + }, "zoomUpdated": "界面缩放已更新", "updateFailed": "更新失败", "security": { @@ -423,7 +431,7 @@ "operationTitle": "积分操作:{{name}}", "submitOperation": "提交操作", "operationTitleBatch": "积分操作:已选 {{count}} 人", - "undoLastAction": "撤销上一步", + "undoLastAction": "撤销", "undoLastHint": "撤销上一条:{{name}} {{delta}}", "undoUnavailable": "当前没有可撤销的积分操作", "undoLastSuccess": "已撤销上一步积分操作", @@ -592,7 +600,7 @@ }, "rewardExchange": { "title": "奖励兑换", - "enterMode": "进入奖励兑换模式", + "enterMode": "奖励兑换", "exitMode": "退出奖励兑换模式", "modeHint": "当前为奖励兑换模式,点击学生卡可选择可兑换奖励。", "normalHint": "当前显示学生原始积分,点击按钮后将显示可兑换积分。", @@ -660,8 +668,14 @@ "card": "卡片视图", "grid": "网格视图" }, + "scoreDisplayModes": { + "total": "显示总分", + "split": "显示加分与扣分" + }, "metrics": { "totalScore": "总分", + "addScore": "加分", + "deductScore": "扣分", "rewardPoints": "奖励分", "weekChange": "近7天", "weekDeducted": "近7天扣分", diff --git a/src/preload/types.ts b/src/preload/types.ts index 78c6b22..8c527ce 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -35,6 +35,7 @@ export type settingsKey = | "dashboards_config" | "pg_connection_string" | "pg_connection_status" + | "mobile_bottom_nav_items" export interface settingsSpec { is_wizard_completed: boolean @@ -53,6 +54,7 @@ export interface settingsSpec { type: "sqlite" | "postgresql" error?: string } + mobile_bottom_nav_items: string[] } const api = { diff --git a/src/shared/mobileNavigation.ts b/src/shared/mobileNavigation.ts new file mode 100644 index 0000000..7142aa4 --- /dev/null +++ b/src/shared/mobileNavigation.ts @@ -0,0 +1,55 @@ +export const MOBILE_NAV_ALL_KEYS = [ + "home", + "students", + "score", + "auto-score", + "reward-settings", + "boards", + "leaderboard", + "settlements", + "reasons", + "settings", +] as const + +export type MobileNavKey = (typeof MOBILE_NAV_ALL_KEYS)[number] + +export interface MobileNavItemConfig { + key: MobileNavKey + path: string + labelKey: string + adminOnly?: boolean +} + +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" }, + { + key: "reward-settings", + path: "/reward-settings", + labelKey: "sidebar.rewardSettings", + adminOnly: true, + }, + { key: "boards", path: "/boards", labelKey: "sidebar.boards" }, + { key: "leaderboard", path: "/leaderboard", labelKey: "sidebar.leaderboard" }, + { key: "settlements", path: "/settlements", labelKey: "sidebar.settlements" }, + { key: "reasons", path: "/reasons", labelKey: "sidebar.reasons", adminOnly: true }, + { key: "settings", path: "/settings", labelKey: "sidebar.settings" }, +] + +const MOBILE_NAV_KEY_SET = new Set(MOBILE_NAV_ALL_KEYS) + +export const sanitizeMobileNavKeys = ( + input: unknown, + fallback: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) +): MobileNavKey[] => { + if (!Array.isArray(input)) return fallback + const deduped: MobileNavKey[] = [] + for (const rawKey of input) { + if (typeof rawKey !== "string" || !MOBILE_NAV_KEY_SET.has(rawKey)) continue + const key = rawKey as MobileNavKey + if (!deduped.includes(key)) deduped.push(key) + } + return deduped.length > 0 ? deduped : fallback +} diff --git a/vite.config.ts b/vite.config.ts index efac6a8..6812fc3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,8 +18,16 @@ export default defineConfig({ emptyOutDir: true, }, server: { + host: process.env.TAURI_DEV_HOST || false, port: 1420, - strictPort: true, + strictPort: false, + hmr: process.env.TAURI_DEV_HOST + ? { + protocol: "ws", + host: process.env.TAURI_DEV_HOST, + port: 1421, + } + : undefined, }, clearScreen: false, })