diff --git a/package.json b/package.json index cffcc04..5b366b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c9a7e3..5bb70a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 73d911c..8f64d5c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null) const [mobileBottomNavItems, setMobileBottomNavItems] = useState( 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 { )} setAuthVisible(true)} onLogout={logout} @@ -984,7 +1075,7 @@ function MainContent(): React.JSX.Element { footer={null} closable={!syncApplyLoading} maskClosable={false} - destroyOnClose + destroyOnHidden >
{ 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 (
@@ -1006,6 +1033,96 @@ ORDER BY reward_points DESC, score DESC`, )}
+ ) : list.viewMode === "largeAvatar" ? ( +
+ {item.avatarUrl ? ( + {item.name} + ) : ( +
1 ? "46px" : "56px", + letterSpacing: "0.02em", + }} + > + {avatarText} +
+ )} + +
+ +
+
+ {item.name} +
+ {metricValueText !== null && ( +
= 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} +
+ )} +
+
) : (
diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index 15ead2e..156326e 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -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 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, @@ -156,8 +167,249 @@ export function ContentArea({ : permission === "points" ? t("permissions.points") : t("permissions.view")} - + ) + 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(null) + const [storageUsage, setStorageUsage] = useState(null) + const [oauthUserId, setOAuthUserId] = useState(null) + const [lastSyncTime, setLastSyncTime] = useState(() => { + try { + return localStorage.getItem("ss_last_sync_time") + } catch { + return null + } + }) + const [copiedUserId, setCopiedUserId] = useState(false) + const [logoutLoading, setLogoutLoading] = useState(false) + const copyResetTimerRef = useRef(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 = ( +
+
+
+
账号 ID
+
+ {oauthUserId || "未登录"} +
+
+ +
+
+
最近同步时间
+
{formattedLastSyncTime}
+
+
云空间用量
+ {storageUsageLoading ? ( +
+ +
+ ) : storageUsage ? ( +
+
+ 已用:{storageUsage.used_storage_formatted} / {storageUsage.total_storage_formatted} +
+ 90 ? "exception" : "active"} + /> +
+ 文件数量:{storageUsage.file_count} +
+
+ ) : ( +
+ {storageUsageError || "暂无云空间数据"} +
+ )} + +
+ ) + return ( )} + + + {permissionTag} {hasAnyPassword && ( <> diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 24b411f..4146a97 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -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 = { @@ -127,6 +128,7 @@ export const Home: React.FC = ({ const [batchMode, setBatchMode] = useState(false) const [selectedStudentIds, setSelectedStudentIds] = useState([]) const [operationVisible, setOperationVisible] = useState(false) + const [operationOriginRect, setOperationOriginRect] = useState(null) const [customScore, setCustomScore] = useState(undefined) const [reasonContent, setReasonContent] = useState("") const [submitLoading, setSubmitLoading] = useState(false) @@ -138,9 +140,25 @@ export const Home: React.FC = ({ const [rewardStudent, setRewardStudent] = useState(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(null) + const [renameValue, setRenameValue] = useState("") + const [avatarEditorVisible, setAvatarEditorVisible] = useState(false) + const [avatarEditorSaving, setAvatarEditorSaving] = useState(false) + const [avatarEditorStudent, setAvatarEditorStudent] = useState(null) + const [avatarEditorValue, setAvatarEditorValue] = useState(null) + const avatarInputRef = useRef(null) const longPressTimerRef = useRef(null) const suppressClickRef = useRef(false) const fetchRequestIdRef = useRef(0) + const operationMorphAnimationRef = useRef(null) + const operationMaskAnimationRef = useRef(null) + const operationMorphRafRef = useRef(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 = ({ } } - 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 = ({ ) 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 = ({ setRewardMode((prev) => !prev) setBatchMode(false) setSelectedStudentIds([]) - setOperationVisible(false) + closeOperationModal() setQuickActionStudentId(null) setRewardStudent(null) setRewardModalVisible(false) @@ -717,7 +1000,7 @@ export const Home: React.FC = ({ setSelectedStudentIds([]) setBatchMode(false) setSelectedStudent(null) - setOperationVisible(false) + closeOperationModal() setCustomScore(undefined) setReasonContent("") setQuickActionStudentId(null) @@ -846,7 +1129,7 @@ export const Home: React.FC = ({ } setBatchMode(true) setSelectedStudent(null) - setOperationVisible(false) + closeOperationModal() setQuickActionStudentId(null) } @@ -870,6 +1153,140 @@ export const Home: React.FC = ({ setOperationVisible(true) } + const readFileAsDataUrl = (file: File): Promise => { + 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 = ({ 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 = ({ 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 = ({ 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 = ({ ) } + 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 ( +
{ + 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 + } + }) + }} + > + +
+ {student.avatarUrl ? ( + {student.name} + ) : ( +
1 ? "46px" : "56px", + letterSpacing: "0.02em", + }} + > + {avatarText} +
+ )} + +
+ + {rankBadge && ( +
+ {rankBadge} +
+ )} + + {isSelected && ( + + {t("home.selected")} + + )} + + {isQuickActionMode ? ( +
+ + +
+ ) : ( +
+ { + domEvent.stopPropagation() + if (key === "rename") openRenameStudent(student) + if (key === "avatar") openAvatarEditor(student) + }, + }} + > +
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} +
+
+
+ {displayPoints > 0 ? `+${displayPoints}` : displayPoints} +
+
+ )} +
+ +
+ ) + } + const renderGroupedCards = () => { if (layoutType === "squareGrid") { return ( @@ -1491,6 +2173,20 @@ export const Home: React.FC = ({ ) } + if (layoutType === "largeAvatar") { + return ( +
+ {sortedStudents.map((student, idx) => renderStudentLargeAvatarCard(student, idx))} +
+ ) + } + return groupedStudents.map((group) => (
= ({ options={[ { value: "grouped", label: t("home.layoutBy.grouped") }, { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, + { value: "largeAvatar", label: t("home.layoutBy.largeAvatar") }, ]} /> + + +
+ { + const file = e.target.files?.[0] + handleAvatarEditorFileChange(file) + if (avatarInputRef.current) avatarInputRef.current.value = "" + }} + /> +
+ {t("students.avatarTip")} +
+
+ +
= ({ options={[ { value: "grouped", label: t("home.layoutBy.grouped") }, { value: "squareGrid", label: t("home.layoutBy.squareGrid") }, + { value: "largeAvatar", label: t("home.layoutBy.largeAvatar") }, ]} /> + @@ -2730,13 +3536,18 @@ export const Home: React.FC = ({ : 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)", diff --git a/src/components/OAuth/OAuthLogin.tsx b/src/components/OAuth/OAuthLogin.tsx index 8ffaef3..d95036e 100644 --- a/src/components/OAuth/OAuthLogin.tsx +++ b/src/components/OAuth/OAuthLogin.tsx @@ -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() +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,65 +69,72 @@ 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 - } - - // 全局锁检查,防止并发处理 - if (isProcessingCallback) { - console.log("[OAuth] 回调正在处理中,跳过") - return - } - - console.log("[OAuth] 收到回调:", result) - const config = getOAuthConfig() - if (!config) { - console.error("[OAuth] 配置不存在") - return - } - + const stopCallbackServer = useCallback(async () => { + const api = (window as any).api + if (!api?.oauthStopCallbackServer) return try { - isProcessingCallback = true - processedEventIds.add(eventId) + await api.oauthStopCallbackServer() + } catch (error) { + console.error("[OAuth] 停止回调服务器失败:", error) + } + }, []) - if (result.error) { - console.error("[OAuth] 错误:", result.error, result.error_description) - message.error(result.error_description || result.error || "授权失败") + const clearOAuthTempState = useCallback(() => { + clearExpectedState() + clearCodeVerifier() + clearCallbackUrl() + }, []) + + const handleOAuthCallback = useCallback( + async (result: OAuthCallbackResult) => { + if (isProcessingCallbackRef.current) { + console.log("[OAuth] 回调正在处理中,跳过") + return + } + + console.log("[OAuth] 收到回调:", result) + const config = getOAuthConfig() + if (!config) { + console.error("[OAuth] 配置不存在") + message.error("OAuth 配置未设置") setLoading(false) return } - if (result.code) { + const api = (window as any).api + 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()) - // 验证 state 防止 CSRF const expectedState = getExpectedState() if (expectedState && result.state !== expectedState) { message.error("安全验证失败:state 不匹配") - 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") + const codeVerifier = getCodeVerifier() if (!codeVerifier) { message.error("安全验证失败:缺少 code_verifier") - setLoading(false) 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 { + await stopCallbackServer() + clearOAuthTempState() + setLoading(false) + window.setTimeout(() => { + isProcessingCallbackRef.current = false + console.log("[OAuth] 局部锁已释放") + }, 300) } - } catch (error: any) { - console.error("[OAuth] 处理回调时发生错误:", error) - message.error(error.message || "登录失败") - } finally { - console.log("[OAuth] 回调处理完成,重置状态") - setLoading(false) - // 立即释放锁,无需延迟 - isProcessingCallback = false - console.log("[OAuth] 全局锁已释放") - - // 5分钟后清理该事件ID,防止内存泄漏 - setTimeout(() => { - processedEventIds.delete(eventId) - console.log("[OAuth] 事件ID已清理:", eventId) - }, 5 * 60 * 1000) - } - } + }, + [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("oauth-callback", async (event) => { + unlisten = await listen("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) { = ({ canEdit }) => { const [exchangeMode, setExchangeMode] = useState(false) const [selectedStudent, setSelectedStudent] = useState(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(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) => { 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 }) => { 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 }) => { { - 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 : ( diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 1e9aa75..363485c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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 ( { + 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<{
{t("settings.account.permission")}: - {oauthUserInfo.permission === 1 - ? t("permissions.admin") - : oauthUserInfo.permission === 2 - ? t("permissions.points") - : t("permissions.view")} + {getOAuthPermissionLabel(oauthUserInfo.permission)}
-
diff --git a/src/components/StudentManager.tsx b/src/components/StudentManager.tsx index 5060cf1..99fe008 100644 --- a/src/components/StudentManager.tsx +++ b/src/components/StudentManager.tsx @@ -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")} + diff --git a/src/i18n/locales/ar-SA.json b/src/i18n/locales/ar-SA.json index 2ce7d70..b4c19ee 100644 --- a/src/i18n/locales/ar-SA.json +++ b/src/i18n/locales/ar-SA.json @@ -430,7 +430,8 @@ }, "layoutBy": { "grouped": "قائمة مجمعة", - "squareGrid": "شبكة مربعة" + "squareGrid": "شبكة مربعة", + "largeAvatar": "صورة رمزية كبيرة" }, "noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب", "noMatch": "لم يتم العثور على طلاب مطابقين", diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json index 8e56b00..057fbfa 100644 --- a/src/i18n/locales/de-DE.json +++ b/src/i18n/locales/de-DE.json @@ -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", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 6d9b0dc..3a361a2 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -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", diff --git a/src/i18n/locales/es-ES.json b/src/i18n/locales/es-ES.json index 950942d..885ce02 100644 --- a/src/i18n/locales/es-ES.json +++ b/src/i18n/locales/es-ES.json @@ -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", diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json index c30d1e3..a109cd7 100644 --- a/src/i18n/locales/fr-FR.json +++ b/src/i18n/locales/fr-FR.json @@ -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é", diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index 15ee239..92b7750 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -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": "合計スコアを表示", diff --git a/src/i18n/locales/ko-KR.json b/src/i18n/locales/ko-KR.json index 1cfdb4d..eda9ef7 100644 --- a/src/i18n/locales/ko-KR.json +++ b/src/i18n/locales/ko-KR.json @@ -430,7 +430,8 @@ }, "layoutBy": { "grouped": "그룹화된 목록", - "squareGrid": "정사각형 그리드" + "squareGrid": "정사각형 그리드", + "largeAvatar": "대형 아바타" }, "noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요", "noMatch": "일치하는 학생이 없습니다", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 8fea588..62655ab 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -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", diff --git a/src/i18n/locales/ru-RU.json b/src/i18n/locales/ru-RU.json index 536ff1f..c014c3e 100644 --- a/src/i18n/locales/ru-RU.json +++ b/src/i18n/locales/ru-RU.json @@ -430,7 +430,8 @@ }, "layoutBy": { "grouped": "Группированный список", - "squareGrid": "Квадратная сетка" + "squareGrid": "Квадратная сетка", + "largeAvatar": "Большой аватар" }, "noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами", "noMatch": "Нет совпадающих студентов", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d2ad8ba..52dc263 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "显示总分",