From a3efe4c48935bf3298801d9fed9443dd718f1459 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Sat, 18 Apr 2026 21:10:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B6=E6=A0=8F=E7=99=BB=E5=BD=95?= =?UTF-8?q?=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)