This commit is contained in:
NanGua-QWQ
2026-03-30 22:01:51 +08:00
25 changed files with 6044 additions and 198 deletions
+19
View File
@@ -72,6 +72,13 @@ jobs:
- name: 应用版本号
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
- name: 设置 OAuth 环境变量
run: |
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $env:GITHUB_ENV
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $env:GITHUB_ENV
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $env:GITHUB_ENV
shell: pwsh
- name: 安装依赖
run: pnpm install --frozen-lockfile
@@ -108,6 +115,12 @@ jobs:
- name: 应用版本号
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
- name: 设置 OAuth 环境变量
run: |
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $GITHUB_ENV
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $GITHUB_ENV
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $GITHUB_ENV
- name: 安装依赖
run: pnpm install --frozen-lockfile
@@ -149,6 +162,12 @@ jobs:
- name: 应用版本号
run: node scripts/ci/apply-version.mjs ${{ needs.parse.outputs.version }}
- name: 设置 OAuth 环境变量
run: |
echo "VITE_OAUTH_PLATFORM_ID=${{ secrets.OAUTH_PLATFORM_ID }}" >> $GITHUB_ENV
echo "VITE_OAUTH_PLATFORM_SECRET=${{ secrets.OAUTH_PLATFORM_SECRET }}" >> $GITHUB_ENV
echo "VITE_OAUTH_CALLBACK_URL=secscore://oauth/callback" >> $GITHUB_ENV
- name: 安装依赖
run: pnpm install --frozen-lockfile
+2
View File
@@ -1,10 +1,12 @@
# AGENTS 协作规范
## 代码规范
- 所有代码必须符合 Rust 语言规范。
- 所有代码跑`pnpm run typecheck` or `cargo typecheck` 必须通过。
## 提交与推送要求
- 每次代码修改后,除非用户说明进行一次 Git 提交(commit)并推送(git push)否则不进行。
- 用户只说 `cp` 时,表示执行一次 Git 提交(commit)并推送(git push)。
- 提交信息必须使用中文。
+2
View File
@@ -20,7 +20,9 @@
"@ant-design/icons": "^6.1.0",
"@react-awesome-query-builder/antd": "6.7.0-alpha.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.3.5",
"antd": "^6.3.1",
"appwrite": "^24.0.0",
"dayjs": "^1.11.20",
"i18next": "^25.8.14",
"json-rules-engine": "^7.3.1",
+32 -18
View File
@@ -17,9 +17,15 @@ importers:
'@tauri-apps/api':
specifier: ^2.5.0
version: 2.10.1
'@tauri-apps/plugin-shell':
specifier: ^2.3.5
version: 2.3.5
antd:
specifier: ^6.3.1
version: 6.3.2(moment@2.30.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
appwrite:
specifier: ^24.0.0
version: 24.0.0
dayjs:
specifier: ^1.11.20
version: 1.11.20
@@ -842,79 +848,66 @@ 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==}
@@ -978,35 +971,30 @@ 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==}
@@ -1031,6 +1019,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@ts-jison/common@0.4.1-alpha.1':
resolution: {integrity: sha512-SDbHzq+UMD+V3ciKVBHwCEgVqSeyQPTCjOsd/ZNTGySUVg4x3EauR9ZcEfdVFAsYRR38XWgDI+spq5LDY46KvQ==}
@@ -1172,6 +1163,9 @@ packages:
react: '>=18.0.0'
react-dom: '>=18.0.0'
appwrite@24.0.0:
resolution: {integrity: sha512-zo0ZMf+B5S+OgGLXMvzAFDNWU/RB3a38oO40o/Atjdk0itBj3Z6uk1cp4ckNWiwZVFoThFrjZWBw2Au+Flk7lw==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1223,6 +1217,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -1767,6 +1764,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -3310,6 +3310,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.10.1
'@ts-jison/common@0.4.1-alpha.1': {}
'@ts-jison/lexer@0.4.1-alpha.1':
@@ -3546,6 +3550,10 @@ snapshots:
- luxon
- moment
appwrite@24.0.0:
dependencies:
json-bigint: 1.0.0
argparse@2.0.1: {}
array-buffer-byte-length@1.0.2:
@@ -3617,6 +3625,8 @@ snapshots:
baseline-browser-mapping@2.10.8: {}
bignumber.js@9.3.1: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -4282,6 +4292,10 @@ snapshots:
jsesc@3.1.0: {}
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.3.1
json-buffer@3.0.1: {}
json-logic-js@2.0.5: {}
+33
View File
@@ -3,6 +3,7 @@
你是 SecScore 的“看板 SQL 生成器”。你的任务是根据“用户需求”生成一条可直接在看板执行的 SQL。
## 1) 强约束(必须全部满足)
1. 只允许输出**单条只读查询**,且必须以 `SELECT``WITH` 开头。
2. 禁止输出分号 `;`
3. 禁止输出注释:`--``/*``*/`
@@ -14,9 +15,11 @@
6. 只返回 SQL 纯文本,不要 markdown,不要解释。
## 2) 模板变量(仅允许以下 6 个)
说明:本提示词里占位符写成了 `{ {xxx} }`(中间有空格),这是为了兼容部分模型平台。你在理解时请把空格忽略,按标准占位符理解;你在最终输出 SQL 时,请使用无空格的标准写法。
只允许使用:
- `{ {now} }`
- `{ {today_start} }`
- `{ {this_week_start} }`
@@ -25,12 +28,15 @@
- `{ {since_30d} }`
并且:
1. 模板变量必须放在**单引号**中使用,例如 `event_time >= '{ {since_7d} }'`
2. 严禁发明任何未支持变量,例如:`{ {month_start} }``{ {last_month_start} }` 等。
3. 严禁输出任何带花括号但不在允许列表中的占位符。
## 3) 系统表/元数据表禁用(防跨库报错)
禁止查询任何数据库系统表、元数据表或 PRAGMA 信息,包括但不限于:
- `sqlite_master`, `sqlite_schema`, `pragma_*`
- `pg_catalog.*`, `pg_class`, `pg_tables`
- `information_schema.*`
@@ -38,34 +44,45 @@
只允许使用下面给定的业务表。
## 4) 可用业务表与字段
1. `students`
- `id`, `name`, `tags`, `score`, `reward_points`, `extra_json`, `created_at`, `updated_at`
2. `reasons`
- `id`, `content`, `category`, `delta`, `is_system`, `updated_at`
3. `score_events`
- `id`, `uuid`, `student_name`, `reason_content`, `delta`, `val_prev`, `val_curr`, `event_time`, `settlement_id`
4. `settlements`
- `id`, `start_time`, `end_time`, `created_at`
5. `settings`
- `key`, `value`
6. `tags`
- `id`, `name`, `created_at`, `updated_at`
7. `student_tags`
- `id`, `student_id`, `tag_id`, `created_at`
8. `reward_settings`
- `id`, `name`, `cost_points`, `created_at`, `updated_at`
9. `reward_redemptions`
- `id`, `uuid`, `student_name`, `reward_id`, `reward_name`, `cost_points`, `redeemed_at`
## 5) 字段名使用铁律(必须遵守,防止列不存在)
1. `SELECT``WHERE``GROUP BY``ORDER BY``JOIN ON` 中引用的字段名,必须来自上面的“可用业务表与字段”原样字段,严禁臆造字段。
2. “展示名/别名”不等于真实字段名。若需展示为 `student_name`,必须使用别名,不可把别名当字段直接查询。
3. 关键映射(高频易错):
@@ -76,64 +93,80 @@
5. 正确示例(优先生成):`SELECT name AS student_name, score FROM students`
## 6) 看板展示字段命名约定(尽量遵守)
- 学生名统一命名为:`student_name`
- 常见指标命名建议:`score`, `reward_points`, `week_change`, `week_deducted`, `answered_count`
## 7) 生成策略
1. 排行类需求必须有 `ORDER BY`
2. 聚合类需求必须有清晰别名。
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} }'`
- 本自然周:`event_time >= '{ {this_week_start} }'`
注意:
1. 模板变量必须带单引号。
2. 不要发明其它周边界变量。
3. 避免使用 SQLite/PostgreSQL 方言日期函数(如 `strftime``date_trunc`)以保证跨库兼容。
## 9) 输出前自检清单(必须全部为“是”)
- 是否只用了 `SELECT/WITH`
- 是否没有 `;` 和注释?
- 是否没有禁用关键词?
+98
View File
@@ -606,6 +606,26 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -728,6 +748,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -967,6 +993,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -2818,6 +2853,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -3850,6 +3895,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
@@ -4178,12 +4233,14 @@ dependencies = [
"sqlx",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-shell",
"tokio",
"tokio-util",
"tracing",
"tracing-appender",
"tracing-subscriber",
"urlencoding",
"uuid",
]
@@ -5215,6 +5272,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
@@ -5450,6 +5528,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -6440,6 +6527,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"
+2
View File
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -39,6 +40,7 @@ once_cell = "1"
parking_lot = { version = "0.12", features = ["send_guard"] }
dirs = "6.0.0"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
urlencoding = "2.1"
[profile.release]
panic = "abort"
Binary file not shown.
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for SecScore application","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-start-dragging","core:webview:default","core:app:default","core:tray:default","core:tray:allow-new","core:tray:allow-set-icon","core:tray:allow-set-menu","core:menu:default","shell:allow-open"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+137
View File
@@ -30,6 +30,30 @@ pub struct SetPasswordsResponse {
pub recovery_string: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
pub platform_id: String,
pub platform_secret: String,
pub callback_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokenResponse {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthUserInfo {
pub user_id: String,
pub email: String,
pub name: String,
pub github_username: Option<String>,
pub permission: u32,
}
fn get_iv_hex() -> String {
SecurityService::generate_iv_hex()
}
@@ -271,3 +295,116 @@ pub async fn auth_clear_all(
Err(e) => Ok(IpcResponse::error(&e)),
}
}
#[tauri::command]
pub async fn oauth_get_authorization_url(
platform_id: String,
callback_url: String,
) -> Result<IpcResponse<String>, String> {
let url = format!(
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code",
platform_id,
urlencoding::encode(&callback_url)
);
Ok(IpcResponse::success(url))
}
#[tauri::command]
pub async fn oauth_exchange_code(
code: String,
platform_id: String,
platform_secret: String,
callback_url: String,
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
let client = reqwest::Client::new();
let response = client
.post("https://sectl.top/api/oauth/token")
.json(&serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"client_id": platform_id,
"client_secret": platform_secret,
"redirect_uri": callback_url
}))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Ok(IpcResponse::error(&error_text));
}
let token_response: OAuthTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(token_response))
}
#[tauri::command]
pub async fn oauth_get_user_info(
access_token: String,
) -> Result<IpcResponse<OAuthUserInfo>, String> {
let client = reqwest::Client::new();
let response = client
.get("https://sectl.top/api/oauth/userinfo")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Ok(IpcResponse::error(&error_text));
}
let user_info: OAuthUserInfo = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(user_info))
}
#[tauri::command]
pub async fn oauth_refresh_token(
refresh_token: String,
platform_id: String,
platform_secret: String,
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
let client = reqwest::Client::new();
let response = client
.post("https://sectl.top/api/oauth/token")
.json(&serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": platform_id,
"client_secret": platform_secret
}))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Ok(IpcResponse::error(&error_text));
}
let token_response: OAuthTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(token_response))
}
+26
View File
@@ -12,6 +12,7 @@ use crate::services::settings::{SettingsKey, SettingsValue};
use crate::{commands::*, state::AppState};
use parking_lot::RwLock;
use std::sync::Arc;
use tauri::Emitter;
#[cfg(desktop)]
use tauri::{
image::Image,
@@ -26,6 +27,7 @@ use tokio::time::{timeout, Duration};
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
let state = AppState::new(app.handle().clone());
app.manage(Arc::new(RwLock::new(state)));
@@ -73,6 +75,10 @@ pub fn run() {
auth_generate_recovery,
auth_reset_by_recovery,
auth_clear_all,
oauth_get_authorization_url,
oauth_exchange_code,
oauth_get_user_info,
oauth_refresh_token,
theme_list,
theme_current,
theme_set,
@@ -136,6 +142,26 @@ pub fn setup_app(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
setup_window_events(app)?;
setup_deep_link(app)?;
Ok(())
}
fn setup_deep_link(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
let handle = app.handle().clone();
#[cfg(desktop)]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().on_open_url(move |event| {
let url = event.urls().first().map(|u| u.to_string()).unwrap_or_default();
if !url.is_empty() {
let _ = handle.emit("deep-link://new-url", url);
}
});
}
Ok(())
}
+8
View File
@@ -58,6 +58,14 @@
"plugins": {
"shell": {
"open": true
},
"deepLink": {
"domains": [
{
"host": "oauth",
"schemes": ["secscore"]
}
]
}
}
}
+61 -11
View File
@@ -1,4 +1,13 @@
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme, Drawer } from "antd"
import {
Layout,
Modal,
Input,
message,
ConfigProvider,
theme as antTheme,
Drawer,
Button,
} from "antd"
import {
HomeOutlined,
SettingOutlined,
@@ -20,15 +29,16 @@ import { useTranslation } from "react-i18next"
import { Sidebar } from "./components/Sidebar"
import { ContentArea } from "./components/ContentArea"
import { OOBE } from "./components/OOBE/OOBE"
import { OAuthLogin } from "./components/OAuthLogin"
import { OAuthCallback } from "./components/OAuthCallback"
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
import {
MOBILE_NAV_ITEMS,
MobileNavKey,
sanitizeMobileNavKeys,
} from "./shared/mobileNavigation"
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(0, 4)
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
0,
4
)
function MainContent(): React.JSX.Element {
const { t } = useTranslation()
@@ -88,6 +98,7 @@ function MainContent(): React.JSX.Element {
const [authVisible, setAuthVisible] = useState(false)
const [authPassword, setAuthPassword] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [oauthVisible, setOAuthVisible] = useState(false)
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
)
@@ -97,9 +108,10 @@ function MainContent(): React.JSX.Element {
const [editingMoreNavKeys, setEditingMoreNavKeys] = useState<MobileNavKey[]>([])
const [draggingNavKey, setDraggingNavKey] = useState<MobileNavKey | null>(null)
const [draggingFromList, setDraggingFromList] = useState<"bottom" | "more" | null>(null)
const [dragOverSlot, setDragOverSlot] = useState<{ list: "bottom" | "more"; index: number } | null>(
null
)
const [dragOverSlot, setDragOverSlot] = useState<{
list: "bottom" | "more"
index: number
} | null>(null)
const [isPortraitMode] = useState(defaultPortraitMode)
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
@@ -379,6 +391,24 @@ function MainContent(): React.JSX.Element {
}
}
const handleOAuthSuccess = (userInfo: {
user_id: string
email: string
name: string
github_username?: string
permission: number
}) => {
let newPermission: "admin" | "points" | "view" = "view"
if (userInfo.permission >= 18) {
newPermission = "admin"
} else if (userInfo.permission >= 1) {
newPermission = "points"
}
setPermission(newPermission)
messageApi.success(t("auth.oauthSuccess", "登录成功"))
}
const onMenuChange = (v: string) => {
const key = String(v)
setMoreNavVisible(false)
@@ -544,7 +574,9 @@ function MainContent(): React.JSX.Element {
}}
>
{contextHolder}
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}>
<Layout
style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}
>
{!isPortraitMode && (
<div
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
@@ -878,9 +910,26 @@ function MainContent(): React.JSX.Element {
placeholder={t("auth.passwordPlaceholder")}
maxLength={6}
/>
<div style={{ textAlign: "center", marginTop: "8px" }}>
<Button
type="link"
onClick={() => {
setAuthVisible(false)
setOAuthVisible(true)
}}
>
{t("auth.useOAuth", "使用 SECTL Auth 登录")}
</Button>
</div>
</div>
</Modal>
<OAuthLogin
visible={oauthVisible}
onClose={() => setOAuthVisible(false)}
onSuccess={handleOAuthSuccess}
/>
<Modal
title="检测到本地与远程数据冲突"
open={syncConflictVisible}
@@ -1044,6 +1093,7 @@ function App(): React.JSX.Element {
<ThemeProvider>
<HashRouter>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/*" element={<MainContent />} />
</Routes>
</HashRouter>
+26 -16
View File
@@ -872,17 +872,20 @@ ORDER BY reward_points DESC, score DESC`,
? { 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 }
: 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
@@ -1039,10 +1042,13 @@ ORDER BY reward_points DESC, score DESC`,
{(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>
<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 }}>
@@ -1428,7 +1434,11 @@ ORDER BY reward_points DESC, score DESC`,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
/>
<Typography.Link href="https://doubao.com/bot/uEh3mtxq" target="_blank" rel="noreferrer">
<Typography.Link
href="https://doubao.com/bot/uEh3mtxq"
target="_blank"
rel="noreferrer"
>
</Typography.Link>
<Typography.Link
+1 -4
View File
@@ -380,10 +380,7 @@ export function ContentArea({
path="/reward-settings"
element={<RewardSettings canEdit={permission === "admin"} />}
/>
<Route
path="/settings"
element={<Settings permission={permission} />}
/>
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
+141 -135
View File
@@ -2356,7 +2356,9 @@ export const Home: React.FC<HomeProps> = ({
onClick={() => handleSearchKeyPress(keyItem.digit)}
style={{ height: "28px", fontSize: "11px", padding: 0 }}
>
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
{keyItem.digit === "⌫"
? "⌫"
: `${keyItem.digit} ${keyItem.letters}`}
</Button>
))}
</div>
@@ -2543,147 +2545,151 @@ export const Home: React.FC<HomeProps> = ({
overflow: "visible",
}}
>
<div
ref={(node) => {
searchAreaRef.current = node
immersiveToolbarContentRef.current = node
<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={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
overflowX: "visible",
justifyContent: "flex-start",
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 }}
>
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onFocus={() => {
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
{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,
}}
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 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>
</div>
{isPortraitMode ? (
+115 -4
View File
@@ -12,12 +12,17 @@ import {
Modal,
Switch,
message,
Avatar,
Typography,
} from "antd"
import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { OAuthLogin } from "./OAuthLogin"
import { useTranslation } from "react-i18next"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
import { useResponsive } from "../hooks/useResponsive"
const { Text, Paragraph } = Typography
type permissionLevel = "admin" | "points" | "view"
type appSettings = {
is_wizard_completed: boolean
@@ -126,6 +131,15 @@ export const Settings: React.FC<{
const [pgSwitchLoading, setPgSwitchLoading] = useState(false)
const [pgUploadLoading, setPgUploadLoading] = useState(false)
const [oauthLoginVisible, setOAuthLoginVisible] = useState(false)
const [oauthUserInfo, setOAuthUserInfo] = useState<{
user_id: string
email: string
name: string
github_username?: string
permission: number
} | null>(null)
const permissionTag = useMemo(() => {
return (
<Tag
@@ -658,7 +672,10 @@ export const Settings: React.FC<{
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)
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"))
@@ -674,7 +691,11 @@ export const Settings: React.FC<{
]}
/>
<div
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
style={{
marginTop: "4px",
fontSize: "12px",
color: "var(--ss-text-secondary)",
}}
>
{t("settings.searchKeyboard.hint")}
</div>
@@ -699,7 +720,11 @@ export const Settings: React.FC<{
disabled={!canAdmin}
/>
<div
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
style={{
marginTop: "4px",
fontSize: "12px",
color: "var(--ss-text-secondary)",
}}
>
{t("settings.searchKeyboard.disableHint")}
</div>
@@ -740,7 +765,6 @@ export const Settings: React.FC<{
{t("settings.zoomHint")}
</div>
</Form.Item>
</Form>
</Card>
),
@@ -849,6 +873,84 @@ export const Settings: React.FC<{
</>
),
},
{
key: "account",
label: t("settings.tabs.account"),
children: (
<>
<Card
style={{
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
marginBottom: "16px",
}}
>
<div style={{ fontWeight: 600, marginBottom: "16px" }}>
{t("settings.account.title")}
</div>
{oauthUserInfo ? (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<Avatar size={64} style={{ backgroundColor: "#1890ff" }}>
{oauthUserInfo.name.charAt(0).toUpperCase()}
</Avatar>
<div>
<div style={{ fontSize: "18px", fontWeight: 600 }}>{oauthUserInfo.name}</div>
<div style={{ fontSize: "14px", color: "var(--ss-text-secondary)" }}>
{oauthUserInfo.email}
</div>
{oauthUserInfo.github_username && (
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)" }}>
GitHub: @{oauthUserInfo.github_username}
</div>
)}
</div>
</div>
<Divider />
<div>
<Text type="secondary">{t("settings.account.userId")}:</Text>
<Text code style={{ marginLeft: "8px" }}>
{oauthUserInfo.user_id}
</Text>
</div>
<div>
<Text type="secondary">{t("settings.account.permission")}:</Text>
<Tag color="blue" style={{ marginLeft: "8px" }}>
{oauthUserInfo.permission === 1
? t("permissions.admin")
: oauthUserInfo.permission === 2
? t("permissions.points")
: t("permissions.view")}
</Tag>
</div>
<Divider />
<Button danger onClick={() => setOAuthUserInfo(null)}>
{t("settings.account.logout")}
</Button>
</div>
) : (
<div style={{ textAlign: "center", padding: "20px 0" }}>
<Paragraph style={{ color: "var(--ss-text-secondary)" }}>
{t("settings.account.notLoggedIn")}
</Paragraph>
<Paragraph style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
{t("settings.account.oauthHint")}
</Paragraph>
<Button type="primary" size="large" onClick={() => setOAuthLoginVisible(true)}>
{t("settings.account.loginButton")}
</Button>
</div>
)}
</Card>
</>
),
},
{
key: "database",
label: t("settings.database.title"),
@@ -1426,6 +1528,15 @@ export const Settings: React.FC<{
>
</Modal>
<OAuthLogin
visible={oauthLoginVisible}
onClose={() => setOAuthLoginVisible(false)}
onSuccess={(userInfo) => {
setOAuthUserInfo(userInfo)
messageApi.success(t("settings.account.loginSuccess"))
}}
/>
</div>
)
}
+28 -8
View File
@@ -260,7 +260,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
return
}
const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName })
const res = await (window as any).api.createStudent({
...values,
name,
group_name: groupName,
})
if (res.success) {
messageApi.success(t("students.addSuccess"))
setVisible(false)
@@ -1080,7 +1084,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const selectedMedals = (banYouDetail.medals || []).filter((m, idx) =>
banYouCheckedMedals.includes(medalKey(m, idx))
)
const selectedTeams = (banYouDetail.teams || []).filter((g) => banYouCheckedTeams.includes(g.teamId))
const selectedTeams = (banYouDetail.teams || []).filter((g) =>
banYouCheckedTeams.includes(g.teamId)
)
if (!selectedStudents.length && !selectedMedals.length && !selectedTeams.length) {
messageApi.warning(t("students.banyouNothingSelected"))
@@ -1325,7 +1331,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
},
}}
>
<Button size={isMobile ? "small" : "middle"} disabled={!canEdit} icon={<MoreOutlined />}>
<Button
size={isMobile ? "small" : "middle"}
disabled={!canEdit}
icon={<MoreOutlined />}
>
{t("common.operation")}
</Button>
</Dropdown>
@@ -1608,7 +1618,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
borderBottom: "1px dashed var(--ss-border-color)",
paddingBottom: 8,
cursor:
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null ? "default" : "grab",
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null
? "default"
: "grab",
userSelect: "none",
WebkitUserSelect: "none",
touchAction: "none",
@@ -1632,7 +1644,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
studentsInGroup.map((student) => (
<div
key={`${groupKey}-${student.id}`}
onPointerDown={(e) => beginPointerDrag(e, student.id, student.name, groupKey)}
onPointerDown={(e) =>
beginPointerDrag(e, student.id, student.name, groupKey)
}
onPointerMove={(e) => trackPointerTarget(e.clientX, e.clientY)}
onPointerUp={finishPointerDrag}
onPointerCancel={finishPointerDrag}
@@ -1935,7 +1949,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
cancelText={t("common.cancel")}
>
{banYouDetailLoading ? (
<div style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}>
<div
style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}
>
{t("common.loading")}
</div>
) : !banYouDetail ? (
@@ -1973,7 +1989,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)}
<div>
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouReasonList")}</div>
<div style={{ fontWeight: 700, marginBottom: 8 }}>
{t("students.banyouReasonList")}
</div>
<div
style={{
border: "1px solid var(--ss-border-color)",
@@ -2006,7 +2024,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</div>
<div>
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouStudentList")}</div>
<div style={{ fontWeight: 700, marginBottom: 8 }}>
{t("students.banyouStudentList")}
</div>
<div
style={{
border: "1px solid var(--ss-border-color)",
+11
View File
@@ -127,6 +127,7 @@
"tabs": {
"appearance": "Appearance",
"security": "Security",
"account": "Account",
"database": "Database Connection",
"dataManagement": "Data Management",
"urlProtocol": "URL Protocol",
@@ -209,6 +210,16 @@
"passwordClearedNewRecovery": "Password cleared, new recovery string",
"newRecoveryString": "New recovery string (please save it)"
},
"account": {
"title": "SECTL Auth Account",
"notLoggedIn": "Not logged in to SECTL Auth",
"oauthHint": "Use SECTL Auth account to login, enjoy unified authentication and remote logout",
"loginButton": "Login with SECTL Auth",
"loginSuccess": "Login successful",
"logout": "Logout",
"userId": "User ID",
"permission": "Permission Level"
},
"database": {
"title": "Database Connection",
"currentStatus": "Current Database Status",
+11
View File
@@ -127,6 +127,7 @@
"tabs": {
"appearance": "外观",
"security": "安全",
"account": "账户",
"database": "数据库连接",
"dataManagement": "数据管理",
"urlProtocol": "URL 链接",
@@ -209,6 +210,16 @@
"passwordClearedNewRecovery": "密码已清空,新的找回字符串",
"newRecoveryString": "新的找回字符串(请保存)"
},
"account": {
"title": "SECTL Auth 账户",
"notLoggedIn": "尚未登录 SECTL Auth 账户",
"oauthHint": "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能",
"loginButton": "使用 SECTL Auth 登录",
"loginSuccess": "登录成功",
"logout": "退出登录",
"userId": "用户ID",
"permission": "权限等级"
},
"database": {
"title": "数据库连接",
"currentStatus": "当前数据库状态",
+1 -1
View File
@@ -160,7 +160,7 @@ const enableTouchWindowDrag = () => {
if (!target) return
// 检查是否在可拖动区域内
const dragRegion = target.closest('[data-tauri-drag-region]')
const dragRegion = target.closest("[data-tauri-drag-region]")
if (!dragRegion) return
// 检查是否点击了不可拖动的元素(如按钮)
+52 -1
View File
@@ -111,7 +111,9 @@ const api = {
names: string[]
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
invoke("student_import_from_xlsx", { params }),
fetchBanYouClassrooms: (params: { cookie: string }): Promise<{
fetchBanYouClassrooms: (params: {
cookie: string
}): Promise<{
success: boolean
data?: {
classrooms: Array<{
@@ -344,6 +346,55 @@ const api = {
invoke("auth_reset_by_recovery", { recoveryString }),
authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"),
// OAuth
oauthGetAuthorizationUrl: (
platformId: string,
callbackUrl: string
): Promise<{ success: boolean; data: string; message?: string }> =>
invoke("oauth_get_authorization_url", { platformId, callbackUrl }),
oauthExchangeCode: (
code: string,
platformId: string,
platformSecret: string,
callbackUrl: string
): Promise<{
success: boolean
data: {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
message?: string
}> => invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl }),
oauthGetUserInfo: (
accessToken: string
): Promise<{
success: boolean
data: {
user_id: string
email: string
name: string
github_username?: string
permission: number
}
message?: string
}> => invoke("oauth_get_user_info", { accessToken }),
oauthRefreshToken: (
refreshToken: string,
platformId: string,
platformSecret: string
): Promise<{
success: boolean
data: {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
message?: string
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
// Data import/export
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
importDataJson: (jsonText: string): Promise<{ success: boolean }> =>