This commit is contained in:
Yukino_fox
2026-03-28 10:34:01 +08:00
27 changed files with 2076 additions and 1123 deletions
+12
View File
@@ -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__"
+2 -1
View File
@@ -5,7 +5,8 @@
- 所有代码跑`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`)。
-5
View File
@@ -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",
+18 -122
View File
@@ -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
+40
View File
@@ -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`
用户需求:
{ {在这里粘贴用户需求} }
+1 -1
View File
@@ -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}`,
},
}
+195 -107
View File
@@ -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? {
// 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<String> = 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;
}
}
let remote_students_after = load_students(&remote_conn).await?;
for student in remote_students_after.values() {
if upsert_student(&local_conn, student).await? {
(None, Some(remote)) => {
if upsert_student(&local_conn, remote).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;
_ => {}
}
}
for pair in local_pairs.union(&remote_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
let reason_keys: std::collections::HashSet<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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;
}
}
+1 -1
View File
@@ -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();
+73
View File
@@ -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"
]),
},
}
}
+257 -4
View File
@@ -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<MobileNavKey[]>(
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<MobileNavKey, React.ReactNode> = {
home: <HomeOutlined style={{ fontSize: "18px" }} />,
students: <UserOutlined style={{ fontSize: "18px" }} />,
score: <HistoryOutlined style={{ fontSize: "18px" }} />,
"auto-score": <SyncOutlined style={{ fontSize: "18px" }} />,
"reward-settings": <AppstoreAddOutlined style={{ fontSize: "18px" }} />,
boards: <ApartmentOutlined style={{ fontSize: "18px" }} />,
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
}
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 (
<ConfigProvider
@@ -337,7 +468,16 @@ function MainContent(): React.JSX.Element {
}}
>
{contextHolder}
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden" }}>
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}>
{!isPortraitMode && (
<div
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
style={
{
"--ss-sidebar-width": `${sidebarCollapsed ? 64 : 200}px`,
} as React.CSSProperties
}
>
<Sidebar
activeMenu={activeMenu}
permission={permission}
@@ -347,6 +487,8 @@ function MainContent(): React.JSX.Element {
floatingExpanded={floatingSidebarExpanded}
onFloatingExpandedChange={setFloatingSidebarExpanded}
/>
</div>
)}
<ContentArea
permission={permission}
hasAnyPassword={hasAnyPassword}
@@ -354,11 +496,117 @@ function MainContent(): React.JSX.Element {
onLogout={logout}
showWindowControls={!isIosDevice && !isAndroidDevice}
isPortraitMode={isPortraitMode}
isMobileDevice={isMobileDevice}
sidebarCollapsed={sidebarCollapsed}
floatingExpand={isPortraitMode}
floatingExpanded={floatingSidebarExpanded}
onToggleSidebar={toggleSidebar}
immersiveMode={immersiveMode}
isHomePage={activeMenu === "home"}
onToggleImmersiveMode={toggleImmersiveMode}
showSidebarToggle={!isPortraitMode}
bottomInset={showMobileBottomNav ? 84 : 0}
/>
{showMobileBottomNav && (
<div
style={{
position: "fixed",
left: 0,
right: 0,
bottom: 0,
background: "var(--ss-header-bg)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderTop: "1px solid var(--ss-border-color)",
zIndex: 1400,
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
>
<div style={{ display: "flex", height: "60px" }}>
{mobileBottomPrimaryItems.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onMenuChange(item.key)}
style={{
flex: 1,
border: "none",
background: "transparent",
color:
activeMenu === item.key
? "var(--ant-color-primary)"
: "var(--ss-text-secondary, var(--ss-text-main))",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
{mobileBottomNavIconMap[item.key]}
<span>{t(item.labelKey)}</span>
</button>
))}
{mobileBottomOverflowItems.length > 0 && (
<button
type="button"
onClick={() => setMoreNavVisible(true)}
style={{
flex: 1,
border: "none",
background: "transparent",
color: isMoreActive
? "var(--ant-color-primary)"
: "var(--ss-text-secondary, var(--ss-text-main))",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
<MoreOutlined style={{ fontSize: "18px" }} />
<span>{t("common.more", "更多")}</span>
</button>
)}
</div>
</div>
)}
<Modal
title={t("common.more", "更多")}
open={moreNavVisible}
onCancel={() => setMoreNavVisible(false)}
footer={null}
width={360}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{mobileBottomOverflowItems.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onMenuChange(item.key)}
style={{
border: "1px solid var(--ss-border-color)",
borderRadius: "10px",
background: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
height: "44px",
padding: "0 12px",
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
}}
>
{mobileBottomNavIconMap[item.key]}
<span>{t(item.labelKey)}</span>
</button>
))}
</div>
</Modal>
<OOBE visible={wizardVisible} onComplete={() => 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 {
+116
View File
@@ -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;
}
}
+2 -38
View File
@@ -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 = () => {
</Form>
</Card>
<RuleComponent />
{/* <Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title={t('autoScore.whenTriggered')}
>
<Space orientation="vertical" style={{ width: '100%' }}>
{triggerList.map((trigger, index) => renderTriggerItem(trigger, index))}
{' '}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddTrigger}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
{t('autoScore.addTrigger')}
</Button>
</Space>
</Card> */}
{/* <Card
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
title={t('autoScore.triggeredActions')}
>
<Space orientation="vertical" style={{ width: '100%' }}>
{actionList.map((action) => renderActionItem(action))}
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={handleAddAction}
style={{ fontWeight: 'bolder', fontSize: 15 }}
>
{t('autoScore.addAction')}
</Button>
</Space>
</Card> */}
<Rule />
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
<Button type="primary" onClick={handleSubmit}>
+131 -10
View File
@@ -19,6 +19,7 @@ import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant
import { useTranslation } from "react-i18next"
type BoardStudentViewMode = "list" | "card" | "grid"
type BoardScoreDisplayMode = "total" | "split"
type SplitDirection = "horizontal" | "vertical"
interface StudentListConfig {
@@ -26,6 +27,7 @@ interface StudentListConfig {
name: string
sql: string
viewMode: BoardStudentViewMode
scoreDisplayMode: BoardScoreDisplayMode
}
interface LayoutLeafNode {
@@ -67,6 +69,10 @@ interface BoardStudentCardData {
key: string
name: string
score?: number
addScore?: number
deductScore?: number
hasAddScoreField?: boolean
hasDeductScoreField?: boolean
rewardPoints?: number
weekChange?: number
weekDeducted?: number
@@ -95,18 +101,31 @@ const makeId = () =>
const clampRatio = (value: number) => Math.max(0.15, Math.min(0.85, value))
const getDefaultSql = () => `SELECT
name AS student_name,
score,
reward_points
FROM students
ORDER BY score DESC`
const getDefaultSql = () => `WITH score_stat AS (
SELECT
student_name,
SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END) AS add_score,
SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END) AS deduct_score
FROM score_events
WHERE settlement_id IS NULL
GROUP BY student_name
)
SELECT
s.name AS student_name,
s.score,
s.reward_points,
COALESCE(ss.add_score, 0) AS add_score,
COALESCE(ss.deduct_score, 0) AS deduct_score
FROM students s
LEFT JOIN score_stat ss ON ss.student_name = s.name
ORDER BY s.score DESC`
const createDefaultList = (): StudentListConfig => ({
id: makeId(),
name: "学生积分榜",
sql: getDefaultSql(),
viewMode: "card",
scoreDisplayMode: "total",
})
const createLeafForList = (listId: string): LayoutLeafNode => ({
@@ -205,6 +224,10 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
list?.viewMode === "list" || list?.viewMode === "card" || list?.viewMode === "grid"
? list.viewMode
: "card",
scoreDisplayMode:
list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split"
? list.scoreDisplayMode
: "total",
}))
.filter((list: StudentListConfig) => list.sql.trim())
: []
@@ -259,6 +282,8 @@ const resolveSqlTemplate = (sql: string) => {
.join(formatIso(at(-30)))
}
const trimSqlTailSemicolon = (sql: string) => sql.trimEnd().replace(/;+$/, "")
const parseNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string" && value.trim() !== "") {
@@ -283,10 +308,50 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
if (!name) return
const data = row as Record<string, unknown>
let hasAddScoreField =
"add_score" in data || "addScore" in data || "plus_score" in data || "plusScore" in data
let hasDeductScoreField =
"deduct_score" in data ||
"deductScore" in data ||
"minus_score" in data ||
"minusScore" in data
const addScoreRaw = data.add_score ?? data.addScore ?? data.plus_score ?? data.plusScore
const deductScoreRaw =
data.deduct_score ?? data.deductScore ?? data.minus_score ?? data.minusScore
const delta = parseNumber(data.delta ?? data.score_delta ?? data.scoreDelta)
let addScore =
addScoreRaw === null || addScoreRaw === undefined
? hasAddScoreField
? 0
: undefined
: parseNumber(addScoreRaw)
let deductScore =
deductScoreRaw === null || deductScoreRaw === undefined
? hasDeductScoreField
? 0
: undefined
: parseNumber(deductScoreRaw)
if (!hasAddScoreField && !hasDeductScoreField && delta !== undefined) {
if (delta >= 0) {
hasAddScoreField = true
addScore = delta
} else {
hasDeductScoreField = true
deductScore = Math.abs(delta)
}
}
cards.push({
key: `${name}-${index}`,
name,
score: parseNumber(data.score),
addScore,
deductScore,
hasAddScoreField,
hasDeductScoreField,
rewardPoints: parseNumber(data.reward_points ?? data.rewardPoints),
weekChange: parseNumber(data.week_change ?? data.range_change ?? data.change),
weekDeducted: parseNumber(data.week_deducted ?? data.deducted),
@@ -538,6 +603,7 @@ ORDER BY reward_points DESC, score DESC`,
name: item.name,
sql: item.sql,
viewMode: item.viewMode,
scoreDisplayMode: item.scoreDisplayMode,
}))
)
}, [activeBoard])
@@ -670,6 +736,7 @@ ORDER BY reward_points DESC, score DESC`,
name: t("board.newList"),
sql: getDefaultSql(),
viewMode: "card",
scoreDisplayMode: "total",
}
const findLeaf = (node: LayoutNode): LayoutLeafNode | null => {
@@ -794,8 +861,29 @@ ORDER BY reward_points DESC, score DESC`,
const avatarColor = getAvatarColor(item.name)
const avatarText = getAvatarText(item.name)
const rankBadge = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : null
const primaryMetric =
item.score !== undefined
const useSplitScore = list.scoreDisplayMode === "split"
const hasSplitScore = Boolean(item.hasAddScoreField || item.hasDeductScoreField)
const primaryMetric = useSplitScore
? item.addScore !== undefined && item.addScore !== 0
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined && item.deductScore !== 0
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.addScore !== undefined
? { label: t("board.metrics.addScore"), value: item.addScore }
: item.deductScore !== undefined
? { label: t("board.metrics.deductScore"), value: item.deductScore }
: item.score !== undefined
? { label: t("board.metrics.totalScore"), value: item.score }
: item.rewardPoints !== undefined
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
: item.weekChange !== undefined
? { label: t("board.metrics.weekChange"), value: item.weekChange }
: item.weekDeducted !== undefined
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
: item.answeredCount !== undefined
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
: null
: item.score !== undefined
? { label: t("board.metrics.totalScore"), value: item.score }
: item.rewardPoints !== undefined
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
@@ -948,12 +1036,25 @@ ORDER BY reward_points DESC, score DESC`,
{item.name}
</div>
<div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
{item.score !== undefined && (
{(list.scoreDisplayMode === "total" ||
(list.scoreDisplayMode === "split" && !hasSplitScore)) &&
item.score !== undefined && (
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}>
{t("board.metrics.totalScore")}:{" "}
{item.score > 0 ? `+${item.score}` : item.score}
</Tag>
)}
{list.scoreDisplayMode === "split" && item.addScore !== undefined && (
<Tag color="success" style={{ margin: 0 }}>
{t("board.metrics.addScore")}:{" "}
{item.addScore > 0 ? `+${item.addScore}` : item.addScore}
</Tag>
)}
{list.scoreDisplayMode === "split" && item.deductScore !== undefined && (
<Tag color="error" style={{ margin: 0 }}>
{t("board.metrics.deductScore")}: {item.deductScore}
</Tag>
)}
{item.rewardPoints !== undefined && (
<Tag color="processing" style={{ margin: 0 }}>
{t("board.metrics.rewardPoints")}: {item.rewardPoints}
@@ -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}
/>
<Select
style={{ width: 180 }}
value={editingList.scoreDisplayMode}
onChange={(scoreDisplayMode: BoardScoreDisplayMode) =>
updateList(activeBoard.id, editingList.id, { scoreDisplayMode })
}
options={[
{ value: "total", label: t("board.scoreDisplayModes.total") },
{ value: "split", label: t("board.scoreDisplayModes.split") },
]}
disabled={!canManage}
/>
</Space>
<Input.TextArea
value={editingList.sql}
+146 -7
View File
@@ -1,9 +1,16 @@
import React, { Suspense, lazy, useEffect } from "react"
import { Layout, Space, Button, Tag, Spin } from "antd"
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"
import { Routes, Route, Navigate } from "react-router-dom"
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
LeftOutlined,
} from "@ant-design/icons"
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { WindowControls } from "./WindowControls"
import appLogo from "../assets/logoHD.svg"
const loadHome = () => import("./Home")
const loadStudentManager = () => import("./StudentManager")
@@ -54,10 +61,16 @@ interface ContentAreaProps {
onLogout: () => void
showWindowControls: boolean
isPortraitMode: boolean
isMobileDevice: boolean
sidebarCollapsed: boolean
floatingExpand: boolean
floatingExpanded: boolean
onToggleSidebar: () => void
immersiveMode: boolean
isHomePage: boolean
onToggleImmersiveMode: () => void
showSidebarToggle?: boolean
bottomInset?: number
}
export function ContentArea({
@@ -67,12 +80,48 @@ export function ContentArea({
onLogout,
showWindowControls,
isPortraitMode,
isMobileDevice,
sidebarCollapsed,
floatingExpand,
floatingExpanded,
onToggleSidebar,
immersiveMode,
isHomePage,
onToggleImmersiveMode,
showSidebarToggle = true,
bottomInset = 0,
}: ContentAreaProps): React.JSX.Element {
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
const shouldAnimateSubPage = isPortraitMode && isSubPage
const normalizedPath = location.pathname === "/" ? "/home" : location.pathname
const isMobileHeaderMode = isPortraitMode && isMobileDevice && !immersiveMode
const isPrimaryMobilePage =
normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings")
const showMobileBack = isMobileHeaderMode && !isPrimaryMobilePage
const mobilePageTitle = (() => {
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("/boards")) return t("sidebar.boards")
if (normalizedPath.startsWith("/leaderboard")) return t("sidebar.leaderboard")
if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements")
if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons")
if (normalizedPath.startsWith("/auto-score")) return t("sidebar.autoScore")
if (normalizedPath.startsWith("/reward-settings")) return t("sidebar.rewardSettings")
if (normalizedPath.startsWith("/settings")) return t("sidebar.settings")
return "SecScore"
})()
const handleMobileBack = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
navigate("/settings")
}
useEffect(() => {
let cancelled = false
@@ -144,6 +193,43 @@ export function ContentArea({
} as React.CSSProperties
}
>
{isMobileHeaderMode ? (
<div
style={{
display: "flex",
alignItems: "center",
minWidth: 0,
gap: "6px",
}}
>
{showMobileBack && (
<Button
type="text"
size="small"
onClick={handleMobileBack}
icon={<LeftOutlined />}
title={t("settlements.back")}
style={{ width: "32px", height: "32px" }}
/>
)}
<div
style={{
color: "var(--ss-text-main)",
fontSize: "14px",
fontWeight: 600,
userSelect: "none",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "40vw",
}}
>
{mobilePageTitle}
</div>
</div>
) : (
!immersiveMode &&
showSidebarToggle && (
<Button
type="text"
size="small"
@@ -164,6 +250,8 @@ export function ContentArea({
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
style={{ width: "32px", height: "32px" }}
/>
)
)}
</div>
<div
data-tauri-drag-region
@@ -171,10 +259,31 @@ export function ContentArea({
{
flex: 1,
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
paddingLeft: "8px",
WebkitAppRegion: "drag",
} as React.CSSProperties
}
/>
>
{immersiveMode && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
color: "var(--ss-text-main)",
fontSize: "14px",
fontWeight: 600,
userSelect: "none",
}}
>
<img src={appLogo} alt="SecScore" style={{ width: "18px", height: "18px" }} />
<span>SecScore</span>
</div>
)}
</div>
<div
style={
{
@@ -186,6 +295,15 @@ export function ContentArea({
}
>
<Space size="small">
{(immersiveMode || (isHomePage && !isMobileDevice)) && (
<Button
size="small"
type={immersiveMode ? "primary" : "default"}
icon={immersiveMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={onToggleImmersiveMode}
title={immersiveMode ? "退出沉浸模式" : "进入沉浸模式"}
/>
)}
{permissionTag}
{hasAnyPassword && (
<>
@@ -202,7 +320,14 @@ export function ContentArea({
{showWindowControls && <WindowControls />}
</div>
<Content style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
<Content
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
paddingBottom: bottomInset ? `${bottomInset}px` : 0,
}}
>
<Suspense
fallback={
<div
@@ -218,6 +343,10 @@ export function ContentArea({
<Spin size="large" />
</div>
}
>
<div
key={location.pathname}
className={`ss-route-page${shouldAnimateSubPage ? " is-subpage-enter" : ""}`}
>
<Routes>
<Route
@@ -226,13 +355,19 @@ export function ContentArea({
<Home
canEdit={permission === "admin" || permission === "points"}
isPortraitMode={isPortraitMode}
immersiveMode={immersiveMode}
/>
}
/>
<Route path="/students" element={<StudentManager canEdit={permission === "admin"} />} />
<Route
path="/students"
element={<StudentManager canEdit={permission === "admin"} />}
/>
<Route
path="/score"
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
element={
<ScoreManager canEdit={permission === "admin" || permission === "points"} />
}
/>
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
<Route path="/leaderboard" element={<Leaderboard />} />
@@ -243,9 +378,13 @@ export function ContentArea({
path="/reward-settings"
element={<RewardSettings canEdit={permission === "admin"} />}
/>
<Route path="/settings" element={<Settings permission={permission} />} />
<Route
path="/settings"
element={<Settings permission={permission} mobileNavigationEnabled={isPortraitMode} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</Suspense>
</Content>
</Layout>
+248 -11
View File
@@ -91,9 +91,14 @@ const T9_KEY_MAP: Record<string, string> = {
interface HomeProps {
canEdit: boolean
isPortraitMode?: boolean
immersiveMode?: boolean
}
export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) => {
export const Home: React.FC<HomeProps> = ({
canEdit,
isPortraitMode = false,
immersiveMode = false,
}) => {
const { t } = useTranslation()
const breakpoint = useResponsive()
const isMobile = breakpoint === "xs" || breakpoint === "sm"
@@ -108,10 +113,15 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
const [showPinyinKeyboard, setShowPinyinKeyboard] = useState(false)
const [searchKeyboardLayout, setSearchKeyboardLayout] = useState<SearchKeyboardLayout>("qwerty26")
const [disableSearchKeyboard, setDisableSearchKeyboard] = useState(false)
const canShowSearchKeyboard = !isMobile && !disableSearchKeyboard
const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
const searchAreaRef = useRef<HTMLDivElement>(null)
const immersiveToolbarRef = useRef<HTMLDivElement>(null)
const immersiveToolbarContentRef = useRef<HTMLDivElement>(null)
const [immersiveToolbarWidth, setImmersiveToolbarWidth] = useState<number | null>(null)
const immersiveToolbarHorizontalPadding = 20
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [batchMode, setBatchMode] = useState(false)
@@ -292,10 +302,10 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
}, [])
useEffect(() => {
if (disableSearchKeyboard && showPinyinKeyboard) {
if (!canShowSearchKeyboard && showPinyinKeyboard) {
setShowPinyinKeyboard(false)
}
}, [disableSearchKeyboard, showPinyinKeyboard])
}, [canShowSearchKeyboard, showPinyinKeyboard])
useEffect(() => {
const onDocumentClick = (e: MouseEvent) => {
@@ -315,6 +325,36 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
return () => document.removeEventListener("mousedown", onDocumentClick)
}, [])
useEffect(() => {
if (!immersiveMode) {
setImmersiveToolbarWidth(null)
return
}
const contentEl = immersiveToolbarContentRef.current
if (!contentEl) return
let frameId: number | null = null
const updateToolbarWidth = () => {
if (frameId !== null) cancelAnimationFrame(frameId)
frameId = requestAnimationFrame(() => {
const contentWidth = Math.ceil(contentEl.scrollWidth)
const nextWidth = contentWidth + immersiveToolbarHorizontalPadding
setImmersiveToolbarWidth((prev) => (prev === nextWidth ? prev : nextWidth))
})
}
updateToolbarWidth()
const observer = new ResizeObserver(updateToolbarWidth)
observer.observe(contentEl)
window.addEventListener("resize", updateToolbarWidth)
return () => {
observer.disconnect()
window.removeEventListener("resize", updateToolbarWidth)
if (frameId !== null) cancelAnimationFrame(frameId)
}
}, [immersiveMode, immersiveToolbarHorizontalPadding])
useEffect(() => {
return () => {
if (longPressTimerRef.current !== null) {
@@ -371,6 +411,10 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
setSearchKeyword((prev) => `${prev}${keyValue}`)
}
const getImmersivePopupContainer = useCallback((triggerNode: HTMLElement) => {
return immersiveToolbarRef.current ?? triggerNode.parentElement ?? document.body
}, [])
const getDisplayText = (name: string) => {
if (!name) return ""
return name.length > 2 ? name.substring(name.length - 2) : name
@@ -1507,6 +1551,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
const navContainerRef = useRef<HTMLDivElement>(null)
const isNavDragging = useRef(false)
const navHapticIndexRef = useRef<number | null>(null)
const bodyUserSelectRef = useRef("")
const bodyWebkitUserSelectRef = useRef("")
const [viewportHeight, setViewportHeight] = useState(() =>
@@ -1516,6 +1561,12 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
const [navIndicatorY, setNavIndicatorY] = useState(0)
const [isNavDraggingState, setIsNavDraggingState] = useState(false)
const triggerNavHaptic = useCallback(() => {
if (!isMobile) return
if (typeof navigator === "undefined" || typeof navigator.vibrate !== "function") return
navigator.vibrate(8)
}, [isMobile])
const quickNavLayout = useMemo(() => {
const baseItemSize = 24
const baseFontSize = 11
@@ -1585,6 +1636,10 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
const clampedY = Math.max(innerTop, Math.min(innerBottom - 0.001, y))
const index = Math.floor((clampedY - innerTop) / quickNavLayout.itemSize)
const safeIndex = Math.max(0, Math.min(itemCount - 1, index))
if (safeIndex !== navHapticIndexRef.current) {
navHapticIndexRef.current = safeIndex
triggerNavHaptic()
}
const targetGroup = groupedStudents[safeIndex]
if (targetGroup) {
@@ -1593,11 +1648,12 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
scrollToGroup(targetGroup.key)
}
},
[groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY]
[groupedStudents, quickNavLayout.itemSize, quickNavLayout.paddingY, triggerNavHaptic]
)
const onNavMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
navHapticIndexRef.current = null
setNavDraggingState(true)
handleNavAction(e.clientY)
document.addEventListener("mousemove", onGlobalMouseMove)
@@ -1618,6 +1674,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
}
const onNavTouchStart = (e: React.TouchEvent) => {
navHapticIndexRef.current = null
setNavDraggingState(true)
if (e.touches[0]) {
handleNavAction(e.touches[0].clientY)
@@ -1633,6 +1690,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
}
const onNavTouchEnd = () => {
navHapticIndexRef.current = null
setNavDraggingState(false)
}
@@ -2136,17 +2194,22 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
}, [])
const batchToolbar = rewardMode ? null : !batchMode ? (
<Button onClick={handleEnterBatchMode} disabled={!canEdit}>
<Button onClick={handleEnterBatchMode} disabled={!canEdit} style={{ borderRadius: "999px" }}>
{t("home.multiSelect")}
</Button>
) : (
<Space size={8} wrap>
<Button onClick={handleSelectAllStudents} disabled={!canEdit || students.length === 0}>
<Button
onClick={handleSelectAllStudents}
disabled={!canEdit || students.length === 0}
style={{ borderRadius: "999px" }}
>
{t("home.selectAll")}
</Button>
<Button
onClick={handleClearSelectedStudents}
disabled={!canEdit || selectedStudentIds.length === 0}
style={{ borderRadius: "999px" }}
>
{t("home.clearSelected")}
</Button>
@@ -2154,10 +2217,13 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
type="primary"
onClick={handleOpenBatchOperation}
disabled={!canEdit || selectedStudentIds.length === 0}
style={{ borderRadius: "999px" }}
>
{t("home.batchOperate")}
</Button>
<Button onClick={handleExitBatchMode}>{t("common.cancel")}</Button>
<Button onClick={handleExitBatchMode} style={{ borderRadius: "999px" }}>
{t("common.cancel")}
</Button>
</Space>
)
@@ -2165,6 +2231,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
<div
style={{
padding: isPortraitMode ? "16px" : "24px",
paddingBottom: immersiveMode ? (isPortraitMode ? "108px" : "124px") : undefined,
width: "100%",
boxSizing: "border-box",
maxWidth: isPortraitMode ? "100%" : "1200px",
@@ -2176,7 +2243,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
{contextHolder}
<div
style={{
marginBottom: "32px",
marginBottom: immersiveMode ? "8px" : "32px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
@@ -2184,6 +2251,8 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
flexWrap: "wrap",
}}
>
{!immersiveMode && (
<>
<div>
<h2 style={{ margin: 0, color: "var(--ss-text-main)", fontSize: "24px" }}>
{rewardMode ? t("rewardExchange.title") : t("home.title")}
@@ -2215,10 +2284,10 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onFocus={() => {
if (!disableSearchKeyboard) setShowPinyinKeyboard(true)
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
}}
onClick={() => {
if (!disableSearchKeyboard) setShowPinyinKeyboard(true)
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
}}
onKeyDown={(e) => {
if (e.key === "Escape") setShowPinyinKeyboard(false)
@@ -2228,7 +2297,7 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
allowClear
style={{ width: "100%" }}
/>
{!disableSearchKeyboard && showPinyinKeyboard && (
{canShowSearchKeyboard && showPinyinKeyboard && (
<div
style={{
position: "absolute",
@@ -2356,6 +2425,8 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
</Button>
{batchToolbar}
</Space>
</>
)}
</div>
{renderQuickNav()}
@@ -2449,6 +2520,172 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
)}
</Modal>
<div
ref={immersiveToolbarRef}
data-immersive-toolbar="true"
className={`ss-immersive-toolbar ${immersiveMode ? "is-visible" : "is-hidden"}`}
aria-hidden={!immersiveMode}
style={{
position: "fixed",
left: "50%",
bottom: isPortraitMode ? "12px" : "16px",
zIndex: 1100,
width: immersiveToolbarWidth ? `${immersiveToolbarWidth}px` : "max-content",
maxWidth: "calc(100vw - 20px)",
borderRadius: "999px",
border: "1px solid color-mix(in srgb, var(--ss-border-color) 80%, transparent)",
backgroundColor: "var(--ss-card-bg)",
background: "color-mix(in srgb, var(--ss-card-bg) 62%, transparent)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
boxShadow: "0 8px 30px rgba(0, 0, 0, 0.16)",
padding: "10px",
overflow: "visible",
}}
>
<div
ref={(node) => {
searchAreaRef.current = node
immersiveToolbarContentRef.current = node
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
overflowX: "visible",
justifyContent: "flex-start",
}}
>
<Input
value={searchKeyword}
onChange={(e) => 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={<SearchOutlined />}
allowClear
style={{ width: isPortraitMode ? "170px" : "220px", borderRadius: "999px", flexShrink: 0 }}
/>
<Select
value={sortType}
onChange={(v) => setSortType(v as SortType)}
getPopupContainer={getImmersivePopupContainer}
style={{ width: 126, flexShrink: 0 }}
options={[
{ value: "alphabet", label: t("home.sortBy.alphabet") },
{ value: "surname", label: t("home.sortBy.surname") },
{ value: "group", label: t("home.sortBy.group") },
{ value: "score", label: t("home.sortBy.score") },
]}
/>
<Select
value={layoutType}
onChange={(v) => 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") },
]}
/>
<Button
icon={<UndoOutlined />}
onClick={handleUndoLastEvent}
loading={undoLoading}
disabled={!canEdit || !latestEvent || rewardMode}
title={
latestEvent
? t("home.undoLastHint", {
name: latestEvent.student_name,
delta: latestEvent.delta > 0 ? `+${latestEvent.delta}` : latestEvent.delta,
})
: t("home.undoUnavailable")
}
style={{ borderRadius: "999px", flexShrink: 0 }}
>
{t("home.undoLastAction")}
</Button>
<Button
type={rewardMode ? "default" : "primary"}
onClick={handleToggleRewardMode}
disabled={!canEdit}
style={{ borderRadius: "999px", flexShrink: 0 }}
>
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
</Button>
<div style={{ flexShrink: 0 }}>{batchToolbar}</div>
{canShowSearchKeyboard && showPinyinKeyboard && (
<div
style={{
position: "absolute",
bottom: "calc(100% + 8px)",
left: isPortraitMode ? "8px" : "12px",
width: searchKeyboardLayout === "qwerty26" ? "288px" : "220px",
padding: "8px",
borderRadius: "10px",
border: "1px solid var(--ss-border-color)",
backgroundColor: "var(--ss-card-bg)",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
zIndex: 20,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{searchKeyboardLayout === "qwerty26"
? qwertyKeyRows.map((row, rowIndex) => (
<div
key={`qwerty-immersive-row-${rowIndex}`}
style={{
display: "grid",
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
gap: "7px",
}}
>
{row.map((keyItem) => (
<Button
key={keyItem}
size="small"
onClick={() => handleSearchKeyPress(keyItem)}
style={{ height: "32px", fontSize: "12px", padding: 0 }}
>
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
</Button>
))}
</div>
))
: t9KeyRows.map((row, rowIndex) => (
<div
key={`pinyin-immersive-row-${rowIndex}`}
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "6px",
}}
>
{row.map((keyItem) => (
<Button
key={keyItem.digit}
size="small"
onClick={() => handleSearchKeyPress(keyItem.digit)}
style={{ height: "28px", fontSize: "11px", padding: 0 }}
>
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
</Button>
))}
</div>
))}
</div>
</div>
)}
</div>
</div>
{isPortraitMode ? (
<Drawer
title={
+113 -2
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { RightOutlined } from "@ant-design/icons"
import {
Tabs,
Card,
@@ -17,6 +18,12 @@ import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { useTranslation } from "react-i18next"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
import { useResponsive } from "../hooks/useResponsive"
import { useLocation, useNavigate } from "react-router-dom"
import {
MOBILE_NAV_ITEMS,
MobileNavKey,
sanitizeMobileNavKeys,
} from "../shared/mobileNavigation"
type permissionLevel = "admin" | "points" | "view"
type appSettings = {
@@ -26,8 +33,11 @@ type appSettings = {
search_keyboard_layout?: "t9" | "qwerty26"
disable_search_keyboard?: boolean
auto_score_enabled?: boolean
mobile_bottom_nav_items?: MobileNavKey[]
}
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
const withTimeout = async (
promise: Promise<any>,
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,6 +685,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
<Divider />
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
{!isMobile && (
<>
<Form.Item label={t("settings.searchKeyboard.title")}>
<Select
value={settings.search_keyboard_layout || "qwerty26"}
@@ -681,6 +739,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
{t("settings.searchKeyboard.disableHint")}
</div>
</Form.Item>
</>
)}
<Form.Item label={t("settings.interfaceZoom")}>
<Select
@@ -715,6 +775,28 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
{t("settings.zoomHint")}
</div>
</Form.Item>
{mobileNavigationEnabled && (
<Form.Item label={t("settings.mobile.bottomNav.label")}>
<Select
mode="multiple"
value={settings.mobile_bottom_nav_items || DEFAULT_MOBILE_BOTTOM_NAV_ITEMS}
onChange={(v) => 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,
}))}
/>
<div
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
>
{t("settings.mobile.bottomNav.hint")}
</div>
</Form.Item>
)}
</Form>
</Card>
),
@@ -1277,6 +1359,35 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
{permissionTag}
</div>
{mobileNavigationEnabled && isMobile && (
<Card
title={t("settings.mobile.navigationTitle", "页面入口")}
bodyStyle={{ padding: 0 }}
style={{
marginBottom: "16px",
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
}}
>
<div className="ss-settings-mobile-nav-list">
{mobilePageItems.map((item) => (
<Button
key={item.key}
disabled={item.disabled}
type="text"
className={`ss-settings-mobile-nav-item${
location.pathname.startsWith(item.path) ? " is-active" : ""
}`}
onClick={() => navigate(item.path)}
>
<span className="ss-settings-mobile-nav-item-label">{item.label}</span>
<RightOutlined className="ss-settings-mobile-nav-item-arrow" />
</Button>
))}
</div>
</Card>
)}
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Modal
+149
View File
@@ -0,0 +1,149 @@
import type { Config, JsonGroup } from "@react-awesome-query-builder/antd"
import { Utils as QbUtils, AntdConfig } from "@react-awesome-query-builder/antd"
const InitialConfig = AntdConfig
export interface AutoScoreRuleConfig {
id: string
name: string
description: string
type: "interval_time" | "student_tag"
config: Config
defaultQuery: JsonGroup
}
export const intervalTimeConfig: Config = {
...InitialConfig,
ctx: InitialConfig.ctx,
fields: {
interval_minutes: {
label: "间隔时间(分钟)",
type: "number",
fieldSettings: {
min: 1,
max: 1440,
},
valueSources: ["value"],
preferWidgets: ["number"],
},
score_value: {
label: "加分数值",
type: "number",
fieldSettings: {
min: -100,
max: 100,
},
valueSources: ["value"],
preferWidgets: ["number"],
},
},
}
export const studentTagConfig: Config = {
...InitialConfig,
ctx: InitialConfig.ctx,
fields: {
tag_name: {
label: "学生标签",
type: "select",
valueSources: ["value"],
fieldSettings: {
listValues: [
{ value: "优秀", title: "优秀" },
{ value: "进步", title: "进步" },
{ value: "活跃", title: "活跃" },
{ value: "负责", title: "负责" },
{ value: "需关注", title: "需关注" },
],
},
},
score_value: {
label: "加分数值",
type: "number",
fieldSettings: {
min: -100,
max: 100,
},
valueSources: ["value"],
preferWidgets: ["number"],
},
},
}
export const defaultIntervalTimeQuery: JsonGroup = {
id: QbUtils.uuid(),
type: "group",
children1: [
{
id: QbUtils.uuid(),
type: "rule",
properties: {
field: "interval_minutes",
operator: "greater",
value: [30],
valueSrc: ["value"],
valueType: ["number"],
},
},
],
}
export const defaultStudentTagQuery: JsonGroup = {
id: QbUtils.uuid(),
type: "group",
children1: [
{
id: QbUtils.uuid(),
type: "rule",
properties: {
field: "tag_name",
operator: "select_equals",
value: ["优秀"],
valueSrc: ["value"],
valueType: ["select"],
},
},
],
}
export const ruleConfigs: AutoScoreRuleConfig[] = [
{
id: "interval_time_rule",
name: "间隔时间加分",
description: "根据学生上次加分后的间隔时间自动加分",
type: "interval_time",
config: intervalTimeConfig,
defaultQuery: defaultIntervalTimeQuery,
},
{
id: "student_tag_rule",
name: "学生标签加分",
description: "根据学生拥有的标签自动加分",
type: "student_tag",
config: studentTagConfig,
defaultQuery: defaultStudentTagQuery,
},
]
export const getRuleConfig = (
type: "interval_time" | "student_tag"
): AutoScoreRuleConfig | undefined => {
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 "{}"
}
}
+204
View File
@@ -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<RuleProps> = ({ onRuleSubmit }) => {
const { t } = useTranslation()
const [messageApi, contextHolder] = message.useMessage()
const [form] = Form.useForm()
const [activeTab, setActiveTab] = useState<string>("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) => (
<div className="query-builder-container" style={{ padding: "10px" }}>
<div className="query-builder qb-lite">
<Builder {...props} />
</div>
</div>
),
[]
)
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: (
<div>
<p style={{ marginBottom: 16, color: "var(--ss-text-secondary)" }}>
{ruleConfigs[0].description}
</p>
<Query
{...intervalTimeState.config}
value={intervalTimeState.tree}
onChange={handleIntervalTimeChange}
renderBuilder={renderBuilder}
/>
</div>
),
},
{
key: "student_tag",
label: t("autoScore.studentTagRule"),
children: (
<div>
<p style={{ marginBottom: 16, color: "var(--ss-text-secondary)" }}>
{ruleConfigs[1].description}
</p>
<Query
{...studentTagState.config}
value={studentTagState.tree}
onChange={handleStudentTagChange}
renderBuilder={renderBuilder}
/>
</div>
),
},
]
return (
<div>
{contextHolder}
<Card style={{ marginBottom: 24, backgroundColor: "var(--ss-card-bg)" }}>
<Form form={form} layout="vertical">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
<Form.Item
label={t("autoScore.ruleName")}
name="name"
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
>
<Input placeholder={t("autoScore.ruleNamePlaceholder")} />
</Form.Item>
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
<Select
mode="multiple"
showSearch
placeholder={t("autoScore.studentPlaceholder")}
options={students.map((student) => ({ label: student.name, value: student.name }))}
/>
</Form.Item>
</div>
</Form>
</Card>
<Card style={{ marginBottom: 24, backgroundColor: "var(--ss-card-bg)" }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Card>
<Space>
<Button type="primary" onClick={handleSubmit}>
{t("autoScore.saveRule")}
</Button>
<Button onClick={handleReset}>{t("autoScore.resetForm")}</Button>
</Space>
</div>
)
}
export default Rule
@@ -1,107 +0,0 @@
import { Space, Input, InputNumber, Select, Button } from "antd"
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
import { useTranslation } from "react-i18next"
const SCORE_RANGE = { MIN: -999, MAX: 999 }
const ACTION_DEFINITIONS = [
{ eventName: "add_score", labelKey: "autoScore.actionAddScore" },
{ eventName: "add_tag", labelKey: "autoScore.actionAddTag" },
]
interface ActionItem {
id: number
eventName: string
value?: string
reason?: string
}
interface ActionComponentProps {
actions: ActionItem[]
onAdd: () => void
onRemove: (id: number) => void
onChange: (id: number, field: keyof ActionItem, value: string | number) => void
}
export const ActionComponent: React.FC<ActionComponentProps> = ({
actions,
onAdd,
onRemove,
onChange,
}) => {
const { t } = useTranslation()
const handleActionChange = (id: number, field: keyof ActionItem, value: string | number) => {
onChange(id, field, value)
}
return (
<div>
<Space direction="vertical" style={{ width: "100%" }}>
{actions.map((action) => (
<div
key={action.id}
style={{
padding: "12px",
border: "1px solid #d9d9d9",
borderRadius: "6px",
backgroundColor: "var(--ss-card-bg)",
}}
>
<Space style={{ width: "100%" }} wrap>
<Select
value={action.eventName}
onChange={(value) => handleActionChange(action.id, "eventName", value)}
style={{ width: 150 }}
options={ACTION_DEFINITIONS.map((d) => ({
label: t(d.labelKey),
value: d.eventName,
}))}
/>
{action.eventName === "add_score" && (
<InputNumber
value={action.value ? parseInt(action.value) : undefined}
onChange={(value) => 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" && (
<Input
value={action.value}
onChange={(e) => handleActionChange(action.id, "value", e.target.value)}
placeholder={t("autoScore.tagNamePlaceholder")}
style={{ width: 200 }}
/>
)}
<Input
value={action.reason}
onChange={(e) => handleActionChange(action.id, "reason", e.target.value)}
placeholder={t("autoScore.reasonPlaceholder")}
style={{ flex: 1, minWidth: 200 }}
/>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => onRemove(action.id)}
/>
</Space>
</div>
))}
<Button type="dashed" icon={<PlusOutlined />} onClick={onAdd} block>
{t("autoScore.addAction")}
</Button>
</Space>
</div>
)
}
export default ActionComponent
@@ -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;
}
@@ -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,
}
}
@@ -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<RuleComponentProps> = ({ initialData, onChange }) => {
const { t } = useTranslation()
const [query, setQuery] = useState<RuleGroupType>(
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 (
<div>
<Card
title={t("autoScore.triggerCondition")}
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
>
<QueryBuilderDnD dnd={{ ...ReactDnD, ...ReactDndHtml5Backend, ...ReactDndTouchBackend }}>
<QueryBuilderAntD>
<QueryBuilder fields={getFields(t)} query={query} onQueryChange={handleQueryChange} />
</QueryBuilderAntD>
</QueryBuilderDnD>
<div style={{ marginTop: "8px" }}>
<pre style={{ fontSize: "12px", color: "#999" }}>{formatQuery(query, "json")}</pre>
</div>
</Card>
</div>
)
}
export default RuleComponent
+15 -1
View File
@@ -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",
+17 -3
View File
@@ -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天扣分",
+2
View File
@@ -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 = {
+55
View File
@@ -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<string>(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
}
+9 -1
View File
@@ -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,
})