feat: 顶栏登录信息

This commit is contained in:
NanGua-QWQ
2026-04-18 21:10:19 +08:00
parent 34577d9656
commit a3efe4c489
4 changed files with 410 additions and 4 deletions
+64 -1
View File
@@ -24,7 +24,7 @@ import {
HolderOutlined, HolderOutlined,
CrownOutlined, CrownOutlined,
} from "@ant-design/icons" } 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 { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Sidebar } from "./components/Sidebar" import { Sidebar } from "./components/Sidebar"
@@ -115,6 +115,7 @@ function MainContent(): React.JSX.Element {
const [authPassword, setAuthPassword] = useState("") const [authPassword, setAuthPassword] = useState("")
const [authLoading, setAuthLoading] = useState(false) const [authLoading, setAuthLoading] = useState(false)
const [oauthVisible, setOAuthVisible] = useState(false) const [oauthVisible, setOAuthVisible] = useState(false)
const [oauthUserName, setOAuthUserName] = useState<string | null>(null)
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>( const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
) )
@@ -140,6 +141,18 @@ function MainContent(): React.JSX.Element {
const syncApplyLoadingRef = useRef(false) const syncApplyLoadingRef = useRef(false)
const lastLocalMutationAtRef = useRef(0) const lastLocalMutationAtRef = useRef(0)
const pluginRuntimeRef = useRef(getPluginRuntime()) 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 activeMenu = useMemo(() => {
const p = location.pathname const p = location.pathname
@@ -209,6 +222,11 @@ function MainContent(): React.JSX.Element {
oauthRes?.success && oauthRes.data oauthRes?.success && oauthRes.data
? mapOAuthPermissionToAppPermission(oauthRes.data.permission) ? mapOAuthPermissionToAppPermission(oauthRes.data.permission)
: null : null
if (oauthRes?.success && oauthRes.data?.name) {
setOAuthUserName(String(oauthRes.data.name))
} else {
setOAuthUserName(null)
}
if (authRes?.success && authRes.data) { if (authRes?.success && authRes.data) {
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword) const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
@@ -231,6 +249,31 @@ function MainContent(): React.JSX.Element {
loadAuthAndSettings() 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(() => { useEffect(() => {
const api = (window as any).api const api = (window as any).api
if (!api || typeof api.onSettingChanged !== "function") return if (!api || typeof api.onSettingChanged !== "function") return
@@ -456,6 +499,8 @@ function MainContent(): React.JSX.Element {
// 退出时同时清理 OAuth 持久化状态,避免刷新后又自动恢复权限 // 退出时同时清理 OAuth 持久化状态,避免刷新后又自动恢复权限
try { try {
await api.oauthClearLoginState() await api.oauthClearLoginState()
setOAuthUserName(null)
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
} catch (error) { } catch (error) {
console.error("Failed to clear OAuth login state:", 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: { const handleOAuthSuccess = (userInfo: {
user_id: string user_id: string
email: string email: string
@@ -476,6 +536,7 @@ function MainContent(): React.JSX.Element {
}) => { }) => {
setPermission(mapOAuthPermissionToAppPermission(userInfo.permission)) setPermission(mapOAuthPermissionToAppPermission(userInfo.permission))
setAuthVisible(false) setAuthVisible(false)
setOAuthUserName(userInfo.name || null)
messageApi.success(t("auth.oauthSuccess", "登录成功")) messageApi.success(t("auth.oauthSuccess", "登录成功"))
} }
@@ -671,6 +732,8 @@ function MainContent(): React.JSX.Element {
)} )}
<ContentArea <ContentArea
permission={permission} permission={permission}
oauthUserName={oauthUserName}
onOAuthLogout={logoutOAuthFromHeader}
hasAnyPassword={hasAnyPassword} hasAnyPassword={hasAnyPassword}
onAuthClick={() => setAuthVisible(true)} onAuthClick={() => setAuthVisible(true)}
onLogout={logout} onLogout={logout}
+304 -3
View File
@@ -1,5 +1,5 @@
import React, { Suspense, lazy, useEffect } from "react" import React, { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"
import { Layout, Space, Button, Tag, Spin } from "antd" import { Layout, Space, Button, Tag, Spin, Avatar, Popover, Progress } from "antd"
import { import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
@@ -57,6 +57,8 @@ const { Content } = Layout
interface ContentAreaProps { interface ContentAreaProps {
permission: "admin" | "points" | "view" permission: "admin" | "points" | "view"
oauthUserName?: string | null
onOAuthLogout?: () => Promise<void> | void
hasAnyPassword: boolean hasAnyPassword: boolean
onAuthClick: () => void onAuthClick: () => void
onLogout: () => void onLogout: () => void
@@ -74,8 +76,17 @@ interface ContentAreaProps {
bottomInset?: number bottomInset?: number
} }
interface HeaderStorageUsage {
used_storage_formatted: string
total_storage_formatted: string
percentage: number
file_count: number
}
export function ContentArea({ export function ContentArea({
permission, permission,
oauthUserName,
onOAuthLogout,
hasAnyPassword, hasAnyPassword,
onAuthClick, onAuthClick,
onLogout, onLogout,
@@ -156,8 +167,249 @@ export function ContentArea({
: permission === "points" : permission === "points"
? t("permissions.points") ? t("permissions.points")
: t("permissions.view")} : 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 ( return (
<Layout <Layout
style={{ style={{
@@ -307,6 +559,55 @@ export function ContentArea({
title={immersiveMode ? "退出沉浸模式" : "进入沉浸模式"} 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} {permissionTag}
{hasAnyPassword && ( {hasAnyPassword && (
<> <>
+5
View File
@@ -180,6 +180,11 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
console.error("[OAuth] 保存登录状态失败:", saveError) console.error("[OAuth] 保存登录状态失败:", saveError)
} }
window.dispatchEvent(
new CustomEvent("ss:oauth-user-updated", {
detail: { user: userRes.data },
})
)
console.log("[OAuth] 调用 onSuccess...") console.log("[OAuth] 调用 onSuccess...")
onSuccess(userRes.data) onSuccess(userRes.data)
console.log("[OAuth] 调用 onClose...") console.log("[OAuth] 调用 onClose...")
+37
View File
@@ -346,6 +346,7 @@ export const Settings: React.FC<{
try { try {
await api.oauthClearLoginState() await api.oauthClearLoginState()
setOAuthUserInfo(null) setOAuthUserInfo(null)
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
messageApi.success(t("auth.logout")) messageApi.success(t("auth.logout"))
} catch (error: any) { } catch (error: any) {
messageApi.error(error?.message || t("common.error")) 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 () => { const showLogs = async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLogsLoading(true) setLogsLoading(true)