mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
Merge branch with OAuth improvements
This commit is contained in:
@@ -26,7 +26,6 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^25.8.14",
|
||||
"json-rules-engine": "^7.3.1",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
|
||||
Generated
+18
-61
@@ -35,9 +35,6 @@ importers:
|
||||
i18next:
|
||||
specifier: ^25.8.14
|
||||
version: 25.8.18(typescript@5.9.3)
|
||||
json-rules-engine:
|
||||
specifier: ^7.3.1
|
||||
version: 7.3.1
|
||||
pinyin-pro:
|
||||
specifier: ^3.27.0
|
||||
version: 3.28.0
|
||||
@@ -462,18 +459,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0':
|
||||
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
peerDependencies:
|
||||
jsep: ^0.4.0||^1.0.0
|
||||
|
||||
'@jsep-plugin/regex@1.0.4':
|
||||
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
peerDependencies:
|
||||
jsep: ^0.4.0||^1.0.0
|
||||
|
||||
'@rc-component/async-validator@5.1.0':
|
||||
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
|
||||
engines: {node: '>=14.x'}
|
||||
@@ -854,66 +839,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==}
|
||||
@@ -977,30 +975,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==}
|
||||
@@ -1477,9 +1480,6 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter2@6.4.9:
|
||||
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1595,9 +1595,6 @@ packages:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hash-it@6.0.1:
|
||||
resolution: {integrity: sha512-qhl8+l4Zwi1eLlL3lja5ywmDQnBzLEJxd0QJoAVIgZpgQbdtVZrN5ypB0y3VHwBlvAalpcbM2/A6x7oUks5zNg==}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1767,10 +1764,6 @@ packages:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
||||
jsep@1.4.0:
|
||||
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1785,10 +1778,6 @@ packages:
|
||||
json-logic-js@2.0.5:
|
||||
resolution: {integrity: sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==}
|
||||
|
||||
json-rules-engine@7.3.1:
|
||||
resolution: {integrity: sha512-NyRTQZllvAt7AQ3g9P7/t4nIwlEB+EyZV7y8/WgXfZWSlpcDryt1UH9CsoU+Z+MDvj8umN9qqEcbE6qnk9JAHw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
@@ -1803,11 +1792,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonpath-plus@10.4.0:
|
||||
resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -2745,14 +2729,6 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
|
||||
dependencies:
|
||||
jsep: 1.4.0
|
||||
|
||||
'@rc-component/async-validator@5.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
@@ -4017,8 +3993,6 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter2@6.4.9: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
@@ -4126,8 +4100,6 @@ snapshots:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hash-it@6.0.1: {}
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -4304,8 +4276,6 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsep@1.4.0: {}
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
@@ -4316,13 +4286,6 @@ snapshots:
|
||||
|
||||
json-logic-js@2.0.5: {}
|
||||
|
||||
json-rules-engine@7.3.1:
|
||||
dependencies:
|
||||
clone: 2.1.2
|
||||
eventemitter2: 6.4.9
|
||||
hash-it: 6.0.1
|
||||
jsonpath-plus: 10.4.0
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
@@ -4333,12 +4296,6 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonpath-plus@10.4.0:
|
||||
dependencies:
|
||||
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
|
||||
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
||||
jsep: 1.4.0
|
||||
|
||||
jsx-ast-utils@3.3.5:
|
||||
dependencies:
|
||||
array-includes: 3.1.9
|
||||
|
||||
+108
-17
@@ -24,7 +24,7 @@ import {
|
||||
HolderOutlined,
|
||||
CrownOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
@@ -48,6 +48,14 @@ const applyGlobalFontFamily = (fontValue?: string) => {
|
||||
document.body.style.fontFamily = fontFamily
|
||||
}
|
||||
|
||||
const mapOAuthPermissionToAppPermission = (
|
||||
oauthPermission: number
|
||||
): "admin" | "points" | "view" => {
|
||||
if (oauthPermission >= 18) return "admin"
|
||||
if (oauthPermission >= 1) return "points"
|
||||
return "view"
|
||||
}
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -107,6 +115,7 @@ function MainContent(): React.JSX.Element {
|
||||
const [authPassword, setAuthPassword] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
const [oauthVisible, setOAuthVisible] = useState(false)
|
||||
const [oauthUserName, setOAuthUserName] = useState<string | null>(null)
|
||||
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
|
||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
||||
)
|
||||
@@ -132,6 +141,18 @@ function MainContent(): React.JSX.Element {
|
||||
const syncApplyLoadingRef = useRef(false)
|
||||
const lastLocalMutationAtRef = useRef(0)
|
||||
const pluginRuntimeRef = useRef(getPluginRuntime())
|
||||
const refreshPermissionFromAuth = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
const authRes = await api.authGetStatus()
|
||||
if (authRes?.success && authRes.data) {
|
||||
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
|
||||
setHasAnyPassword(anyPwd)
|
||||
setPermission(authRes.data.permission)
|
||||
setAuthVisible(anyPwd && authRes.data.permission === "view")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeMenu = useMemo(() => {
|
||||
const p = location.pathname
|
||||
@@ -188,15 +209,37 @@ function MainContent(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuthAndSettings = async () => {
|
||||
if (!(window as any).api) return
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
const [authRes, oauthRes, settingsRes] = await Promise.all([
|
||||
api.authGetStatus(),
|
||||
api.oauthLoadLoginState(),
|
||||
api.getAllSettings(),
|
||||
])
|
||||
|
||||
const oauthPermission =
|
||||
oauthRes?.success && oauthRes.data
|
||||
? mapOAuthPermissionToAppPermission(oauthRes.data.permission)
|
||||
: null
|
||||
if (oauthRes?.success && oauthRes.data?.name) {
|
||||
setOAuthUserName(String(oauthRes.data.name))
|
||||
} else {
|
||||
setOAuthUserName(null)
|
||||
}
|
||||
|
||||
if (authRes?.success && authRes.data) {
|
||||
setPermission(authRes.data.permission)
|
||||
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
|
||||
setHasAnyPassword(anyPwd)
|
||||
if (anyPwd && authRes.data.permission === "view") setAuthVisible(true)
|
||||
|
||||
const finalPermission = oauthPermission ?? authRes.data.permission
|
||||
setPermission(finalPermission)
|
||||
setAuthVisible(anyPwd && finalPermission === "view")
|
||||
} else if (oauthPermission) {
|
||||
setPermission(oauthPermission)
|
||||
setAuthVisible(false)
|
||||
}
|
||||
const settingsRes = await (window as any).api.getAllSettings()
|
||||
|
||||
if (settingsRes?.success && settingsRes.data) {
|
||||
setMobileBottomNavItems(normalizeStoredBottomKeys(settingsRes.data.mobile_bottom_nav_items))
|
||||
applyGlobalFontFamily(settingsRes.data.font_family)
|
||||
@@ -206,6 +249,31 @@ function MainContent(): React.JSX.Element {
|
||||
loadAuthAndSettings()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthUserUpdated = (
|
||||
event: Event
|
||||
) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
user?: { name?: string } | null
|
||||
}>
|
||||
const userName = customEvent?.detail?.user?.name
|
||||
if (typeof userName === "string" && userName.trim()) {
|
||||
setOAuthUserName(userName)
|
||||
} else {
|
||||
setOAuthUserName(null)
|
||||
void refreshPermissionFromAuth()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("ss:oauth-user-updated", handleOAuthUserUpdated as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"ss:oauth-user-updated",
|
||||
handleOAuthUserUpdated as EventListener
|
||||
)
|
||||
}
|
||||
}, [refreshPermissionFromAuth])
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
if (!api || typeof api.onSettingChanged !== "function") return
|
||||
@@ -425,14 +493,40 @@ function MainContent(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.authLogout()
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
// 退出时同时清理 OAuth 持久化状态,避免刷新后又自动恢复权限
|
||||
try {
|
||||
await api.oauthClearLoginState()
|
||||
setOAuthUserName(null)
|
||||
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
||||
} catch (error) {
|
||||
console.error("Failed to clear OAuth login state:", error)
|
||||
}
|
||||
|
||||
const res = await api.authLogout()
|
||||
if (res?.success && res.data) {
|
||||
setPermission(res.data.permission)
|
||||
messageApi.success(t("auth.logout"))
|
||||
}
|
||||
}
|
||||
|
||||
const logoutOAuthFromHeader = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api) return
|
||||
|
||||
try {
|
||||
await api.oauthClearLoginState()
|
||||
setOAuthUserName(null)
|
||||
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
||||
await refreshPermissionFromAuth()
|
||||
messageApi.success("已退出云账号")
|
||||
} catch (error: any) {
|
||||
messageApi.error(error?.message || t("common.error"))
|
||||
}
|
||||
}, [messageApi, refreshPermissionFromAuth, t])
|
||||
|
||||
const handleOAuthSuccess = (userInfo: {
|
||||
user_id: string
|
||||
email: string
|
||||
@@ -440,14 +534,9 @@ function MainContent(): React.JSX.Element {
|
||||
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)
|
||||
setPermission(mapOAuthPermissionToAppPermission(userInfo.permission))
|
||||
setAuthVisible(false)
|
||||
setOAuthUserName(userInfo.name || null)
|
||||
messageApi.success(t("auth.oauthSuccess", "登录成功"))
|
||||
}
|
||||
|
||||
@@ -643,6 +732,8 @@ function MainContent(): React.JSX.Element {
|
||||
)}
|
||||
<ContentArea
|
||||
permission={permission}
|
||||
oauthUserName={oauthUserName}
|
||||
onOAuthLogout={logoutOAuthFromHeader}
|
||||
hasAnyPassword={hasAnyPassword}
|
||||
onAuthClick={() => setAuthVisible(true)}
|
||||
onLogout={logout}
|
||||
@@ -984,7 +1075,7 @@ function MainContent(): React.JSX.Element {
|
||||
footer={null}
|
||||
closable={!syncApplyLoading}
|
||||
maskClosable={false}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<div
|
||||
style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}
|
||||
|
||||
@@ -229,6 +229,80 @@ html.platform-macos #root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ss-reward-morph-modal {
|
||||
--ss-modal-from-x: 0px;
|
||||
--ss-modal-from-y: 0px;
|
||||
--ss-modal-scale-x: 1;
|
||||
--ss-modal-scale-y: 1;
|
||||
}
|
||||
|
||||
.ss-reward-morph-modal.is-preparing {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ss-reward-morph-modal .ant-modal-content {
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-enter,
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-appear,
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-leave,
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-enter-active,
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-appear-active,
|
||||
.ss-reward-morph-wrap.ss-reward-noop-motion-leave-active,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-enter,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-appear,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-leave,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-enter-active,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-appear-active,
|
||||
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-leave-active {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.ss-reward-morph-modal.is-enter .ant-modal-content {
|
||||
animation: ss-reward-modal-morph-in 300ms cubic-bezier(0.2, 0.82, 0.22, 1) both;
|
||||
}
|
||||
|
||||
.ss-home-operation-morph-modal .ant-modal-content {
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.ss-operation-mask-fade-enter,
|
||||
.ss-operation-mask-fade-appear {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.ss-operation-mask-fade-enter-active,
|
||||
.ss-operation-mask-fade-appear-active {
|
||||
opacity: 1 !important;
|
||||
transition: opacity 260ms cubic-bezier(0.2, 0, 0, 1) !important;
|
||||
}
|
||||
|
||||
.ss-operation-mask-fade-leave {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.ss-operation-mask-fade-leave-active {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 220ms cubic-bezier(0.4, 0, 1, 1) !important;
|
||||
}
|
||||
|
||||
@keyframes ss-reward-modal-morph-in {
|
||||
from {
|
||||
transform: translate3d(var(--ss-modal-from-x), var(--ss-modal-from-y), 0)
|
||||
scale(var(--ss-modal-scale-x), var(--ss-modal-scale-y));
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate3d(0, 0, 0) scale(1, 1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ss-immersive-sidebar {
|
||||
--ss-sidebar-width: 200px;
|
||||
height: 100%;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type BoardStudentViewMode = "list" | "card" | "grid"
|
||||
type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar"
|
||||
type BoardScoreDisplayMode = "total" | "split"
|
||||
type SplitDirection = "horizontal" | "vertical"
|
||||
|
||||
@@ -68,6 +68,7 @@ interface BoardManagerProps {
|
||||
interface BoardStudentCardData {
|
||||
key: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
score?: number
|
||||
addScore?: number
|
||||
deductScore?: number
|
||||
@@ -221,7 +222,10 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
|
||||
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
||||
sql: typeof list?.sql === "string" && list.sql.trim() ? list.sql : getDefaultSql(),
|
||||
viewMode:
|
||||
list?.viewMode === "list" || list?.viewMode === "card" || list?.viewMode === "grid"
|
||||
list?.viewMode === "list" ||
|
||||
list?.viewMode === "card" ||
|
||||
list?.viewMode === "grid" ||
|
||||
list?.viewMode === "largeAvatar"
|
||||
? list.viewMode
|
||||
: "card",
|
||||
scoreDisplayMode:
|
||||
@@ -347,6 +351,11 @@ const toStudentCards = (rows: any[]): BoardStudentCardData[] => {
|
||||
cards.push({
|
||||
key: `${name}-${index}`,
|
||||
name,
|
||||
avatarUrl:
|
||||
typeof (data.avatar_url ?? data.avatarUrl ?? data.avatar) === "string" &&
|
||||
String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim()
|
||||
? String(data.avatar_url ?? data.avatarUrl ?? data.avatar).trim()
|
||||
: undefined,
|
||||
score: parseNumber(data.score),
|
||||
addScore,
|
||||
deductScore,
|
||||
@@ -851,6 +860,8 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
gridTemplateColumns:
|
||||
list.viewMode === "grid"
|
||||
? "repeat(auto-fill, minmax(102px, 1fr))"
|
||||
: list.viewMode === "largeAvatar"
|
||||
? "repeat(auto-fill, minmax(180px, 1fr))"
|
||||
: list.viewMode === "list"
|
||||
? "1fr"
|
||||
: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
@@ -897,11 +908,22 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
: item.answeredCount !== undefined
|
||||
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
|
||||
: null
|
||||
const metricValueText = primaryMetric
|
||||
? primaryMetric.value > 0
|
||||
? `+${primaryMetric.value}`
|
||||
: String(primaryMetric.value)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ ...(list.viewMode === "grid" ? { aspectRatio: "1 / 1" } : null) }}
|
||||
style={{
|
||||
...(list.viewMode === "grid"
|
||||
? { aspectRatio: "1 / 1" }
|
||||
: list.viewMode === "largeAvatar"
|
||||
? { aspectRatio: "1.2 / 1" }
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
@@ -917,10 +939,15 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
padding:
|
||||
list.viewMode === "grid"
|
||||
? "8px"
|
||||
: list.viewMode === "largeAvatar"
|
||||
? 0
|
||||
: list.viewMode === "list"
|
||||
? "10px 12px"
|
||||
: "12px 14px",
|
||||
height: list.viewMode === "grid" ? "100%" : undefined,
|
||||
height:
|
||||
list.viewMode === "grid" || list.viewMode === "largeAvatar"
|
||||
? "100%"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -1006,6 +1033,96 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : list.viewMode === "largeAvatar" ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
{item.avatarUrl ? (
|
||||
<img
|
||||
src={item.avatarUrl}
|
||||
alt={item.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: `linear-gradient(140deg, ${avatarColor} 0%, ${avatarColor}aa 100%)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
fontWeight: 700,
|
||||
fontSize: avatarText.length > 1 ? "46px" : "56px",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0) 56%, rgba(255,255,255,0.72) 84%, rgba(255,255,255,0.95) 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "65%",
|
||||
fontWeight: 700,
|
||||
fontSize: 22,
|
||||
lineHeight: 1,
|
||||
color: "#111",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
background: "rgba(255,255,255,0.62)",
|
||||
border: "1px solid rgba(255,255,255,0.82)",
|
||||
borderRadius: 8,
|
||||
padding: "3px 9px",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{metricValueText !== null && (
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: 28,
|
||||
lineHeight: 1,
|
||||
color:
|
||||
primaryMetric && primaryMetric.value >= 0
|
||||
? "#52c41a"
|
||||
: "#ff4d4f",
|
||||
background: "rgba(255,255,255,0.62)",
|
||||
border: "1px solid rgba(255,255,255,0.82)",
|
||||
borderRadius: 8,
|
||||
padding: "2px 9px",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{metricValueText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div
|
||||
@@ -1407,6 +1524,7 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
{ value: "list", label: t("board.viewModes.list") },
|
||||
{ value: "card", label: t("board.viewModes.card") },
|
||||
{ value: "grid", label: t("board.viewModes.grid") },
|
||||
{ value: "largeAvatar", label: t("board.viewModes.largeAvatar") },
|
||||
]}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Suspense, lazy, useEffect } from "react"
|
||||
import { Layout, Space, Button, Tag, Spin } from "antd"
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Layout, Space, Button, Tag, Spin, Avatar, Popover, Progress } from "antd"
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
@@ -57,6 +57,8 @@ const { Content } = Layout
|
||||
|
||||
interface ContentAreaProps {
|
||||
permission: "admin" | "points" | "view"
|
||||
oauthUserName?: string | null
|
||||
onOAuthLogout?: () => Promise<void> | void
|
||||
hasAnyPassword: boolean
|
||||
onAuthClick: () => void
|
||||
onLogout: () => void
|
||||
@@ -74,8 +76,17 @@ interface ContentAreaProps {
|
||||
bottomInset?: number
|
||||
}
|
||||
|
||||
interface HeaderStorageUsage {
|
||||
used_storage_formatted: string
|
||||
total_storage_formatted: string
|
||||
percentage: number
|
||||
file_count: number
|
||||
}
|
||||
|
||||
export function ContentArea({
|
||||
permission,
|
||||
oauthUserName,
|
||||
onOAuthLogout,
|
||||
hasAnyPassword,
|
||||
onAuthClick,
|
||||
onLogout,
|
||||
@@ -158,6 +169,247 @@ export function ContentArea({
|
||||
: t("permissions.view")}
|
||||
</Tag>
|
||||
)
|
||||
const fallbackDisplayName =
|
||||
permission === "admin"
|
||||
? t("permissions.admin")
|
||||
: permission === "points"
|
||||
? t("permissions.points")
|
||||
: t("permissions.view")
|
||||
const userDisplayName = (oauthUserName || fallbackDisplayName).trim()
|
||||
const avatarText = userDisplayName.slice(0, 1).toUpperCase()
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = useState(false)
|
||||
const [storageUsageLoading, setStorageUsageLoading] = useState(false)
|
||||
const [storageUsageError, setStorageUsageError] = useState<string | null>(null)
|
||||
const [storageUsage, setStorageUsage] = useState<HeaderStorageUsage | null>(null)
|
||||
const [oauthUserId, setOAuthUserId] = useState<string | null>(null)
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem("ss_last_sync_time")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||
const [logoutLoading, setLogoutLoading] = useState(false)
|
||||
const copyResetTimerRef = useRef<number | null>(null)
|
||||
const hasOAuthSession = Boolean(oauthUserName && oauthUserName.trim())
|
||||
const formattedLastSyncTime = (() => {
|
||||
if (!lastSyncTime) return "暂无"
|
||||
const date = new Date(lastSyncTime)
|
||||
if (Number.isNaN(date.getTime())) return "暂无"
|
||||
return date.toLocaleString("zh-CN", { hour12: false })
|
||||
})()
|
||||
|
||||
const loadStorageUsage = useCallback(async () => {
|
||||
setStorageUsageLoading(true)
|
||||
setStorageUsageError(null)
|
||||
|
||||
try {
|
||||
const api = (window as any).api
|
||||
if (!api?.oauthLoadLoginState) {
|
||||
setStorageUsage(null)
|
||||
setStorageUsageError("当前环境不支持云用量查询")
|
||||
return
|
||||
}
|
||||
|
||||
const oauthStateRes = await api.oauthLoadLoginState()
|
||||
const oauthState = oauthStateRes?.success ? oauthStateRes.data : null
|
||||
if (!oauthState?.access_token || !oauthState?.user_id) {
|
||||
setOAuthUserId(null)
|
||||
setStorageUsage(null)
|
||||
setStorageUsageError("当前未登录云账号")
|
||||
return
|
||||
}
|
||||
setOAuthUserId(String(oauthState.user_id))
|
||||
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
if (!platformId) {
|
||||
setStorageUsage(null)
|
||||
setStorageUsageError("未配置平台 ID")
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: platformId,
|
||||
user_id: oauthState.user_id,
|
||||
})
|
||||
|
||||
const response = await fetch(
|
||||
`https://appwrite.sectl.top/api/cloud/storage/usage?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${oauthState.access_token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const responseText = await response.text()
|
||||
let payload: any = {}
|
||||
try {
|
||||
payload = responseText ? JSON.parse(responseText) : {}
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error_description || payload?.message || "获取云空间用量失败")
|
||||
}
|
||||
|
||||
setStorageUsage({
|
||||
used_storage_formatted: String(payload?.used_storage_formatted || "0 B"),
|
||||
total_storage_formatted: String(payload?.total_storage_formatted || "0 B"),
|
||||
percentage: Number.isFinite(payload?.percentage) ? Number(payload.percentage) : 0,
|
||||
file_count: Number.isFinite(payload?.file_count) ? Number(payload.file_count) : 0,
|
||||
})
|
||||
} catch (error: any) {
|
||||
setStorageUsage(null)
|
||||
setStorageUsageError(error?.message || "获取云空间用量失败")
|
||||
} finally {
|
||||
setStorageUsageLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profilePopoverOpen) return
|
||||
void loadStorageUsage()
|
||||
}, [loadStorageUsage, profilePopoverOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataUpdated = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ source?: string }>
|
||||
if (customEvent?.detail?.source !== "sync") return
|
||||
const now = new Date().toISOString()
|
||||
setLastSyncTime(now)
|
||||
try {
|
||||
localStorage.setItem("ss_last_sync_time", now)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("ss:data-updated", handleDataUpdated as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener("ss:data-updated", handleDataUpdated as EventListener)
|
||||
if (copyResetTimerRef.current) {
|
||||
window.clearTimeout(copyResetTimerRef.current)
|
||||
copyResetTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCopyUserId = async () => {
|
||||
if (!oauthUserId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(oauthUserId)
|
||||
setCopiedUserId(true)
|
||||
if (copyResetTimerRef.current) {
|
||||
window.clearTimeout(copyResetTimerRef.current)
|
||||
}
|
||||
copyResetTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedUserId(false)
|
||||
copyResetTimerRef.current = null
|
||||
}, 1500)
|
||||
} catch {
|
||||
setCopiedUserId(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthLogoutClick = async () => {
|
||||
if (logoutLoading) return
|
||||
setLogoutLoading(true)
|
||||
try {
|
||||
if (onOAuthLogout) {
|
||||
await onOAuthLogout()
|
||||
} else {
|
||||
const api = (window as any).api
|
||||
if (api?.oauthClearLoginState) {
|
||||
await api.oauthClearLoginState()
|
||||
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
||||
}
|
||||
}
|
||||
setProfilePopoverOpen(false)
|
||||
} finally {
|
||||
setLogoutLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const profilePopoverContent = (
|
||||
<div style={{ width: "260px", display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>账号 ID</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-main)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
title={oauthUserId || ""}
|
||||
>
|
||||
{oauthUserId || "未登录"}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" onClick={handleCopyUserId} disabled={!oauthUserId}>
|
||||
{copiedUserId ? "已复制" : "复制"}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>最近同步时间</div>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-main)" }}>{formattedLastSyncTime}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)" }}>云空间用量</div>
|
||||
{storageUsageLoading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "10px 0" }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : storageUsage ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<div style={{ fontSize: "13px", color: "var(--ss-text-main)" }}>
|
||||
已用:{storageUsage.used_storage_formatted} / {storageUsage.total_storage_formatted}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.max(0, Math.min(100, Math.round(storageUsage.percentage)))}
|
||||
size="small"
|
||||
status={storageUsage.percentage > 90 ? "exception" : "active"}
|
||||
/>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
文件数量:{storageUsage.file_count}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "12px", color: "var(--ant-color-error, #ff4d4f)" }}>
|
||||
{storageUsageError || "暂无云空间数据"}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
block
|
||||
size="small"
|
||||
onClick={handleOAuthLogoutClick}
|
||||
loading={logoutLoading}
|
||||
disabled={!hasOAuthSession}
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
@@ -307,6 +559,55 @@ export function ContentArea({
|
||||
title={immersiveMode ? "退出沉浸模式" : "进入沉浸模式"}
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
content={profilePopoverContent}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
borderRadius: "999px",
|
||||
padding: "2px 10px 2px 2px",
|
||||
background: "var(--ss-bg-color)",
|
||||
minWidth: "92px",
|
||||
maxWidth: "180px",
|
||||
height: "30px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={userDisplayName}
|
||||
>
|
||||
<Avatar
|
||||
size={24}
|
||||
style={{
|
||||
backgroundColor: "var(--ant-color-primary, #1677ff)",
|
||||
color: "#fff",
|
||||
fontSize: "12px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{avatarText || "U"}
|
||||
</Avatar>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ss-text-main)",
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{userDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
{permissionTag}
|
||||
{hasAnyPassword && (
|
||||
<>
|
||||
|
||||
+825
-14
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from "react"
|
||||
import {
|
||||
Card,
|
||||
Space,
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
message,
|
||||
InputNumber,
|
||||
Divider,
|
||||
Dropdown,
|
||||
} from "antd"
|
||||
import { SearchOutlined, DeleteOutlined, UndoOutlined } from "@ant-design/icons"
|
||||
import { SearchOutlined, DeleteOutlined, UndoOutlined, UploadOutlined, CopyOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { match, pinyin } from "pinyin-pro"
|
||||
import { getAvatarFromExtraJson } from "../utils/studentAvatar"
|
||||
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
|
||||
interface student {
|
||||
@@ -56,7 +57,7 @@ interface rewardSetting {
|
||||
}
|
||||
|
||||
type SortType = "alphabet" | "surname" | "group" | "score"
|
||||
type LayoutType = "grouped" | "squareGrid"
|
||||
type LayoutType = "grouped" | "squareGrid" | "largeAvatar"
|
||||
type SearchKeyboardLayout = "t9" | "qwerty26"
|
||||
|
||||
const T9_KEY_MAP: Record<string, string> = {
|
||||
@@ -127,6 +128,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([])
|
||||
const [operationVisible, setOperationVisible] = useState(false)
|
||||
const [operationOriginRect, setOperationOriginRect] = useState<DOMRect | null>(null)
|
||||
const [customScore, setCustomScore] = useState<number | undefined>(undefined)
|
||||
const [reasonContent, setReasonContent] = useState("")
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
@@ -138,9 +140,25 @@ export const Home: React.FC<HomeProps> = ({
|
||||
const [rewardStudent, setRewardStudent] = useState<student | null>(null)
|
||||
const [rewardModalVisible, setRewardModalVisible] = useState(false)
|
||||
const [redeemLoading, setRedeemLoading] = useState(false)
|
||||
const [renameVisible, setRenameVisible] = useState(false)
|
||||
const [renameSaving, setRenameSaving] = useState(false)
|
||||
const [renameStudent, setRenameStudent] = useState<student | null>(null)
|
||||
const [renameValue, setRenameValue] = useState("")
|
||||
const [avatarEditorVisible, setAvatarEditorVisible] = useState(false)
|
||||
const [avatarEditorSaving, setAvatarEditorSaving] = useState(false)
|
||||
const [avatarEditorStudent, setAvatarEditorStudent] = useState<student | null>(null)
|
||||
const [avatarEditorValue, setAvatarEditorValue] = useState<string | null>(null)
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
const fetchRequestIdRef = useRef(0)
|
||||
const operationMorphAnimationRef = useRef<Animation | null>(null)
|
||||
const operationMaskAnimationRef = useRef<Animation | null>(null)
|
||||
const operationMorphRafRef = useRef<number | null>(null)
|
||||
const operationClosingRef = useRef(false)
|
||||
const operationCloseTokenRef = useRef(0)
|
||||
const operationModalRootClass = "ss-home-operation-morph-root"
|
||||
const operationModalClass = "ss-home-operation-morph-modal"
|
||||
|
||||
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||
@@ -589,7 +607,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const openOperation = (student: student) => {
|
||||
const openOperation = (student: student, sourceEl?: HTMLElement | null) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
@@ -605,12 +623,277 @@ export const Home: React.FC<HomeProps> = ({
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const cardEl = sourceEl?.querySelector(".ant-card") as HTMLElement | null
|
||||
const sourceRect = (cardEl ?? sourceEl)?.getBoundingClientRect() ?? null
|
||||
logHome("operation:morph:open", {
|
||||
student: student.name,
|
||||
hasSourceEl: Boolean(sourceEl),
|
||||
hasCardEl: Boolean(cardEl),
|
||||
sourceRect: sourceRect
|
||||
? {
|
||||
left: sourceRect.left,
|
||||
top: sourceRect.top,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
setOperationOriginRect(sourceRect)
|
||||
operationClosingRef.current = false
|
||||
operationCloseTokenRef.current += 1
|
||||
operationMorphAnimationRef.current?.cancel()
|
||||
operationMaskAnimationRef.current?.cancel()
|
||||
|
||||
setSelectedStudent(student)
|
||||
setCustomScore(undefined)
|
||||
setReasonContent("")
|
||||
setOperationVisible(true)
|
||||
}
|
||||
|
||||
const playOperationMorph = useCallback((attempt = 0) => {
|
||||
if (isPortraitMode || !operationOriginRect) return false
|
||||
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
|
||||
if (!modalEl) {
|
||||
const byClassCount = document.querySelectorAll(`.${operationModalClass}`).length
|
||||
const byRootCount = document.querySelectorAll(`.${operationModalRootClass}`).length
|
||||
const modalCount = document.querySelectorAll(".ant-modal").length
|
||||
const rootExists = Boolean(document.querySelector(`.${operationModalRootClass}`))
|
||||
logHome("operation:morph:modal-miss", {
|
||||
attempt,
|
||||
rootExists,
|
||||
byClassCount,
|
||||
byRootCount,
|
||||
modalCount,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear any leftover transform from previous close animation before measuring.
|
||||
operationMorphAnimationRef.current?.cancel()
|
||||
for (const animation of modalEl.getAnimations()) {
|
||||
animation.cancel()
|
||||
}
|
||||
modalEl.style.transform = ""
|
||||
modalEl.style.opacity = ""
|
||||
modalEl.style.visibility = ""
|
||||
const maskEl = document.querySelector(`.${operationModalRootClass} .ant-modal-mask`) as
|
||||
| HTMLElement
|
||||
| null
|
||||
if (maskEl) {
|
||||
operationMaskAnimationRef.current?.cancel()
|
||||
maskEl.style.opacity = ""
|
||||
maskEl.style.visibility = ""
|
||||
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: 220,
|
||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
fill: "both",
|
||||
})
|
||||
}
|
||||
const modalRect = modalEl.getBoundingClientRect()
|
||||
if (modalRect.width <= 0 || modalRect.height <= 0) {
|
||||
logHome("operation:morph:modal-rect-invalid", {
|
||||
attempt,
|
||||
width: modalRect.width,
|
||||
height: modalRect.height,
|
||||
})
|
||||
return false
|
||||
}
|
||||
const fromX = operationOriginRect.left - modalRect.left
|
||||
const fromY = operationOriginRect.top - modalRect.top
|
||||
const scaleX = operationOriginRect.width / modalRect.width
|
||||
const scaleY = operationOriginRect.height / modalRect.height
|
||||
|
||||
logHome("operation:morph:computed", {
|
||||
modalRect: {
|
||||
left: modalRect.left,
|
||||
top: modalRect.top,
|
||||
width: modalRect.width,
|
||||
height: modalRect.height,
|
||||
},
|
||||
fromX,
|
||||
fromY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
expectedStartRect: {
|
||||
left: modalRect.left + fromX,
|
||||
top: modalRect.top + fromY,
|
||||
width: modalRect.width * scaleX,
|
||||
height: modalRect.height * scaleY,
|
||||
},
|
||||
animateSupported: typeof modalEl.animate === "function",
|
||||
})
|
||||
|
||||
modalEl.style.transformOrigin = "top left"
|
||||
modalEl.style.willChange = "transform, opacity"
|
||||
operationMorphAnimationRef.current = modalEl.animate(
|
||||
[
|
||||
{
|
||||
transform: `translate3d(${fromX}px, ${fromY}px, 0) scale(${scaleX}, ${scaleY})`,
|
||||
opacity: 0.86,
|
||||
},
|
||||
{
|
||||
transform: "translate3d(0, 0, 0) scale(1, 1)",
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 480,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
fill: "both",
|
||||
}
|
||||
)
|
||||
logHome("operation:morph:animation-start", {
|
||||
attempt,
|
||||
currentTime: operationMorphAnimationRef.current.currentTime,
|
||||
playState: operationMorphAnimationRef.current.playState,
|
||||
})
|
||||
operationMorphAnimationRef.current.onfinish = () => {
|
||||
logHome("operation:morph:animation-finish")
|
||||
}
|
||||
operationMorphAnimationRef.current.oncancel = () => {
|
||||
logHome("operation:morph:animation-cancel")
|
||||
}
|
||||
return true
|
||||
}, [isPortraitMode, operationOriginRect, operationModalClass, operationModalRootClass])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isPortraitMode || !operationVisible) return
|
||||
|
||||
logHome("operation:morph:layout-open", {
|
||||
hasOriginRect: Boolean(operationOriginRect),
|
||||
})
|
||||
let attempt = 0
|
||||
const maxAttempts = 120
|
||||
|
||||
const run = () => {
|
||||
operationMorphRafRef.current = null
|
||||
const done = playOperationMorph(attempt)
|
||||
if (done) return
|
||||
if (attempt >= maxAttempts) {
|
||||
logHome("operation:morph:retry-exhausted", { attempts: attempt + 1 })
|
||||
return
|
||||
}
|
||||
attempt += 1
|
||||
operationMorphRafRef.current = window.requestAnimationFrame(run)
|
||||
}
|
||||
operationMorphRafRef.current = window.requestAnimationFrame(run)
|
||||
|
||||
return () => {
|
||||
if (operationMorphRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(operationMorphRafRef.current)
|
||||
operationMorphRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isPortraitMode, operationVisible, operationOriginRect, playOperationMorph])
|
||||
|
||||
const finishCloseOperationModal = useCallback((skipCancelMorph = false, skipCancelMask = false) => {
|
||||
if (!skipCancelMorph) {
|
||||
operationMorphAnimationRef.current?.cancel()
|
||||
}
|
||||
operationMorphAnimationRef.current = null
|
||||
if (!skipCancelMask) {
|
||||
operationMaskAnimationRef.current?.cancel()
|
||||
}
|
||||
operationMaskAnimationRef.current = null
|
||||
if (operationMorphRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(operationMorphRafRef.current)
|
||||
operationMorphRafRef.current = null
|
||||
}
|
||||
operationClosingRef.current = false
|
||||
setOperationVisible(false)
|
||||
setOperationOriginRect(null)
|
||||
}, [])
|
||||
|
||||
const closeOperationModal = useCallback(() => {
|
||||
logHome("operation:morph:close", {
|
||||
hasAnimation: Boolean(operationMorphAnimationRef.current),
|
||||
playState: operationMorphAnimationRef.current?.playState,
|
||||
currentTime: operationMorphAnimationRef.current?.currentTime,
|
||||
})
|
||||
if (operationMorphRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(operationMorphRafRef.current)
|
||||
operationMorphRafRef.current = null
|
||||
}
|
||||
if (
|
||||
!isPortraitMode &&
|
||||
operationVisible &&
|
||||
operationOriginRect &&
|
||||
!operationClosingRef.current
|
||||
) {
|
||||
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
|
||||
if (modalEl) {
|
||||
const modalRect = modalEl.getBoundingClientRect()
|
||||
if (modalRect.width > 0 && modalRect.height > 0) {
|
||||
const toX = operationOriginRect.left - modalRect.left
|
||||
const toY = operationOriginRect.top - modalRect.top
|
||||
const toScaleX = operationOriginRect.width / modalRect.width
|
||||
const toScaleY = operationOriginRect.height / modalRect.height
|
||||
const closeToken = ++operationCloseTokenRef.current
|
||||
operationClosingRef.current = true
|
||||
operationMorphAnimationRef.current?.cancel()
|
||||
operationMaskAnimationRef.current?.cancel()
|
||||
modalEl.style.transformOrigin = "top left"
|
||||
modalEl.style.willChange = "transform, opacity"
|
||||
const maskEl = document.querySelector(
|
||||
`.${operationModalRootClass} .ant-modal-mask`
|
||||
) as HTMLElement | null
|
||||
if (maskEl) {
|
||||
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 320,
|
||||
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
||||
fill: "both",
|
||||
})
|
||||
}
|
||||
operationMorphAnimationRef.current = modalEl.animate(
|
||||
[
|
||||
{ transform: "translate3d(0, 0, 0) scale(1, 1)", opacity: 1 },
|
||||
{
|
||||
transform: `translate3d(${toX}px, ${toY}px, 0) scale(${toScaleX}, ${toScaleY})`,
|
||||
opacity: 0,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 320,
|
||||
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
||||
fill: "both",
|
||||
}
|
||||
)
|
||||
operationMorphAnimationRef.current.onfinish = () => {
|
||||
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
||||
logHome("operation:morph:close-animation-finish:stale", { closeToken })
|
||||
return
|
||||
}
|
||||
logHome("operation:morph:close-animation-finish")
|
||||
modalEl.style.opacity = "0"
|
||||
modalEl.style.visibility = "hidden"
|
||||
if (maskEl) {
|
||||
maskEl.style.opacity = "0"
|
||||
maskEl.style.visibility = "hidden"
|
||||
}
|
||||
finishCloseOperationModal(true, true)
|
||||
}
|
||||
operationMorphAnimationRef.current.oncancel = () => {
|
||||
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
||||
logHome("operation:morph:close-animation-cancel:stale", { closeToken })
|
||||
return
|
||||
}
|
||||
logHome("operation:morph:close-animation-cancel")
|
||||
finishCloseOperationModal()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
finishCloseOperationModal()
|
||||
}, [
|
||||
finishCloseOperationModal,
|
||||
isPortraitMode,
|
||||
operationModalClass,
|
||||
operationOriginRect,
|
||||
operationVisible,
|
||||
])
|
||||
|
||||
const handleToggleRewardMode = () => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
@@ -623,7 +906,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
setRewardMode((prev) => !prev)
|
||||
setBatchMode(false)
|
||||
setSelectedStudentIds([])
|
||||
setOperationVisible(false)
|
||||
closeOperationModal()
|
||||
setQuickActionStudentId(null)
|
||||
setRewardStudent(null)
|
||||
setRewardModalVisible(false)
|
||||
@@ -717,7 +1000,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
setSelectedStudentIds([])
|
||||
setBatchMode(false)
|
||||
setSelectedStudent(null)
|
||||
setOperationVisible(false)
|
||||
closeOperationModal()
|
||||
setCustomScore(undefined)
|
||||
setReasonContent("")
|
||||
setQuickActionStudentId(null)
|
||||
@@ -846,7 +1129,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
}
|
||||
setBatchMode(true)
|
||||
setSelectedStudent(null)
|
||||
setOperationVisible(false)
|
||||
closeOperationModal()
|
||||
setQuickActionStudentId(null)
|
||||
}
|
||||
|
||||
@@ -870,6 +1153,140 @@ export const Home: React.FC<HomeProps> = ({
|
||||
setOperationVisible(true)
|
||||
}
|
||||
|
||||
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result === "string") resolve(result)
|
||||
else reject(new Error("invalid-result"))
|
||||
}
|
||||
reader.onerror = () => reject(reader.error || new Error("read-failed"))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const handleAvatarEditorFileChange = async (file?: File) => {
|
||||
if (!file) return
|
||||
if (!file.type.startsWith("image/")) {
|
||||
messageApi.error(t("students.avatarInvalidFile"))
|
||||
return
|
||||
}
|
||||
const maxSize = 2 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
messageApi.error(t("students.avatarTooLarge"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dataUrl = await readFileAsDataUrl(file)
|
||||
setAvatarEditorValue(dataUrl)
|
||||
} catch {
|
||||
messageApi.error(t("students.avatarReadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarPasteFromClipboard = async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.read) {
|
||||
messageApi.error(t("students.avatarClipboardUnsupported"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read()
|
||||
for (const item of clipboardItems) {
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"))
|
||||
if (!imageType) continue
|
||||
const blob = await item.getType(imageType)
|
||||
const ext = imageType.split("/")[1] || "png"
|
||||
const file = new File([blob], `avatar-clipboard.${ext}`, { type: imageType })
|
||||
await handleAvatarEditorFileChange(file)
|
||||
return
|
||||
}
|
||||
messageApi.warning(t("students.avatarClipboardNoImage"))
|
||||
} catch {
|
||||
messageApi.error(t("students.avatarClipboardReadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const openRenameStudent = (target: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setRenameStudent(target)
|
||||
setRenameValue(target.name)
|
||||
setRenameVisible(true)
|
||||
}
|
||||
|
||||
const handleRenameStudent = async () => {
|
||||
if (!renameStudent || !(window as any).api) return
|
||||
const nextName = renameValue.trim()
|
||||
if (!nextName) {
|
||||
messageApi.warning(`${t("common.pleaseEnter")} ${t("common.name")}`)
|
||||
return
|
||||
}
|
||||
if (nextName === renameStudent.name) {
|
||||
setRenameVisible(false)
|
||||
setRenameStudent(null)
|
||||
setRenameValue("")
|
||||
return
|
||||
}
|
||||
setRenameSaving(true)
|
||||
try {
|
||||
const res = await (window as any).api.updateStudent(renameStudent.id, { name: nextName })
|
||||
if (res?.success) {
|
||||
messageApi.success(t("common.success"))
|
||||
setRenameVisible(false)
|
||||
setRenameStudent(null)
|
||||
setRenameValue("")
|
||||
await fetchData(true)
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
messageApi.error(res?.message || t("home.submitFailed"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("home.submitFailed"))
|
||||
} finally {
|
||||
setRenameSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAvatarEditor = (target: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setAvatarEditorStudent(target)
|
||||
setAvatarEditorValue(target.avatarUrl || null)
|
||||
setAvatarEditorVisible(true)
|
||||
}
|
||||
|
||||
const handleSaveAvatarEditor = async () => {
|
||||
if (!avatarEditorStudent || !(window as any).api) return
|
||||
setAvatarEditorSaving(true)
|
||||
try {
|
||||
const latest =
|
||||
students.find((item) => item.id === avatarEditorStudent.id) || avatarEditorStudent
|
||||
const payload = setAvatarInExtraJson(latest.extra_json, avatarEditorValue)
|
||||
const res = await (window as any).api.updateStudent(avatarEditorStudent.id, {
|
||||
extra_json: payload,
|
||||
})
|
||||
if (res?.success) {
|
||||
messageApi.success(t("students.avatarSaveSuccess"))
|
||||
setAvatarEditorVisible(false)
|
||||
setAvatarEditorStudent(null)
|
||||
setAvatarEditorValue(null)
|
||||
await fetchData(true)
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
messageApi.error(res?.message || t("students.avatarSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t("students.avatarSaveFailed"))
|
||||
} finally {
|
||||
setAvatarEditorSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStudentCard = (student: student, index: number) => {
|
||||
const avatarText = getDisplayText(student.name)
|
||||
const avatarColor = getAvatarColor(student.name)
|
||||
@@ -895,7 +1312,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
openOperation(student)
|
||||
openOperation(student, e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (batchMode) return
|
||||
@@ -1113,7 +1530,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
openOperation(student)
|
||||
openOperation(student, e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (batchMode) return
|
||||
@@ -1280,7 +1697,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
openOperation(student)
|
||||
openOperation(student, e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (batchMode) return
|
||||
@@ -1476,6 +1893,271 @@ export const Home: React.FC<HomeProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const renderStudentLargeAvatarCard = (student: student, index: number) => {
|
||||
const avatarText = getDisplayText(student.name)
|
||||
const avatarColor = getAvatarColor(student.name)
|
||||
const isQuickActionMode = quickActionStudentId === student.id
|
||||
const isSelected = selectedStudentIds.includes(student.id)
|
||||
const displayPoints = getDisplayPoints(student)
|
||||
const scoreColor = displayPoints > 0 ? "#52c41a" : displayPoints < 0 ? "#ff4d4f" : "#595959"
|
||||
|
||||
let rankBadge: string | null = null
|
||||
if (sortType === "score" && !searchKeyword) {
|
||||
if (index === 0) rankBadge = "🥇"
|
||||
else if (index === 1) rankBadge = "🥈"
|
||||
else if (index === 2) rankBadge = "🥉"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
data-student-quick-card="true"
|
||||
onClick={(e) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
openOperation(student, e.currentTarget as HTMLElement)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (batchMode) return
|
||||
if (e.button !== 0) return
|
||||
startLongPress(student)
|
||||
}}
|
||||
onMouseUp={cancelLongPress}
|
||||
onMouseLeave={cancelLongPress}
|
||||
onTouchStart={() => {
|
||||
if (batchMode) return
|
||||
startLongPress(student)
|
||||
}}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onContextMenu={(e) => {
|
||||
if (batchMode) return
|
||||
e.preventDefault()
|
||||
openQuickAction(student)
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
aspectRatio: "1.2 / 1",
|
||||
}}
|
||||
ref={(el) => {
|
||||
groupedStudents.forEach((group) => {
|
||||
if (group.key === "all") return
|
||||
if (firstStudentIdByGroup.get(group.key) === student.id) {
|
||||
groupRefs.current[group.key] = el
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
transition: "all 0.2s cubic-bezier(0.38, 0, 0.24, 1)",
|
||||
border: isQuickActionMode
|
||||
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||
: isSelected
|
||||
? "1px solid var(--ant-color-primary, #1677ff)"
|
||||
: "1px solid var(--ss-border-color)",
|
||||
overflow: "hidden",
|
||||
borderRadius: "18px",
|
||||
boxShadow:
|
||||
isQuickActionMode || isSelected ? "0 8px 18px rgba(22, 119, 255, 0.18)" : undefined,
|
||||
}}
|
||||
styles={{ body: { height: "100%", padding: 0 } }}
|
||||
>
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
{student.avatarUrl ? (
|
||||
<img
|
||||
src={student.avatarUrl}
|
||||
alt={student.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: `linear-gradient(140deg, ${avatarColor} 0%, ${avatarColor}aa 100%)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
fontWeight: 700,
|
||||
fontSize: avatarText.length > 1 ? "46px" : "56px",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(255,255,255,0) 56%, rgba(255,255,255,0.72) 84%, rgba(255,255,255,0.95) 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{rankBadge && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 10,
|
||||
fontSize: "24px",
|
||||
lineHeight: 1,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{rankBadge}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<Tag
|
||||
color="processing"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
marginInlineEnd: 0,
|
||||
zIndex: 2,
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{t("home.selected")}
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{isQuickActionMode ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "10px",
|
||||
zIndex: 3,
|
||||
background: "rgba(0, 0, 0, 0.18)",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleQuickAdjust(student, 1)
|
||||
}}
|
||||
style={{
|
||||
minWidth: "52px",
|
||||
height: "32px",
|
||||
borderRadius: "16px",
|
||||
fontWeight: 700,
|
||||
paddingInline: "10px",
|
||||
}}
|
||||
>
|
||||
+1
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleQuickAdjust(student, -1)
|
||||
}}
|
||||
style={{
|
||||
minWidth: "52px",
|
||||
height: "32px",
|
||||
borderRadius: "16px",
|
||||
fontWeight: 700,
|
||||
paddingInline: "10px",
|
||||
}}
|
||||
>
|
||||
-1
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={["contextMenu"]}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "rename", label: "改名", disabled: !canEdit },
|
||||
{ key: "avatar", label: t("students.editAvatar"), disabled: !canEdit },
|
||||
],
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation()
|
||||
if (key === "rename") openRenameStudent(student)
|
||||
if (key === "avatar") openAvatarEditor(student)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: "65%",
|
||||
fontWeight: 700,
|
||||
fontSize: "22px",
|
||||
lineHeight: 1,
|
||||
color: "#111",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
background: "rgba(255,255,255,0.62)",
|
||||
border: "1px solid rgba(255,255,255,0.82)",
|
||||
borderRadius: "8px",
|
||||
padding: "3px 9px",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{student.name}
|
||||
</div>
|
||||
</Dropdown>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: "28px",
|
||||
lineHeight: 1,
|
||||
color: scoreColor,
|
||||
background: "rgba(255,255,255,0.62)",
|
||||
border: "1px solid rgba(255,255,255,0.82)",
|
||||
borderRadius: "8px",
|
||||
padding: "2px 9px",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
{displayPoints > 0 ? `+${displayPoints}` : displayPoints}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroupedCards = () => {
|
||||
if (layoutType === "squareGrid") {
|
||||
return (
|
||||
@@ -1491,6 +2173,20 @@ export const Home: React.FC<HomeProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutType === "largeAvatar") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||
gap: isPortraitMode ? "10px" : "14px",
|
||||
}}
|
||||
>
|
||||
{sortedStudents.map((student, idx) => renderStudentLargeAvatarCard(student, idx))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return groupedStudents.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
@@ -2394,6 +3090,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
{ value: "largeAvatar", label: t("home.layoutBy.largeAvatar") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -2522,6 +3219,114 @@ export const Home: React.FC<HomeProps> = ({
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`${t("common.edit")} ${t("common.name")} - ${renameStudent?.name || ""}`}
|
||||
open={renameVisible}
|
||||
onCancel={() => {
|
||||
setRenameVisible(false)
|
||||
setRenameStudent(null)
|
||||
setRenameValue("")
|
||||
}}
|
||||
onOk={handleRenameStudent}
|
||||
okButtonProps={{ loading: renameSaving, disabled: !renameValue.trim() }}
|
||||
okText={t("common.save")}
|
||||
cancelText={t("common.cancel")}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder={`${t("common.pleaseEnter")} ${t("common.name")}`}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
onPressEnter={() => {
|
||||
if (!renameSaving && renameValue.trim()) handleRenameStudent()
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t("students.editAvatarTitle", { name: avatarEditorStudent?.name || "" })}
|
||||
open={avatarEditorVisible}
|
||||
onCancel={() => {
|
||||
setAvatarEditorVisible(false)
|
||||
setAvatarEditorStudent(null)
|
||||
setAvatarEditorValue(null)
|
||||
}}
|
||||
onOk={handleSaveAvatarEditor}
|
||||
okButtonProps={{ loading: avatarEditorSaving, disabled: !avatarEditorStudent }}
|
||||
okText={t("common.save")}
|
||||
cancelText={t("common.cancel")}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "8px 0" }}>
|
||||
{avatarEditorValue ? (
|
||||
<img
|
||||
src={avatarEditorValue}
|
||||
alt={avatarEditorStudent?.name || "avatar"}
|
||||
style={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: "50%",
|
||||
border: "1px dashed var(--ss-border-color)",
|
||||
backgroundColor: "var(--ss-bg-color)",
|
||||
color: "var(--ss-text-secondary)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{t("students.noAvatar")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t("students.avatarUpload")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleAvatarPasteFromClipboard}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t("students.avatarClipboardImport")}
|
||||
</Button>
|
||||
<Button onClick={() => setAvatarEditorValue(null)} disabled={!canEdit}>
|
||||
{t("students.avatarClear")}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
handleAvatarEditorFileChange(file)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||
{t("students.avatarTip")}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div
|
||||
ref={immersiveToolbarRef}
|
||||
data-immersive-toolbar="true"
|
||||
@@ -2599,6 +3404,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
{ value: "largeAvatar", label: t("home.layoutBy.largeAvatar") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -2705,7 +3511,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
placement="bottom"
|
||||
height="100%"
|
||||
open={operationVisible}
|
||||
onClose={() => setOperationVisible(false)}
|
||||
onClose={closeOperationModal}
|
||||
afterOpenChange={applyDrawerDragRegion}
|
||||
destroyOnClose
|
||||
styles={{
|
||||
@@ -2713,7 +3519,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
}}
|
||||
footer={
|
||||
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
|
||||
<Button onClick={() => setOperationVisible(false)}>{t("common.cancel")}</Button>
|
||||
<Button onClick={closeOperationModal}>{t("common.cancel")}</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitLoading}>
|
||||
{t("home.submitOperation")}
|
||||
</Button>
|
||||
@@ -2730,13 +3536,18 @@ export const Home: React.FC<HomeProps> = ({
|
||||
: t("home.operationTitle", { name: selectedStudent?.name })
|
||||
}
|
||||
open={operationVisible}
|
||||
onCancel={() => setOperationVisible(false)}
|
||||
onCancel={closeOperationModal}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitLoading}
|
||||
okText={t("home.submitOperation")}
|
||||
cancelText={t("common.cancel")}
|
||||
width={560}
|
||||
centered
|
||||
forceRender
|
||||
transitionName=""
|
||||
maskTransitionName=""
|
||||
rootClassName={operationModalRootClass}
|
||||
className={operationModalClass}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: "calc(100vh - 220px)",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, message, Modal, Space, Spin } from "antd"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { open } from "@tauri-apps/plugin-shell"
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event"
|
||||
@@ -28,20 +28,29 @@ interface OAuthCallbackResult {
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
// 全局锁,确保同一时间只有一个回调在处理
|
||||
let isProcessingCallback = false
|
||||
// 全局监听器引用,确保只有一个监听器
|
||||
let globalUnlisten: UnlistenFn | null = null
|
||||
// 已处理事件ID集合,用于精确去重
|
||||
const processedEventIds = new Set<string>()
|
||||
const OAUTH_EXPECTED_STATE_KEY = "oauth_expected_state"
|
||||
const OAUTH_CODE_VERIFIER_KEY = "oauth_code_verifier"
|
||||
const OAUTH_CALLBACK_URL_KEY = "oauth_callback_url"
|
||||
const DEFAULT_CALLBACK_URL = "http://127.0.0.1:16888/oauth/callback"
|
||||
|
||||
const getExpectedState = () => sessionStorage.getItem(OAUTH_EXPECTED_STATE_KEY)
|
||||
const setExpectedState = (state: string) => sessionStorage.setItem(OAUTH_EXPECTED_STATE_KEY, state)
|
||||
const clearExpectedState = () => sessionStorage.removeItem(OAUTH_EXPECTED_STATE_KEY)
|
||||
|
||||
const getCodeVerifier = () => sessionStorage.getItem(OAUTH_CODE_VERIFIER_KEY)
|
||||
const setCodeVerifier = (codeVerifier: string) =>
|
||||
sessionStorage.setItem(OAUTH_CODE_VERIFIER_KEY, codeVerifier)
|
||||
const clearCodeVerifier = () => sessionStorage.removeItem(OAUTH_CODE_VERIFIER_KEY)
|
||||
|
||||
const getCallbackUrl = () => sessionStorage.getItem(OAUTH_CALLBACK_URL_KEY) || DEFAULT_CALLBACK_URL
|
||||
const setCallbackUrl = (callbackUrl: string) =>
|
||||
sessionStorage.setItem(OAUTH_CALLBACK_URL_KEY, callbackUrl)
|
||||
const clearCallbackUrl = () => sessionStorage.removeItem(OAUTH_CALLBACK_URL_KEY)
|
||||
|
||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
// 使用 sessionStorage 存储 state,防止组件重新渲染导致丢失
|
||||
const getExpectedState = () => sessionStorage.getItem("oauth_expected_state")
|
||||
const setExpectedState = (state: string) => sessionStorage.setItem("oauth_expected_state", state)
|
||||
const clearExpectedState = () => sessionStorage.removeItem("oauth_expected_state")
|
||||
const isProcessingCallbackRef = useRef(false)
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const api = (window as any).api
|
||||
@@ -60,20 +69,25 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthCallback = async (result: OAuthCallbackResult) => {
|
||||
// 生成事件唯一标识(使用code和state的组合,如果没有则使用时间戳)
|
||||
const eventId = result.code && result.state
|
||||
? `${result.code}_${result.state}`
|
||||
: `error_${result.error}_${Date.now()}`
|
||||
|
||||
// 检查事件是否已被处理
|
||||
if (processedEventIds.has(eventId)) {
|
||||
console.log("[OAuth] 事件已被处理,跳过:", eventId)
|
||||
return
|
||||
const stopCallbackServer = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.oauthStopCallbackServer) return
|
||||
try {
|
||||
await api.oauthStopCallbackServer()
|
||||
} catch (error) {
|
||||
console.error("[OAuth] 停止回调服务器失败:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 全局锁检查,防止并发处理
|
||||
if (isProcessingCallback) {
|
||||
const clearOAuthTempState = useCallback(() => {
|
||||
clearExpectedState()
|
||||
clearCodeVerifier()
|
||||
clearCallbackUrl()
|
||||
}, [])
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (result: OAuthCallbackResult) => {
|
||||
if (isProcessingCallbackRef.current) {
|
||||
console.log("[OAuth] 回调正在处理中,跳过")
|
||||
return
|
||||
}
|
||||
@@ -82,43 +96,45 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const config = getOAuthConfig()
|
||||
if (!config) {
|
||||
console.error("[OAuth] 配置不存在")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessingCallback = true
|
||||
processedEventIds.add(eventId)
|
||||
|
||||
if (result.error) {
|
||||
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||
message.error(result.error_description || result.error || "授权失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.code) {
|
||||
console.log("[OAuth] 授权码:", result.code)
|
||||
console.log("[OAuth] State:", result.state, "期望:", getExpectedState())
|
||||
|
||||
// 验证 state 防止 CSRF
|
||||
const expectedState = getExpectedState()
|
||||
if (expectedState && result.state !== expectedState) {
|
||||
message.error("安全验证失败:state 不匹配")
|
||||
message.error("OAuth 配置未设置")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const api = (window as any).api
|
||||
const callbackUrl = "http://127.0.0.1:16888/oauth/callback"
|
||||
|
||||
// 获取存储的 code_verifier
|
||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier")
|
||||
if (!codeVerifier) {
|
||||
message.error("安全验证失败:缺少 code_verifier")
|
||||
if (!api) {
|
||||
message.error("API 不可用")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessingCallbackRef.current = true
|
||||
|
||||
if (result.error) {
|
||||
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||
message.error(result.error_description || result.error || "授权失败")
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.code) return
|
||||
|
||||
console.log("[OAuth] 授权码:", result.code)
|
||||
console.log("[OAuth] State:", result.state, "期望:", getExpectedState())
|
||||
|
||||
const expectedState = getExpectedState()
|
||||
if (expectedState && result.state !== expectedState) {
|
||||
message.error("安全验证失败:state 不匹配")
|
||||
return
|
||||
}
|
||||
|
||||
const codeVerifier = getCodeVerifier()
|
||||
if (!codeVerifier) {
|
||||
message.error("安全验证失败:缺少 code_verifier")
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = getCallbackUrl()
|
||||
console.log("[OAuth] 开始换取 token...")
|
||||
const tokenRes = await api.oauthExchangeCode(
|
||||
result.code,
|
||||
@@ -131,7 +147,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
|
||||
if (!tokenRes.success) {
|
||||
message.error(tokenRes.message || "获取访问令牌失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,13 +156,11 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
|
||||
if (!userRes.success) {
|
||||
message.error(userRes.message || "获取用户信息失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[OAuth] 登录成功,保存登录状态...")
|
||||
|
||||
// 保存登录状态到本地
|
||||
const loginState = {
|
||||
access_token: tokenRes.data.access_token,
|
||||
refresh_token: tokenRes.data.refresh_token,
|
||||
@@ -168,61 +181,65 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
console.error("[OAuth] 保存登录状态失败:", saveError)
|
||||
}
|
||||
|
||||
console.log("[OAuth] 清理资源...")
|
||||
await api.oauthStopCallbackServer()
|
||||
clearExpectedState()
|
||||
sessionStorage.removeItem("oauth_code_verifier")
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:oauth-user-updated", {
|
||||
detail: { user: userRes.data },
|
||||
})
|
||||
)
|
||||
console.log("[OAuth] 调用 onSuccess...")
|
||||
onSuccess(userRes.data)
|
||||
console.log("[OAuth] 调用 onClose...")
|
||||
onClose()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[OAuth] 处理回调时发生错误:", error)
|
||||
message.error(error.message || "登录失败")
|
||||
} finally {
|
||||
console.log("[OAuth] 回调处理完成,重置状态")
|
||||
await stopCallbackServer()
|
||||
clearOAuthTempState()
|
||||
setLoading(false)
|
||||
// 立即释放锁,无需延迟
|
||||
isProcessingCallback = false
|
||||
console.log("[OAuth] 全局锁已释放")
|
||||
|
||||
// 5分钟后清理该事件ID,防止内存泄漏
|
||||
setTimeout(() => {
|
||||
processedEventIds.delete(eventId)
|
||||
console.log("[OAuth] 事件ID已清理:", eventId)
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
isProcessingCallbackRef.current = false
|
||||
console.log("[OAuth] 局部锁已释放")
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
[clearOAuthTempState, onClose, onSuccess, stopCallbackServer]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const setupListener = async () => {
|
||||
// 如果已经有全局监听器,不再创建新的
|
||||
if (globalUnlisten) {
|
||||
console.log("[OAuth] 全局监听器已存在,跳过创建")
|
||||
return
|
||||
}
|
||||
if (!visible) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let disposed = false
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||
unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
||||
if (event.payload) {
|
||||
await handleOAuthCallback(event.payload)
|
||||
}
|
||||
})
|
||||
globalUnlisten = unlisten
|
||||
console.log("[OAuth] 全局监听器已创建")
|
||||
|
||||
if (disposed) {
|
||||
unlisten?.()
|
||||
unlisten = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to setup OAuth callback listener:", error)
|
||||
}
|
||||
}
|
||||
|
||||
setupListener()
|
||||
void setupListener()
|
||||
|
||||
// 组件卸载时不取消监听,保持全局监听
|
||||
// 只在应用完全关闭时才清理
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return () => {
|
||||
disposed = true
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
}
|
||||
}, [handleOAuthCallback, visible])
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const config = getOAuthConfig()
|
||||
@@ -244,8 +261,8 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
}
|
||||
|
||||
const callbackUrl = serverRes.data.url
|
||||
setCallbackUrl(callbackUrl)
|
||||
|
||||
// 生成随机 state 防止 CSRF
|
||||
const state = generateRandomState()
|
||||
console.log("[OAuth] 生成 state:", state)
|
||||
setExpectedState(state)
|
||||
@@ -259,8 +276,7 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
return
|
||||
}
|
||||
|
||||
// 存储 code_verifier 用于后续换取 token
|
||||
sessionStorage.setItem("oauth_code_verifier", urlRes.data.code_verifier)
|
||||
setCodeVerifier(urlRes.data.code_verifier)
|
||||
console.log("[OAuth] code_verifier 已存储")
|
||||
|
||||
await open(urlRes.data.url)
|
||||
@@ -270,7 +286,13 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机 state 字符串
|
||||
const handleModalClose = () => {
|
||||
setLoading(false)
|
||||
clearOAuthTempState()
|
||||
void stopCallbackServer()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const generateRandomState = (): string => {
|
||||
const array = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(array)
|
||||
@@ -284,7 +306,7 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
<Modal
|
||||
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onCancel={handleModalClose}
|
||||
footer={null}
|
||||
width={400}
|
||||
centered
|
||||
|
||||
@@ -35,6 +35,14 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [exchangeMode, setExchangeMode] = useState(false)
|
||||
const [selectedStudent, setSelectedStudent] = useState<StudentItem | null>(null)
|
||||
const [chooseRewardVisible, setChooseRewardVisible] = useState(false)
|
||||
const [modalMorphStage, setModalMorphStage] = useState<"idle" | "measuring" | "enter">("idle")
|
||||
const [modalMorphVars, setModalMorphVars] = useState<{
|
||||
fromX: number
|
||||
fromY: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
} | null>(null)
|
||||
const [originCardRect, setOriginCardRect] = useState<DOMRect | null>(null)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const emitDataUpdated = () => {
|
||||
@@ -90,16 +98,75 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return rewards.filter((r) => r.cost_points <= selectedStudent.reward_points)
|
||||
}, [rewards, selectedStudent])
|
||||
|
||||
const handleStudentClick = (student: StudentItem) => {
|
||||
const handleStudentClick = (student: StudentItem, event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!exchangeMode) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
|
||||
const cardBody = event.currentTarget.querySelector(".ant-card-body") as HTMLElement | null
|
||||
const sourceRect = (cardBody ?? event.currentTarget).getBoundingClientRect()
|
||||
setOriginCardRect(sourceRect)
|
||||
setModalMorphVars(null)
|
||||
setModalMorphStage("measuring")
|
||||
setSelectedStudent(student)
|
||||
setChooseRewardVisible(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chooseRewardVisible || !originCardRect || modalMorphStage !== "measuring") {
|
||||
return
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const modalEl = document.querySelector(".ss-reward-morph-modal.ant-modal") as HTMLElement | null
|
||||
if (!modalEl) {
|
||||
setModalMorphStage("idle")
|
||||
return
|
||||
}
|
||||
|
||||
const modalRect = modalEl.getBoundingClientRect()
|
||||
const fromX =
|
||||
originCardRect.left + originCardRect.width / 2 - (modalRect.left + modalRect.width / 2)
|
||||
const fromY =
|
||||
originCardRect.top + originCardRect.height / 2 - (modalRect.top + modalRect.height / 2)
|
||||
const scaleX = Math.min(Math.max(originCardRect.width / modalRect.width, 0.28), 1)
|
||||
const scaleY = Math.min(Math.max(originCardRect.height / modalRect.height, 0.2), 1)
|
||||
|
||||
setModalMorphVars({ fromX, fromY, scaleX, scaleY })
|
||||
window.requestAnimationFrame(() => {
|
||||
setModalMorphStage("enter")
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [chooseRewardVisible, modalMorphStage, originCardRect])
|
||||
|
||||
const resetModalState = () => {
|
||||
setChooseRewardVisible(false)
|
||||
setSelectedStudent(null)
|
||||
setModalMorphStage("idle")
|
||||
setModalMorphVars(null)
|
||||
setOriginCardRect(null)
|
||||
}
|
||||
|
||||
const modalClassName =
|
||||
modalMorphStage === "measuring"
|
||||
? "ss-reward-morph-modal is-preparing"
|
||||
: modalMorphStage === "enter"
|
||||
? "ss-reward-morph-modal is-enter"
|
||||
: "ss-reward-morph-modal"
|
||||
|
||||
const modalStyle = {
|
||||
"--ss-modal-from-x": `${modalMorphVars?.fromX ?? 0}px`,
|
||||
"--ss-modal-from-y": `${modalMorphVars?.fromY ?? 0}px`,
|
||||
"--ss-modal-scale-x": `${modalMorphVars?.scaleX ?? 1}`,
|
||||
"--ss-modal-scale-y": `${modalMorphVars?.scaleY ?? 1}`,
|
||||
} as React.CSSProperties
|
||||
|
||||
const handleRedeem = async (reward: RewardItem) => {
|
||||
if (!(window as any).api || !selectedStudent) return
|
||||
if (!canEdit) {
|
||||
@@ -120,8 +187,7 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
points: reward.cost_points,
|
||||
})
|
||||
)
|
||||
setChooseRewardVisible(false)
|
||||
setSelectedStudent(null)
|
||||
resetModalState()
|
||||
setExchangeMode(false)
|
||||
fetchData()
|
||||
emitDataUpdated()
|
||||
@@ -199,7 +265,7 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<Card
|
||||
key={student.id}
|
||||
hoverable={exchangeMode}
|
||||
onClick={() => handleStudentClick(student)}
|
||||
onClick={(event) => handleStudentClick(student, event)}
|
||||
style={{
|
||||
cursor: exchangeMode ? "pointer" : "default",
|
||||
border: exchangeMode
|
||||
@@ -234,11 +300,14 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<Modal
|
||||
title={t("rewardExchange.chooseRewardTitle", { name: selectedStudent?.name || "" })}
|
||||
open={chooseRewardVisible}
|
||||
onCancel={() => {
|
||||
setChooseRewardVisible(false)
|
||||
setSelectedStudent(null)
|
||||
}}
|
||||
onCancel={resetModalState}
|
||||
footer={null}
|
||||
className={modalClassName}
|
||||
rootClassName="ss-reward-morph-root"
|
||||
wrapClassName="ss-reward-morph-wrap"
|
||||
style={modalStyle}
|
||||
transitionName="ss-reward-noop-motion"
|
||||
maskTransitionName="ss-reward-noop-motion"
|
||||
destroyOnHidden
|
||||
>
|
||||
{!selectedStudent ? null : (
|
||||
|
||||
@@ -206,6 +206,12 @@ export const Settings: React.FC<{
|
||||
permission: number
|
||||
} | null>(null)
|
||||
|
||||
const getOAuthPermissionLabel = (oauthPermission: number) => {
|
||||
if (oauthPermission >= 18) return t("permissions.admin")
|
||||
if (oauthPermission >= 1) return t("permissions.points")
|
||||
return t("permissions.view")
|
||||
}
|
||||
|
||||
const permissionTag = useMemo(() => {
|
||||
return (
|
||||
<Tag
|
||||
@@ -312,10 +318,41 @@ export const Settings: React.FC<{
|
||||
}
|
||||
const authRes = await api.authGetStatus()
|
||||
if (authRes.success && authRes.data) setSecurityStatus(authRes.data)
|
||||
|
||||
const oauthStateRes = await api.oauthLoadLoginState()
|
||||
if (oauthStateRes?.success && oauthStateRes.data) {
|
||||
setOAuthUserInfo({
|
||||
user_id: oauthStateRes.data.user_id,
|
||||
email: oauthStateRes.data.email,
|
||||
name: oauthStateRes.data.name,
|
||||
github_username: oauthStateRes.data.github_username,
|
||||
permission: oauthStateRes.data.permission,
|
||||
})
|
||||
} else {
|
||||
setOAuthUserInfo(null)
|
||||
}
|
||||
|
||||
await loadMcpStatus()
|
||||
await loadSystemFonts(savedFontFamily)
|
||||
}
|
||||
|
||||
const handleOAuthLogout = async () => {
|
||||
const api = (window as any).api
|
||||
if (!api) {
|
||||
setOAuthUserInfo(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.oauthClearLoginState()
|
||||
setOAuthUserInfo(null)
|
||||
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
||||
messageApi.success(t("auth.logout"))
|
||||
} catch (error: any) {
|
||||
messageApi.error(error?.message || t("common.error"))
|
||||
}
|
||||
}
|
||||
|
||||
const loadAboutContent = async () => {
|
||||
try {
|
||||
const res = await fetch("/about-content.json")
|
||||
@@ -376,6 +413,42 @@ export const Settings: React.FC<{
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthUserUpdated = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
user?:
|
||||
| {
|
||||
user_id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
github_username?: string
|
||||
permission?: number
|
||||
}
|
||||
| null
|
||||
}>
|
||||
const user = customEvent?.detail?.user
|
||||
if (user?.user_id && user.email && user.name && typeof user.permission === "number") {
|
||||
setOAuthUserInfo({
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
github_username: user.github_username,
|
||||
permission: user.permission,
|
||||
})
|
||||
} else {
|
||||
setOAuthUserInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("ss:oauth-user-updated", handleOAuthUserUpdated as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"ss:oauth-user-updated",
|
||||
handleOAuthUserUpdated as EventListener
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showLogs = async () => {
|
||||
if (!(window as any).api) return
|
||||
setLogsLoading(true)
|
||||
@@ -1098,17 +1171,13 @@ export const Settings: React.FC<{
|
||||
<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")}
|
||||
{getOAuthPermissionLabel(oauthUserInfo.permission)}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button danger onClick={() => setOAuthUserInfo(null)}>
|
||||
<Button danger onClick={handleOAuthLogout}>
|
||||
{t("settings.account.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Select,
|
||||
} from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { UploadOutlined, MoreOutlined, PlusOutlined } from "@ant-design/icons"
|
||||
import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TagEditorDialog } from "./TagEditorDialog"
|
||||
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
||||
@@ -720,6 +720,28 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarPasteFromClipboard = async () => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.read) {
|
||||
messageApi.error(t("students.avatarClipboardUnsupported"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read()
|
||||
for (const item of clipboardItems) {
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"))
|
||||
if (!imageType) continue
|
||||
const blob = await item.getType(imageType)
|
||||
const ext = imageType.split("/")[1] || "png"
|
||||
const file = new File([blob], `avatar-clipboard.${ext}`, { type: imageType })
|
||||
await handleAvatarFileChange(file)
|
||||
return
|
||||
}
|
||||
messageApi.warning(t("students.avatarClipboardNoImage"))
|
||||
} catch {
|
||||
messageApi.error(t("students.avatarClipboardReadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAvatar = async () => {
|
||||
if (!(window as any).api || !avatarStudent) return
|
||||
|
||||
@@ -2197,6 +2219,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
>
|
||||
{t("students.avatarUpload")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleAvatarPasteFromClipboard}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{t("students.avatarClipboardImport")}
|
||||
</Button>
|
||||
<Button onClick={() => setAvatarValue(null)} disabled={!canEdit}>
|
||||
{t("students.avatarClear")}
|
||||
</Button>
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "قائمة مجمعة",
|
||||
"squareGrid": "شبكة مربعة"
|
||||
"squareGrid": "شبكة مربعة",
|
||||
"largeAvatar": "صورة رمزية كبيرة"
|
||||
},
|
||||
"noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب",
|
||||
"noMatch": "لم يتم العثور على طلاب مطابقين",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Gruppierte Liste",
|
||||
"squareGrid": "Quadratisches Raster"
|
||||
"squareGrid": "Quadratisches Raster",
|
||||
"largeAvatar": "Großer Avatar"
|
||||
},
|
||||
"noStudents": "Keine Schülerdaten vorhanden, bitte in der Schülerverwaltung hinzufügen",
|
||||
"noMatch": "Keine übereinstimmenden Schüler gefunden",
|
||||
|
||||
@@ -423,7 +423,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Grouped List",
|
||||
"squareGrid": "Square Grid"
|
||||
"squareGrid": "Square Grid",
|
||||
"largeAvatar": "Large Avatar"
|
||||
},
|
||||
"noStudents": "No student data, please add in Student Management",
|
||||
"noMatch": "No matching students found",
|
||||
@@ -567,7 +568,11 @@
|
||||
"avatarSaveFailed": "Failed to save avatar",
|
||||
"avatarInvalidFile": "Please choose an image file",
|
||||
"avatarTooLarge": "Image must be smaller than 2MB",
|
||||
"avatarReadFailed": "Failed to read image"
|
||||
"avatarReadFailed": "Failed to read image",
|
||||
"avatarClipboardImport": "Import from Clipboard",
|
||||
"avatarClipboardUnsupported": "Clipboard image read is not supported",
|
||||
"avatarClipboardNoImage": "No image found in clipboard",
|
||||
"avatarClipboardReadFailed": "Failed to read clipboard image"
|
||||
},
|
||||
"score": {
|
||||
"title": "Points Management",
|
||||
@@ -686,7 +691,8 @@
|
||||
"viewModes": {
|
||||
"list": "List View",
|
||||
"card": "Card View",
|
||||
"grid": "Grid View"
|
||||
"grid": "Grid View",
|
||||
"largeAvatar": "Large Avatar"
|
||||
},
|
||||
"scoreDisplayModes": {
|
||||
"total": "Show Total Score",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Lista agrupada",
|
||||
"squareGrid": "Cuadrícula cuadrada"
|
||||
"squareGrid": "Cuadrícula cuadrada",
|
||||
"largeAvatar": "Avatar grande"
|
||||
},
|
||||
"noStudents": "Sin datos de estudiantes, por favor añada en gestión de estudiantes",
|
||||
"noMatch": "Sin estudiantes coincidentes encontrados",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Liste groupée",
|
||||
"squareGrid": "Grille carrée"
|
||||
"squareGrid": "Grille carrée",
|
||||
"largeAvatar": "Grand avatar"
|
||||
},
|
||||
"noStudents": "Aucune donnée d'élève, veuillez ajouter dans la gestion des élèves",
|
||||
"noMatch": "Aucun élève correspondant trouvé",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "グループリスト",
|
||||
"squareGrid": "正方形グリッド"
|
||||
"squareGrid": "正方形グリッド",
|
||||
"largeAvatar": "大型アバター"
|
||||
},
|
||||
"noStudents": "生徒データがありません。生徒管理で追加してください",
|
||||
"noMatch": "一致する生徒が見つかりません",
|
||||
@@ -574,7 +575,11 @@
|
||||
"avatarSaveFailed": "アバターの保存に失敗しました",
|
||||
"avatarInvalidFile": "画像ファイルを選択してください",
|
||||
"avatarTooLarge": "画像は2MB以下にしてください",
|
||||
"avatarReadFailed": "画像の読み取りに失敗しました"
|
||||
"avatarReadFailed": "画像の読み取りに失敗しました",
|
||||
"avatarClipboardImport": "クリップボードから取り込み",
|
||||
"avatarClipboardUnsupported": "この環境ではクリップボード画像を読み取れません",
|
||||
"avatarClipboardNoImage": "クリップボードに画像がありません",
|
||||
"avatarClipboardReadFailed": "クリップボード画像の読み取りに失敗しました"
|
||||
},
|
||||
"score": {
|
||||
"title": "ポイント管理",
|
||||
@@ -693,7 +698,8 @@
|
||||
"viewModes": {
|
||||
"list": "リスト表示",
|
||||
"card": "カード表示",
|
||||
"grid": "グリッド表示"
|
||||
"grid": "グリッド表示",
|
||||
"largeAvatar": "大型アバター表示"
|
||||
},
|
||||
"scoreDisplayModes": {
|
||||
"total": "合計スコアを表示",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "그룹화된 목록",
|
||||
"squareGrid": "정사각형 그리드"
|
||||
"squareGrid": "정사각형 그리드",
|
||||
"largeAvatar": "대형 아바타"
|
||||
},
|
||||
"noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요",
|
||||
"noMatch": "일치하는 학생이 없습니다",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Lista agrupada",
|
||||
"squareGrid": "Grade quadrada"
|
||||
"squareGrid": "Grade quadrada",
|
||||
"largeAvatar": "Avatar grande"
|
||||
},
|
||||
"noStudents": "Sem dados de alunos, por favor adicione em gerenciamento de alunos",
|
||||
"noMatch": "Nenhum aluno correspondente encontrado",
|
||||
|
||||
@@ -430,7 +430,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "Группированный список",
|
||||
"squareGrid": "Квадратная сетка"
|
||||
"squareGrid": "Квадратная сетка",
|
||||
"largeAvatar": "Большой аватар"
|
||||
},
|
||||
"noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами",
|
||||
"noMatch": "Нет совпадающих студентов",
|
||||
|
||||
@@ -423,7 +423,8 @@
|
||||
},
|
||||
"layoutBy": {
|
||||
"grouped": "分组列表",
|
||||
"squareGrid": "正方形网格"
|
||||
"squareGrid": "正方形网格",
|
||||
"largeAvatar": "大头像"
|
||||
},
|
||||
"noStudents": "暂无学生数据,请前往学生管理添加",
|
||||
"noMatch": "未找到匹配的学生",
|
||||
@@ -567,7 +568,11 @@
|
||||
"avatarSaveFailed": "头像保存失败",
|
||||
"avatarInvalidFile": "请选择图片文件",
|
||||
"avatarTooLarge": "图片不能超过 2MB",
|
||||
"avatarReadFailed": "读取图片失败"
|
||||
"avatarReadFailed": "读取图片失败",
|
||||
"avatarClipboardImport": "从剪贴板导入",
|
||||
"avatarClipboardUnsupported": "当前环境不支持读取剪贴板图片",
|
||||
"avatarClipboardNoImage": "剪贴板中没有图片",
|
||||
"avatarClipboardReadFailed": "读取剪贴板图片失败"
|
||||
},
|
||||
"score": {
|
||||
"title": "积分管理",
|
||||
@@ -686,7 +691,8 @@
|
||||
"viewModes": {
|
||||
"list": "列表视图",
|
||||
"card": "卡片视图",
|
||||
"grid": "网格视图"
|
||||
"grid": "网格视图",
|
||||
"largeAvatar": "大头像视图"
|
||||
},
|
||||
"scoreDisplayModes": {
|
||||
"total": "显示总分",
|
||||
|
||||
Reference in New Issue
Block a user