mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
@@ -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,10 +2,11 @@
|
||||
|
||||
## 代码规范
|
||||
- 所有代码必须符合 Rust 语言规范。
|
||||
- 所有代码跑`pnpm run typecheck` or `cargo typecheck`必须通过。
|
||||
- 所有代码跑`pnpm run typecheck` or `cargo typecheck` 必须通过。
|
||||
|
||||
## 提交与推送要求
|
||||
- 每次代码修改后,必须进行一次 Git 提交(commit)并推送(git push)。
|
||||
- 每次代码修改后,除非用户说明进行一次 Git 提交(commit)并推送(git push)否则不进行。
|
||||
- 用户只说 `cp` 时,表示执行一次 Git 提交(commit)并推送(git push)。
|
||||
- 提交信息必须使用中文。
|
||||
- 提交信息必须遵循 Conventional Commits 规范,格式示例:`feat: 新增登录页表单校验`、`fix: 修复导出报告空指针问题`。
|
||||
- 每次提交完成后,必须将对应提交推送到远端仓库(`git push`)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+18
-122
@@ -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
|
||||
|
||||
@@ -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`?
|
||||
|
||||
用户需求:
|
||||
{ {在这里粘贴用户需求} }
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
}
|
||||
|
||||
+202
-114
@@ -828,139 +828,227 @@ async fn db_sync_apply_internal(
|
||||
};
|
||||
|
||||
let local_students = load_students(&local_conn).await?;
|
||||
let remote_students = load_students(&remote_conn).await?;
|
||||
let local_reasons = load_reasons(&local_conn).await?;
|
||||
let remote_reasons = load_reasons(&remote_conn).await?;
|
||||
let local_tags = load_tags(&local_conn).await?;
|
||||
let remote_tags = load_tags(&remote_conn).await?;
|
||||
let local_events = load_events(&local_conn).await?;
|
||||
let remote_events = load_events(&remote_conn).await?;
|
||||
let local_reward_settings = load_reward_settings(&local_conn).await?;
|
||||
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
|
||||
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
|
||||
let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?;
|
||||
let local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
|
||||
|
||||
let (preferred, target) = if strategy == ConflictStrategy::KeepLocal {
|
||||
(&local_conn, &remote_conn)
|
||||
} else {
|
||||
(&remote_conn, &local_conn)
|
||||
};
|
||||
|
||||
let mut synced_records = 0usize;
|
||||
let mut resolved_conflicts = 0usize;
|
||||
|
||||
for student in local_students.values() {
|
||||
if upsert_student(&remote_conn, student).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_students_after = load_students(&remote_conn).await?;
|
||||
for student in remote_students_after.values() {
|
||||
if upsert_student(&local_conn, student).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for reason in local_reasons.values() {
|
||||
if upsert_reason(&remote_conn, reason).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_reasons_after = load_reasons(&remote_conn).await?;
|
||||
for reason in remote_reasons_after.values() {
|
||||
if upsert_reason(&local_conn, reason).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for tag in local_tags.values() {
|
||||
if upsert_tag(&remote_conn, tag).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_tags_after = load_tags(&remote_conn).await?;
|
||||
for tag in remote_tags_after.values() {
|
||||
if upsert_tag(&local_conn, tag).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for event in local_events.values() {
|
||||
if upsert_event(&remote_conn, event).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_events_after = load_events(&remote_conn).await?;
|
||||
for event in remote_events_after.values() {
|
||||
if upsert_event(&local_conn, event).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for reward in local_reward_settings.values() {
|
||||
if upsert_reward_setting(&remote_conn, reward).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_reward_settings_after = load_reward_settings(&remote_conn).await?;
|
||||
for reward in remote_reward_settings_after.values() {
|
||||
if upsert_reward_setting(&local_conn, reward).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
for redemption in local_reward_redemptions.values() {
|
||||
if upsert_reward_redemption(&remote_conn, redemption).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?;
|
||||
for redemption in remote_reward_redemptions_after.values() {
|
||||
if upsert_reward_redemption(&local_conn, redemption).await? {
|
||||
synced_records += 1;
|
||||
// Deterministic per-key merge:
|
||||
// 1) local_only => remote
|
||||
// 2) remote_only => local
|
||||
// 3) conflict => resolve by strategy (winner -> loser)
|
||||
let student_keys: std::collections::HashSet<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;
|
||||
}
|
||||
}
|
||||
(None, Some(remote)) => {
|
||||
if upsert_student(&local_conn, remote).await? {
|
||||
synced_records += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for pair in local_pairs.union(&remote_pairs) {
|
||||
if ensure_student_tag_pair(&local_conn, pair).await? {
|
||||
synced_records += 1;
|
||||
let reason_keys: std::collections::HashSet<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+266
-13
@@ -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,16 +468,27 @@ function MainContent(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden" }}>
|
||||
<Sidebar
|
||||
activeMenu={activeMenu}
|
||||
permission={permission}
|
||||
onMenuChange={onMenuChange}
|
||||
collapsed={sidebarCollapsed}
|
||||
floatingExpand={isPortraitMode}
|
||||
floatingExpanded={floatingSidebarExpanded}
|
||||
onFloatingExpandedChange={setFloatingSidebarExpanded}
|
||||
/>
|
||||
<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}
|
||||
onMenuChange={onMenuChange}
|
||||
collapsed={sidebarCollapsed}
|
||||
floatingExpand={isPortraitMode}
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,11 +1036,24 @@ 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 }}>
|
||||
@@ -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}
|
||||
|
||||
+190
-51
@@ -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,26 +193,65 @@ export function ContentArea({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onToggleSidebar}
|
||||
icon={
|
||||
floatingExpand && sidebarCollapsed ? (
|
||||
floatingExpanded ? (
|
||||
<MenuFoldOutlined />
|
||||
) : (
|
||||
<MenuUnfoldOutlined />
|
||||
)
|
||||
) : sidebarCollapsed ? (
|
||||
<MenuUnfoldOutlined />
|
||||
) : (
|
||||
<MenuFoldOutlined />
|
||||
)
|
||||
}
|
||||
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
|
||||
style={{ width: "32px", height: "32px" }}
|
||||
/>
|
||||
{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"
|
||||
onClick={onToggleSidebar}
|
||||
icon={
|
||||
floatingExpand && sidebarCollapsed ? (
|
||||
floatingExpanded ? (
|
||||
<MenuFoldOutlined />
|
||||
) : (
|
||||
<MenuUnfoldOutlined />
|
||||
)
|
||||
) : sidebarCollapsed ? (
|
||||
<MenuUnfoldOutlined />
|
||||
) : (
|
||||
<MenuFoldOutlined />
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -219,33 +344,47 @@ export function ContentArea({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
canEdit={permission === "admin" || permission === "points"}
|
||||
isPortraitMode={isPortraitMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/students" element={<StudentManager canEdit={permission === "admin"} />} />
|
||||
<Route
|
||||
path="/score"
|
||||
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/auto-score" element={<AutoScoreManager />} />
|
||||
<Route
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<div
|
||||
key={location.pathname}
|
||||
className={`ss-route-page${shouldAnimateSubPage ? " is-subpage-enter" : ""}`}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
canEdit={permission === "admin" || permission === "points"}
|
||||
isPortraitMode={isPortraitMode}
|
||||
immersiveMode={immersiveMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/students"
|
||||
element={<StudentManager canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route
|
||||
path="/score"
|
||||
element={
|
||||
<ScoreManager canEdit={permission === "admin" || permission === "points"} />
|
||||
}
|
||||
/>
|
||||
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/auto-score" element={<AutoScoreManager />} />
|
||||
<Route
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Settings permission={permission} mobileNavigationEnabled={isPortraitMode} />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Suspense>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
+406
-169
@@ -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,178 +2251,182 @@ export const Home: React.FC<HomeProps> = ({ canEdit, isPortraitMode = false }) =
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)", fontSize: "24px" }}>
|
||||
{rewardMode ? t("rewardExchange.title") : t("home.title")}
|
||||
</h2>
|
||||
<p style={{ margin: "4px 0 0", color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("home.subtitle", { count: students.length })}
|
||||
</p>
|
||||
</div>
|
||||
{!immersiveMode && (
|
||||
<>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)", fontSize: "24px" }}>
|
||||
{rewardMode ? t("rewardExchange.title") : t("home.title")}
|
||||
</h2>
|
||||
<p style={{ margin: "4px 0 0", color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("home.subtitle", { count: students.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Space
|
||||
size="middle"
|
||||
align="start"
|
||||
wrap
|
||||
style={{
|
||||
width: isPortraitMode ? "100%" : undefined,
|
||||
justifyContent: isPortraitMode ? "flex-start" : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={searchAreaRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: isMobile ? "100%" : isTablet ? "180px" : "220px",
|
||||
minWidth: isMobile ? "100%" : isTablet ? "150px" : "180px",
|
||||
flexShrink: isMobile ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (!disableSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
<Space
|
||||
size="middle"
|
||||
align="start"
|
||||
wrap
|
||||
style={{
|
||||
width: isPortraitMode ? "100%" : undefined,
|
||||
justifyContent: isPortraitMode ? "flex-start" : undefined,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disableSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowPinyinKeyboard(false)
|
||||
}}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
{!disableSearchKeyboard && showPinyinKeyboard && (
|
||||
>
|
||||
<div
|
||||
ref={searchAreaRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "0" : "50%",
|
||||
transform: isPortraitMode ? "none" : "translateX(-50%)",
|
||||
width: isPortraitMode
|
||||
? `min(calc(100vw - 32px), ${searchKeyboardLayout === "qwerty26" ? "288px" : "220px"})`
|
||||
: 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,
|
||||
position: "relative",
|
||||
width: isMobile ? "100%" : isTablet ? "180px" : "220px",
|
||||
minWidth: isMobile ? "100%" : isTablet ? "150px" : "180px",
|
||||
flexShrink: isMobile ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{searchKeyboardLayout === "qwerty26"
|
||||
? qwertyKeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`qwerty-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 }}
|
||||
<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: "100%" }}
|
||||
/>
|
||||
{canShowSearchKeyboard && showPinyinKeyboard && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "0" : "50%",
|
||||
transform: isPortraitMode ? "none" : "translateX(-50%)",
|
||||
width: isPortraitMode
|
||||
? `min(calc(100vw - 32px), ${searchKeyboardLayout === "qwerty26" ? "288px" : "220px"})`
|
||||
: 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-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
|
||||
gap: "7px",
|
||||
}}
|
||||
>
|
||||
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
: t9KeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`pinyin-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 }}
|
||||
{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-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
style={{
|
||||
width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px",
|
||||
minWidth: isMobile ? "100px" : "120px",
|
||||
flexShrink: isMobile ? 1 : 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)}
|
||||
style={{
|
||||
width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px",
|
||||
minWidth: isMobile ? "100px" : "120px",
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<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={{
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
{batchToolbar}
|
||||
</Space>
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
style={{
|
||||
width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px",
|
||||
minWidth: isMobile ? "100px" : "120px",
|
||||
flexShrink: isMobile ? 1 : 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)}
|
||||
style={{
|
||||
width: isMobile ? "calc(50% - 8px)" : isTablet ? "130px" : "140px",
|
||||
minWidth: isMobile ? "100px" : "120px",
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<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={{
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{
|
||||
flexShrink: isMobile ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</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={
|
||||
|
||||
+164
-53
@@ -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,58 +685,62 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
<Divider />
|
||||
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label={t("settings.searchKeyboard.title")}>
|
||||
<Select
|
||||
value={settings.search_keyboard_layout || "qwerty26"}
|
||||
onChange={async (v) => {
|
||||
if (!(window as any).api) return
|
||||
const next = String(v) as "t9" | "qwerty26"
|
||||
const res = await (window as any).api.setSetting("search_keyboard_layout", next)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, search_keyboard_layout: next }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
} else {
|
||||
messageApi.error(res.message || t("settings.general.saveFailed"))
|
||||
}
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
disabled={!canAdmin}
|
||||
options={[
|
||||
{ value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") },
|
||||
{ value: "t9", label: t("settings.searchKeyboard.options.t9") },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.searchKeyboard.hint")}
|
||||
</div>
|
||||
</Form.Item>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Form.Item label={t("settings.searchKeyboard.title")}>
|
||||
<Select
|
||||
value={settings.search_keyboard_layout || "qwerty26"}
|
||||
onChange={async (v) => {
|
||||
if (!(window as any).api) return
|
||||
const next = String(v) as "t9" | "qwerty26"
|
||||
const res = await (window as any).api.setSetting("search_keyboard_layout", next)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, search_keyboard_layout: next }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
} else {
|
||||
messageApi.error(res.message || t("settings.general.saveFailed"))
|
||||
}
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
disabled={!canAdmin}
|
||||
options={[
|
||||
{ value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") },
|
||||
{ value: "t9", label: t("settings.searchKeyboard.options.t9") },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.searchKeyboard.hint")}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("settings.searchKeyboard.disableToggle")}>
|
||||
<Switch
|
||||
checked={Boolean(settings.disable_search_keyboard)}
|
||||
onChange={async (checked) => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.setSetting(
|
||||
"disable_search_keyboard",
|
||||
checked
|
||||
)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, disable_search_keyboard: checked }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
} else {
|
||||
messageApi.error(res.message || t("settings.general.saveFailed"))
|
||||
}
|
||||
}}
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("settings.searchKeyboard.disableHint")}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("settings.searchKeyboard.disableToggle")}>
|
||||
<Switch
|
||||
checked={Boolean(settings.disable_search_keyboard)}
|
||||
onChange={async (checked) => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.setSetting(
|
||||
"disable_search_keyboard",
|
||||
checked
|
||||
)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, disable_search_keyboard: checked }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
} else {
|
||||
messageApi.error(res.message || t("settings.general.saveFailed"))
|
||||
}
|
||||
}}
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{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
|
||||
|
||||
@@ -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 "{}"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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天扣分",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user