From 0bcd41a704796f18f819969f0b8ddaad0eb0c175 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 17 Apr 2026 20:46:16 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E4=B8=BB=E9=A1=B5?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=88=B0=E5=BC=B9=E7=AA=97=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E4=B8=8E=E9=81=AE=E7=BD=A9=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/main.css | 74 ++++++++++ src/components/Home.tsx | 300 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 363 insertions(+), 11 deletions(-) diff --git a/src/assets/main.css b/src/assets/main.css index ac0acb0..fba91c1 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -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%; diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 24b411f..a48014e 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, @@ -127,6 +127,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) @@ -141,6 +142,13 @@ export const Home: React.FC = ({ 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 +597,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 +613,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 +896,7 @@ export const Home: React.FC = ({ setRewardMode((prev) => !prev) setBatchMode(false) setSelectedStudentIds([]) - setOperationVisible(false) + closeOperationModal() setQuickActionStudentId(null) setRewardStudent(null) setRewardModalVisible(false) @@ -717,7 +990,7 @@ export const Home: React.FC = ({ setSelectedStudentIds([]) setBatchMode(false) setSelectedStudent(null) - setOperationVisible(false) + closeOperationModal() setCustomScore(undefined) setReasonContent("") setQuickActionStudentId(null) @@ -846,7 +1119,7 @@ export const Home: React.FC = ({ } setBatchMode(true) setSelectedStudent(null) - setOperationVisible(false) + closeOperationModal() setQuickActionStudentId(null) } @@ -895,7 +1168,7 @@ export const Home: React.FC = ({ e.stopPropagation() return } - openOperation(student) + openOperation(student, e.currentTarget as HTMLElement) }} onMouseDown={(e) => { if (batchMode) return @@ -1113,7 +1386,7 @@ export const Home: React.FC = ({ e.stopPropagation() return } - openOperation(student) + openOperation(student, e.currentTarget as HTMLElement) }} onMouseDown={(e) => { if (batchMode) return @@ -1280,7 +1553,7 @@ export const Home: React.FC = ({ e.stopPropagation() return } - openOperation(student) + openOperation(student, e.currentTarget as HTMLElement) }} onMouseDown={(e) => { if (batchMode) return @@ -2705,7 +2978,7 @@ export const Home: React.FC = ({ placement="bottom" height="100%" open={operationVisible} - onClose={() => setOperationVisible(false)} + onClose={closeOperationModal} afterOpenChange={applyDrawerDragRegion} destroyOnClose styles={{ @@ -2713,7 +2986,7 @@ export const Home: React.FC = ({ }} footer={ - + @@ -2730,13 +3003,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)", From b458a10deb98f8f92fdb5a49561c30c656202434 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 17 Apr 2026 20:50:03 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E5=A5=96=E5=8A=B1?= =?UTF-8?q?=E5=85=91=E6=8D=A2=E9=A1=B5=E9=9D=A2=E4=BA=A4=E4=BA=92=E7=BB=86?= =?UTF-8?q?=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RewardExchange.tsx | 85 ++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/components/RewardExchange.tsx b/src/components/RewardExchange.tsx index 7e73528..7423095 100644 --- a/src/components/RewardExchange.tsx +++ b/src/components/RewardExchange.tsx @@ -35,6 +35,14 @@ export const RewardExchange: React.FC<{ canEdit: boolean }> = ({ 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 : ( From e82866bbfc9e0ccf271c15e0d67a5b20b5080bfd Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 18 Apr 2026 11:16:44 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=A4=A7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E8=A7=86=E5=9B=BE=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BoardManager.tsx | 126 ++++++++++++++- src/components/Home.tsx | 267 +++++++++++++++++++++++++++++++- src/i18n/locales/ar-SA.json | 3 +- src/i18n/locales/de-DE.json | 3 +- src/i18n/locales/en-US.json | 6 +- src/i18n/locales/es-ES.json | 3 +- src/i18n/locales/fr-FR.json | 3 +- src/i18n/locales/ja-JP.json | 6 +- src/i18n/locales/ko-KR.json | 3 +- src/i18n/locales/pt-BR.json | 3 +- src/i18n/locales/ru-RU.json | 3 +- src/i18n/locales/zh-CN.json | 6 +- 12 files changed, 414 insertions(+), 18 deletions(-) diff --git a/src/components/BoardManager.tsx b/src/components/BoardManager.tsx index 806599a..2b8b021 100644 --- a/src/components/BoardManager.tsx +++ b/src/components/BoardManager.tsx @@ -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 (
@@ -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/Home.tsx b/src/components/Home.tsx index a48014e..73ad675 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -56,7 +56,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 = { @@ -1749,6 +1749,255 @@ 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 ? ( +
+ + +
+ ) : ( +
+
+ {student.name} +
+
+ {displayPoints > 0 ? `+${displayPoints}` : displayPoints} +
+
+ )} +
+ +
+ ) + } + const renderGroupedCards = () => { if (layoutType === "squareGrid") { return ( @@ -1764,6 +2013,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") }, ]} /> + diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 7e4b6ee..3a361a2 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -568,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", diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json index 803974d..92b7750 100644 --- a/src/i18n/locales/ja-JP.json +++ b/src/i18n/locales/ja-JP.json @@ -575,7 +575,11 @@ "avatarSaveFailed": "アバターの保存に失敗しました", "avatarInvalidFile": "画像ファイルを選択してください", "avatarTooLarge": "画像は2MB以下にしてください", - "avatarReadFailed": "画像の読み取りに失敗しました" + "avatarReadFailed": "画像の読み取りに失敗しました", + "avatarClipboardImport": "クリップボードから取り込み", + "avatarClipboardUnsupported": "この環境ではクリップボード画像を読み取れません", + "avatarClipboardNoImage": "クリップボードに画像がありません", + "avatarClipboardReadFailed": "クリップボード画像の読み取りに失敗しました" }, "score": { "title": "ポイント管理", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 85b9938..52dc263 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -568,7 +568,11 @@ "avatarSaveFailed": "头像保存失败", "avatarInvalidFile": "请选择图片文件", "avatarTooLarge": "图片不能超过 2MB", - "avatarReadFailed": "读取图片失败" + "avatarReadFailed": "读取图片失败", + "avatarClipboardImport": "从剪贴板导入", + "avatarClipboardUnsupported": "当前环境不支持读取剪贴板图片", + "avatarClipboardNoImage": "剪贴板中没有图片", + "avatarClipboardReadFailed": "读取剪贴板图片失败" }, "score": { "title": "积分管理", From 92f6f3684bab92be9674f2eaa5109e6170a82343 Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 18 Apr 2026 12:05:02 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E4=B8=BB=E9=A1=B5=E5=A4=A7?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E6=94=AF=E6=8C=81=E5=8F=B3=E9=94=AE=E6=94=B9?= =?UTF-8?q?=E5=90=8D=E4=B8=8E=E8=AE=BE=E7=BD=AE=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Home.tsx | 306 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 287 insertions(+), 19 deletions(-) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 73ad675..4146a97 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -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 { @@ -139,6 +140,15 @@ 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) @@ -1143,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) @@ -1956,25 +2100,41 @@ export const Home: React.FC = ({ zIndex: 2, }} > -
{ + domEvent.stopPropagation() + if (key === "rename") openRenameStudent(student) + if (key === "avatar") openAvatarEditor(student) + }, }} > - {student.name} -
+
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} +
+
= ({ )} + { + setRenameVisible(false) + setRenameStudent(null) + setRenameValue("") + }} + onOk={handleRenameStudent} + okButtonProps={{ loading: renameSaving, disabled: !renameValue.trim() }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > + setRenameValue(e.target.value)} + placeholder={`${t("common.pleaseEnter")} ${t("common.name")}`} + maxLength={32} + autoFocus + onPressEnter={() => { + if (!renameSaving && renameValue.trim()) handleRenameStudent() + }} + /> + + + { + setAvatarEditorVisible(false) + setAvatarEditorStudent(null) + setAvatarEditorValue(null) + }} + onOk={handleSaveAvatarEditor} + okButtonProps={{ loading: avatarEditorSaving, disabled: !avatarEditorStudent }} + okText={t("common.save")} + cancelText={t("common.cancel")} + destroyOnHidden + > +
+
+ {avatarEditorValue ? ( + {avatarEditorStudent?.name + ) : ( +
+ {t("students.noAvatar")} +
+ )} +
+
+ + + +
+ { + const file = e.target.files?.[0] + handleAvatarEditorFileChange(file) + if (avatarInputRef.current) avatarInputRef.current.value = "" + }} + /> +
+ {t("students.avatarTip")} +
+
+
+
Date: Sat, 18 Apr 2026 20:10:19 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E9=97=AE=E9=A2=98=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=BA=86json-rules-engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 - pnpm-lock.yaml | 79 +++--------- src/App.tsx | 60 ++++++--- src/components/OAuth/OAuthLogin.tsx | 184 ++++++++++++++++------------ src/components/Settings.tsx | 44 ++++++- 5 files changed, 209 insertions(+), 159 deletions(-) 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..9e011d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() @@ -188,15 +196,32 @@ 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 (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) @@ -425,8 +450,17 @@ 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() + } 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")) @@ -440,14 +474,8 @@ 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) messageApi.success(t("auth.oauthSuccess", "登录成功")) } @@ -984,7 +1012,7 @@ function MainContent(): React.JSX.Element { footer={null} closable={!syncApplyLoading} maskClosable={false} - destroyOnClose + destroyOnHidden >
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 @@ -58,53 +68,72 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { } } - const handleOAuthCallback = async (result: OAuthCallbackResult) => { - // 全局锁检查,防止重复处理 - 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 + 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, @@ -117,7 +146,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { if (!tokenRes.success) { message.error(tokenRes.message || "获取访问令牌失败") - setLoading(false) return } @@ -127,13 +155,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, @@ -154,57 +180,60 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { console.error("[OAuth] 保存登录状态失败:", saveError) } - console.log("[OAuth] 清理资源...") - await api.oauthStopCallbackServer() - clearExpectedState() - sessionStorage.removeItem("oauth_code_verifier") 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) - // 延迟释放锁,确保其他可能的重复事件被忽略 - setTimeout(() => { - isProcessingCallback = false - console.log("[OAuth] 全局锁已释放") - }, 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() @@ -226,8 +255,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) @@ -241,8 +270,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) @@ -252,7 +280,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) @@ -266,7 +300,7 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { (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) + 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") @@ -1098,17 +1134,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)}
-
From a3efe4c48935bf3298801d9fed9443dd718f1459 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sat, 18 Apr 2026 21:10:19 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E9=A1=B6=E6=A0=8F=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 65 +++++- src/components/ContentArea.tsx | 307 +++++++++++++++++++++++++++- src/components/OAuth/OAuthLogin.tsx | 5 + src/components/Settings.tsx | 37 ++++ 4 files changed, 410 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9e011d9..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" @@ -115,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 ) @@ -140,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 @@ -209,6 +222,11 @@ function MainContent(): React.JSX.Element { 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) { const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword) @@ -231,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 @@ -456,6 +499,8 @@ function MainContent(): React.JSX.Element { // 退出时同时清理 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) } @@ -467,6 +512,21 @@ function MainContent(): React.JSX.Element { } } + 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 @@ -476,6 +536,7 @@ function MainContent(): React.JSX.Element { }) => { setPermission(mapOAuthPermissionToAppPermission(userInfo.permission)) setAuthVisible(false) + setOAuthUserName(userInfo.name || null) messageApi.success(t("auth.oauthSuccess", "登录成功")) } @@ -671,6 +732,8 @@ function MainContent(): React.JSX.Element { )} setAuthVisible(true)} onLogout={logout} 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/OAuth/OAuthLogin.tsx b/src/components/OAuth/OAuthLogin.tsx index 29faf27..e7912fe 100644 --- a/src/components/OAuth/OAuthLogin.tsx +++ b/src/components/OAuth/OAuthLogin.tsx @@ -180,6 +180,11 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) { console.error("[OAuth] 保存登录状态失败:", saveError) } + window.dispatchEvent( + new CustomEvent("ss:oauth-user-updated", { + detail: { user: userRes.data }, + }) + ) console.log("[OAuth] 调用 onSuccess...") onSuccess(userRes.data) console.log("[OAuth] 调用 onClose...") diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 693e4a4..363485c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -346,6 +346,7 @@ export const Settings: React.FC<{ 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")) @@ -412,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)