Merge branch with OAuth improvements

This commit is contained in:
Yukino_fox
2026-04-19 12:25:31 +08:00
21 changed files with 1789 additions and 224 deletions
-1
View File
@@ -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",
+18 -61
View File
@@ -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
View File
@@ -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" }}
+74
View File
@@ -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%;
+122 -4
View File
@@ -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}
/>
+304 -3
View File
@@ -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,
@@ -156,8 +167,249 @@ export function ContentArea({
: permission === "points"
? t("permissions.points")
: t("permissions.view")}
</Tag>
</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
View File
@@ -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)",
+115 -93
View File
@@ -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,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<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
+77 -8
View File
@@ -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 : (
+75 -6
View File
@@ -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>
+30 -1
View File
@@ -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>
+2 -1
View File
@@ -430,7 +430,8 @@
},
"layoutBy": {
"grouped": "قائمة مجمعة",
"squareGrid": "شبكة مربعة"
"squareGrid": "شبكة مربعة",
"largeAvatar": "صورة رمزية كبيرة"
},
"noStudents": "لا توجد بيانات طلاب، يرجى الإضافة في إدارة الطلاب",
"noMatch": "لم يتم العثور على طلاب مطابقين",
+2 -1
View File
@@ -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",
+9 -3
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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é",
+9 -3
View File
@@ -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": "合計スコアを表示",
+2 -1
View File
@@ -430,7 +430,8 @@
},
"layoutBy": {
"grouped": "그룹화된 목록",
"squareGrid": "정사각형 그리드"
"squareGrid": "정사각형 그리드",
"largeAvatar": "대형 아바타"
},
"noStudents": "학생 데이터가 없습니다. 학생 관리에서 추가해 주세요",
"noMatch": "일치하는 학생이 없습니다",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -430,7 +430,8 @@
},
"layoutBy": {
"grouped": "Группированный список",
"squareGrid": "Квадратная сетка"
"squareGrid": "Квадратная сетка",
"largeAvatar": "Большой аватар"
},
"noStudents": "Нет данных о студентах, пожалуйста, добавьте в управлении студентами",
"noMatch": "Нет совпадающих студентов",
+9 -3
View File
@@ -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": "显示总分",