mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
1235 lines
42 KiB
TypeScript
1235 lines
42 KiB
TypeScript
import {
|
|
Layout,
|
|
Modal,
|
|
Input,
|
|
message,
|
|
ConfigProvider,
|
|
theme as antTheme,
|
|
Drawer,
|
|
Button,
|
|
} from "antd"
|
|
import {
|
|
HomeOutlined,
|
|
SettingOutlined,
|
|
UserOutlined,
|
|
HistoryOutlined,
|
|
SyncOutlined,
|
|
AppstoreAddOutlined,
|
|
ApartmentOutlined,
|
|
UnorderedListOutlined,
|
|
FileTextOutlined,
|
|
MoreOutlined,
|
|
UpOutlined,
|
|
DownOutlined,
|
|
HolderOutlined,
|
|
CrownOutlined,
|
|
} from "@ant-design/icons"
|
|
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"
|
|
import { ContentArea } from "./components/ContentArea"
|
|
import { OOBE } from "./components/OOBE/OOBE"
|
|
import { OAuthLogin } from "./components/OAuth/OAuthLogin"
|
|
import { OAuthCallback } from "./components/OAuth/OAuthCallback"
|
|
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
|
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
|
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
|
import { getPluginRuntime } from "./plugins/runtime"
|
|
|
|
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
|
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
|
0,
|
|
4
|
|
)
|
|
const applyGlobalFontFamily = (fontValue?: string) => {
|
|
const fontFamily = resolveStoredFontFamily(fontValue)
|
|
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
|
|
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()
|
|
const location = useLocation()
|
|
const { currentTheme } = useTheme()
|
|
const [messageApi, contextHolder] = message.useMessage()
|
|
const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, [])
|
|
const [immersiveMode, setImmersiveMode] = useState(false)
|
|
|
|
const normalizeStoredBottomKeys = (raw: unknown): MobileNavKey[] => {
|
|
if (Array.isArray(raw)) {
|
|
return sanitizeMobileNavKeys(raw, []).slice(0, 4)
|
|
}
|
|
return DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS
|
|
}
|
|
|
|
useEffect(() => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
|
|
let disposed = false
|
|
let unlisten: (() => void) | null = null
|
|
|
|
api
|
|
.onNavigate((route: string) => {
|
|
const currentPath = location.pathname === "/" ? "/home" : location.pathname
|
|
const targetPath = route === "/" ? "/home" : route
|
|
|
|
if (immersiveMode && !targetPath.startsWith("/home")) {
|
|
navigate("/", { replace: true })
|
|
return
|
|
}
|
|
|
|
if (currentPath !== targetPath) {
|
|
navigate(route)
|
|
}
|
|
})
|
|
.then((fn: () => void) => {
|
|
if (disposed) {
|
|
fn()
|
|
return
|
|
}
|
|
unlisten = fn
|
|
})
|
|
.catch(() => void 0)
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (unlisten) unlisten()
|
|
}
|
|
}, [navigate, location.pathname, immersiveMode])
|
|
|
|
const [wizardVisible, setWizardVisible] = useState(false)
|
|
const [permission, setPermission] = useState<"admin" | "points" | "view">("view")
|
|
const [hasAnyPassword, setHasAnyPassword] = useState(false)
|
|
const [authVisible, setAuthVisible] = useState(false)
|
|
const [authPassword, setAuthPassword] = useState("")
|
|
const [authLoading, setAuthLoading] = useState(false)
|
|
const [oauthVisible, setOAuthVisible] = useState(false)
|
|
const [oauthUserName, setOAuthUserName] = useState<string | null>(null)
|
|
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
|
|
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
|
)
|
|
const [moreNavVisible, setMoreNavVisible] = useState(false)
|
|
const [editingNav, setEditingNav] = useState(false)
|
|
const [editingBottomNavKeys, setEditingBottomNavKeys] = useState<MobileNavKey[]>([])
|
|
const [editingMoreNavKeys, setEditingMoreNavKeys] = useState<MobileNavKey[]>([])
|
|
const [draggingNavKey, setDraggingNavKey] = useState<MobileNavKey | null>(null)
|
|
const [draggingFromList, setDraggingFromList] = useState<"bottom" | "more" | null>(null)
|
|
const [dragOverSlot, setDragOverSlot] = useState<{
|
|
list: "bottom" | "more"
|
|
index: number
|
|
} | null>(null)
|
|
const [isPortraitMode] = useState(defaultPortraitMode)
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
|
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
|
const [syncConflictVisible, setSyncConflictVisible] = useState(false)
|
|
const [syncConflicts, setSyncConflicts] = useState<
|
|
Array<{ table: string; key: string; local_summary: string; remote_summary: string }>
|
|
>([])
|
|
const [syncApplyLoading, setSyncApplyLoading] = useState(false)
|
|
const syncCheckingRef = useRef(false)
|
|
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
|
|
if (p === "/" || p.startsWith("/home")) return "home"
|
|
if (p.startsWith("/students")) return "students"
|
|
if (p.startsWith("/score")) return "score"
|
|
if (p.startsWith("/boards")) return "boards"
|
|
if (p.startsWith("/leaderboard")) return "leaderboard"
|
|
if (p.startsWith("/settlements")) return "settlements"
|
|
if (p.startsWith("/reasons")) return "reasons"
|
|
if (p.startsWith("/auto-score")) return "auto-score"
|
|
if (p.startsWith("/reward-settings")) return "reward-settings"
|
|
if (p.startsWith("/plugins")) return "plugins"
|
|
if (p.startsWith("/settings")) return "settings"
|
|
return "home"
|
|
}, [location.pathname])
|
|
|
|
useEffect(() => {
|
|
const runtime = pluginRuntimeRef.current
|
|
runtime.start().catch((error) => {
|
|
console.error("Failed to start plugin runtime:", error)
|
|
})
|
|
|
|
const handlePluginsUpdated = () => {
|
|
runtime.reload().catch((error) => {
|
|
console.error("Failed to reload plugin runtime:", error)
|
|
})
|
|
}
|
|
window.addEventListener("ss:plugins-updated", handlePluginsUpdated)
|
|
|
|
return () => {
|
|
window.removeEventListener("ss:plugins-updated", handlePluginsUpdated)
|
|
runtime.stop().catch((error) => {
|
|
console.error("Failed to stop plugin runtime:", error)
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const normalizedPath = location.pathname === "/" ? "/home" : location.pathname
|
|
window.dispatchEvent(new CustomEvent("ss:route-changed", { detail: { path: normalizedPath } }))
|
|
}, [location.pathname])
|
|
|
|
useEffect(() => {
|
|
const checkWizard = async () => {
|
|
if (!(window as any).api) return
|
|
const res = await (window as any).api.getAllSettings()
|
|
if (res.success && res.data && !res.data.is_wizard_completed) {
|
|
setWizardVisible(true)
|
|
}
|
|
}
|
|
checkWizard()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const loadAuthAndSettings = async () => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
|
|
const [authRes, oauthRes, settingsRes] = await Promise.all([
|
|
api.authGetStatus(),
|
|
api.oauthLoadLoginState(),
|
|
api.getAllSettings(),
|
|
])
|
|
|
|
const oauthPermission =
|
|
oauthRes?.success && oauthRes.data
|
|
? mapOAuthPermissionToAppPermission(oauthRes.data.permission)
|
|
: null
|
|
if (oauthRes?.success && oauthRes.data?.name) {
|
|
setOAuthUserName(String(oauthRes.data.name))
|
|
} else {
|
|
setOAuthUserName(null)
|
|
}
|
|
|
|
if (authRes?.success && authRes.data) {
|
|
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
|
|
setHasAnyPassword(anyPwd)
|
|
|
|
const finalPermission = oauthPermission ?? authRes.data.permission
|
|
setPermission(finalPermission)
|
|
setAuthVisible(anyPwd && finalPermission === "view")
|
|
} else if (oauthPermission) {
|
|
setPermission(oauthPermission)
|
|
setAuthVisible(false)
|
|
}
|
|
|
|
if (settingsRes?.success && settingsRes.data) {
|
|
setMobileBottomNavItems(normalizeStoredBottomKeys(settingsRes.data.mobile_bottom_nav_items))
|
|
applyGlobalFontFamily(settingsRes.data.font_family)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
let disposed = false
|
|
let unlisten: (() => void) | null = null
|
|
|
|
api
|
|
.onSettingChanged((change: { key?: string; value?: unknown }) => {
|
|
if (change?.key === "mobile_bottom_nav_items") {
|
|
setMobileBottomNavItems(normalizeStoredBottomKeys(change.value))
|
|
return
|
|
}
|
|
if (change?.key === "font_family") {
|
|
applyGlobalFontFamily(String(change.value || "system"))
|
|
}
|
|
})
|
|
.then((fn: () => void) => {
|
|
if (disposed) {
|
|
fn()
|
|
return
|
|
}
|
|
unlisten = fn
|
|
})
|
|
.catch(() => void 0)
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (unlisten) unlisten()
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const api = (window as any).api
|
|
if (!api || !isIosDevice) return
|
|
|
|
const fitIosWindow = async () => {
|
|
try {
|
|
await api.windowMaximize()
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
try {
|
|
await api.windowSetResizable(false)
|
|
} catch {
|
|
void 0
|
|
}
|
|
|
|
try {
|
|
// 传入远大于设备尺寸的物理像素,交由系统裁剪为可用全屏区域
|
|
await api.windowResize(10000, 10000)
|
|
} catch {
|
|
void 0
|
|
}
|
|
}
|
|
|
|
fitIosWindow().catch(() => void 0)
|
|
const timer = window.setTimeout(() => {
|
|
fitIosWindow().catch(() => void 0)
|
|
}, 300)
|
|
|
|
return () => {
|
|
window.clearTimeout(timer)
|
|
}
|
|
}, [isIosDevice])
|
|
|
|
useEffect(() => {
|
|
const api = (window as any).api
|
|
if (!api || typeof api.onDataUpdated !== "function") return
|
|
|
|
let disposed = false
|
|
let unlisten: (() => void) | null = null
|
|
|
|
api
|
|
.onDataUpdated((payload: { category?: string; source?: string }) => {
|
|
const detail = {
|
|
category: payload?.category || "all",
|
|
source: payload?.source || "tauri",
|
|
}
|
|
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail }))
|
|
})
|
|
.then((fn: () => void) => {
|
|
if (disposed) {
|
|
fn()
|
|
return
|
|
}
|
|
unlisten = fn
|
|
})
|
|
.catch(() => void 0)
|
|
|
|
return () => {
|
|
disposed = true
|
|
if (unlisten) unlisten()
|
|
}
|
|
}, [])
|
|
|
|
const applySyncStrategy = async (strategy: "keep_local" | "keep_remote") => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
setSyncApplyLoading(true)
|
|
syncApplyLoadingRef.current = true
|
|
try {
|
|
const res = await api.dbSyncApply(strategy)
|
|
if (res?.success && res?.data?.success) {
|
|
messageApi.success(
|
|
res.data.message ||
|
|
`同步完成(同步 ${res.data.synced_records} 条,解决冲突 ${res.data.resolved_conflicts} 条)`
|
|
)
|
|
window.dispatchEvent(
|
|
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
|
|
)
|
|
} else {
|
|
messageApi.error(res?.data?.message || res?.message || "同步失败")
|
|
}
|
|
} catch (error: any) {
|
|
messageApi.error(error?.message || "同步失败")
|
|
} finally {
|
|
setSyncApplyLoading(false)
|
|
syncApplyLoadingRef.current = false
|
|
setSyncConflictVisible(false)
|
|
setSyncConflicts([])
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
let disposed = false
|
|
|
|
const checkAndSync = async () => {
|
|
if (disposed || syncCheckingRef.current || syncApplyLoadingRef.current) return
|
|
if (permission !== "admin") return
|
|
try {
|
|
syncCheckingRef.current = true
|
|
const statusRes = await api.dbGetStatus()
|
|
if (
|
|
!statusRes?.success ||
|
|
statusRes?.data?.type !== "postgresql" ||
|
|
!statusRes?.data?.connected
|
|
) {
|
|
return
|
|
}
|
|
|
|
const previewRes = await api.dbSyncPreview()
|
|
if (!previewRes?.success || !previewRes?.data?.can_sync || !previewRes?.data?.need_sync) {
|
|
return
|
|
}
|
|
|
|
const conflicts = previewRes.data.conflicts || []
|
|
if (conflicts.length > 0) {
|
|
const recentLocalMutation = Date.now() - lastLocalMutationAtRef.current < 15000
|
|
if (recentLocalMutation) {
|
|
const autoApplyRes = await api.dbSyncApply("keep_remote")
|
|
if (
|
|
autoApplyRes?.success &&
|
|
autoApplyRes?.data?.success &&
|
|
autoApplyRes?.data?.synced_records > 0
|
|
) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
|
|
)
|
|
}
|
|
return
|
|
}
|
|
setSyncConflicts(conflicts)
|
|
setSyncConflictVisible(true)
|
|
return
|
|
}
|
|
|
|
const applyRes = await api.dbSyncApply("keep_remote")
|
|
if (applyRes?.success && applyRes?.data?.success && applyRes?.data?.synced_records > 0) {
|
|
window.dispatchEvent(
|
|
new CustomEvent("ss:data-updated", { detail: { category: "all", source: "sync" } })
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error("Auto sync failed:", error)
|
|
} finally {
|
|
syncCheckingRef.current = false
|
|
}
|
|
}
|
|
|
|
checkAndSync()
|
|
const timer = window.setInterval(checkAndSync, 30000)
|
|
const onDataUpdated = (e: Event) => {
|
|
const customEvent = e as CustomEvent<{ source?: string }>
|
|
if (customEvent?.detail?.source !== "sync") {
|
|
lastLocalMutationAtRef.current = Date.now()
|
|
}
|
|
window.setTimeout(() => {
|
|
checkAndSync().catch(() => void 0)
|
|
}, 1200)
|
|
}
|
|
window.addEventListener("ss:data-updated", onDataUpdated)
|
|
|
|
return () => {
|
|
disposed = true
|
|
window.clearInterval(timer)
|
|
window.removeEventListener("ss:data-updated", onDataUpdated)
|
|
}
|
|
}, [permission])
|
|
|
|
const login = async () => {
|
|
if (!(window as any).api) return
|
|
setAuthLoading(true)
|
|
const res = await (window as any).api.authLogin(authPassword)
|
|
setAuthLoading(false)
|
|
if (res.success && res.data) {
|
|
setPermission(res.data.permission)
|
|
setAuthVisible(false)
|
|
setAuthPassword("")
|
|
messageApi.success(t("auth.unlocked"))
|
|
} else {
|
|
messageApi.error(res.message || t("common.error"))
|
|
}
|
|
}
|
|
|
|
const logout = async () => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
|
|
// 退出时同时清理 OAuth 持久化状态,避免刷新后又自动恢复权限
|
|
try {
|
|
await api.oauthClearLoginState()
|
|
setOAuthUserName(null)
|
|
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
|
} catch (error) {
|
|
console.error("Failed to clear OAuth login state:", error)
|
|
}
|
|
|
|
const res = await api.authLogout()
|
|
if (res?.success && res.data) {
|
|
setPermission(res.data.permission)
|
|
messageApi.success(t("auth.logout"))
|
|
}
|
|
}
|
|
|
|
const logoutOAuthFromHeader = useCallback(async () => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
|
|
try {
|
|
await api.oauthClearLoginState()
|
|
setOAuthUserName(null)
|
|
window.dispatchEvent(new CustomEvent("ss:oauth-user-updated", { detail: { user: null } }))
|
|
await refreshPermissionFromAuth()
|
|
messageApi.success("已退出云账号")
|
|
} catch (error: any) {
|
|
messageApi.error(error?.message || t("common.error"))
|
|
}
|
|
}, [messageApi, refreshPermissionFromAuth, t])
|
|
|
|
const handleOAuthSuccess = (userInfo: {
|
|
user_id: string
|
|
email: string
|
|
name: string
|
|
github_username?: string
|
|
permission: number
|
|
}) => {
|
|
setPermission(mapOAuthPermissionToAppPermission(userInfo.permission))
|
|
setAuthVisible(false)
|
|
setOAuthUserName(userInfo.name || null)
|
|
messageApi.success(t("auth.oauthSuccess", "登录成功"))
|
|
}
|
|
|
|
const onMenuChange = (v: string) => {
|
|
const key = String(v)
|
|
setMoreNavVisible(false)
|
|
setEditingNav(false)
|
|
if (immersiveMode && key !== "home") return
|
|
if (key === "home") navigate("/")
|
|
if (key === "students") navigate("/students")
|
|
if (key === "score") navigate("/score")
|
|
if (key === "auto-score") navigate("/auto-score")
|
|
if (key === "reward-settings") navigate("/reward-settings")
|
|
if (key === "boards") navigate("/boards")
|
|
if (key === "leaderboard") navigate("/leaderboard")
|
|
if (key === "settlements") navigate("/settlements")
|
|
if (key === "reasons") navigate("/reasons")
|
|
if (key === "plugins") navigate("/plugins")
|
|
if (key === "settings") navigate("/settings")
|
|
}
|
|
|
|
const toggleSidebar = () => {
|
|
if (immersiveMode) return
|
|
if (isPortraitMode && sidebarCollapsed) {
|
|
setFloatingSidebarExpanded((prev) => !prev)
|
|
return
|
|
}
|
|
setSidebarCollapsed((prev) => !prev)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!immersiveMode) return
|
|
if (location.pathname !== "/" && !location.pathname.startsWith("/home")) {
|
|
navigate("/", { replace: true })
|
|
}
|
|
}, [immersiveMode, location.pathname, navigate])
|
|
|
|
const toggleImmersiveMode = () => {
|
|
setImmersiveMode((prev) => {
|
|
const next = !prev
|
|
if (next) {
|
|
setFloatingSidebarExpanded(false)
|
|
if (location.pathname !== "/" && !location.pathname.startsWith("/home")) {
|
|
navigate("/", { replace: true })
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const isDark = currentTheme?.mode === "dark"
|
|
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
|
|
const isMobileDevice = isIosDevice || isAndroidDevice
|
|
const showMobileBottomNav = isPortraitMode && !immersiveMode
|
|
const mobileBottomNavIconMap: Record<MobileNavKey, React.ReactNode> = {
|
|
home: <HomeOutlined style={{ fontSize: "18px" }} />,
|
|
students: <UserOutlined style={{ fontSize: "18px" }} />,
|
|
score: <HistoryOutlined style={{ fontSize: "18px" }} />,
|
|
"auto-score": <SyncOutlined style={{ fontSize: "18px" }} />,
|
|
"reward-settings": <AppstoreAddOutlined style={{ fontSize: "18px" }} />,
|
|
boards: <ApartmentOutlined style={{ fontSize: "18px" }} />,
|
|
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
|
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
|
|
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
|
plugins: <CrownOutlined style={{ fontSize: "18px" }} />,
|
|
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
|
}
|
|
|
|
const mobileNavAvailableItems = useMemo(
|
|
() => MOBILE_NAV_ITEMS.filter((item) => !(item.adminOnly && permission !== "admin")),
|
|
[permission]
|
|
)
|
|
const mobileBottomSelectedKeys = useMemo(() => {
|
|
const availableKeySet = new Set(mobileNavAvailableItems.map((item) => item.key))
|
|
return sanitizeMobileNavKeys(mobileBottomNavItems, [])
|
|
.filter((key) => availableKeySet.has(key))
|
|
.slice(0, 4)
|
|
}, [mobileBottomNavItems, mobileNavAvailableItems])
|
|
const mobileBottomPrimaryItems = useMemo(
|
|
() =>
|
|
mobileBottomSelectedKeys
|
|
.map((key) => mobileNavAvailableItems.find((item) => item.key === key))
|
|
.filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item)),
|
|
[mobileBottomSelectedKeys, mobileNavAvailableItems]
|
|
)
|
|
const mobileBottomOverflowItems = useMemo(
|
|
() => mobileNavAvailableItems.filter((item) => !mobileBottomSelectedKeys.includes(item.key)),
|
|
[mobileNavAvailableItems, mobileBottomSelectedKeys]
|
|
)
|
|
const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu)
|
|
|
|
const openEditNav = () => {
|
|
const savedKeys = mobileBottomSelectedKeys
|
|
const missingKeys = mobileNavAvailableItems
|
|
.map((item) => item.key)
|
|
.filter((key) => !savedKeys.includes(key))
|
|
setEditingBottomNavKeys(savedKeys)
|
|
setEditingMoreNavKeys(missingKeys)
|
|
setEditingNav(true)
|
|
setDraggingNavKey(null)
|
|
setDraggingFromList(null)
|
|
setDragOverSlot(null)
|
|
}
|
|
|
|
const dropPlaceholder = (
|
|
<div
|
|
style={{
|
|
border: "1px dashed var(--ant-color-primary)",
|
|
borderRadius: "10px",
|
|
background: "color-mix(in srgb, var(--ant-color-primary) 10%, transparent)",
|
|
minHeight: "44px",
|
|
padding: "8px 10px",
|
|
}}
|
|
/>
|
|
)
|
|
|
|
const persistMobileBottomKeys = async (nextKeys: MobileNavKey[]) => {
|
|
const api = (window as any).api
|
|
if (!api) return
|
|
const next = sanitizeMobileNavKeys(nextKeys, [])
|
|
const res = await api.setSetting("mobile_bottom_nav_items", next)
|
|
if (res.success) {
|
|
setMobileBottomNavItems(next)
|
|
} else {
|
|
messageApi.error(res.message || t("settings.general.saveFailed"))
|
|
}
|
|
}
|
|
|
|
const handleDropToList = (targetList: "bottom" | "more", targetIndex: number) => {
|
|
if (!draggingNavKey || !draggingFromList) return
|
|
const bottom = editingBottomNavKeys.slice()
|
|
const more = editingMoreNavKeys.slice()
|
|
|
|
const source = draggingFromList === "bottom" ? bottom : more
|
|
const sourceIndex = source.indexOf(draggingNavKey)
|
|
if (sourceIndex < 0) return
|
|
source.splice(sourceIndex, 1)
|
|
|
|
if (targetList === "bottom" && draggingFromList === "more" && bottom.length >= 4) {
|
|
setDraggingNavKey(null)
|
|
setDraggingFromList(null)
|
|
setDragOverSlot(null)
|
|
messageApi.warning(t("settings.mobile.bottomMaxHint", "底栏最多 4 个"))
|
|
return
|
|
}
|
|
|
|
const target = targetList === "bottom" ? bottom : more
|
|
const clampedIndex = Math.max(0, Math.min(targetIndex, target.length))
|
|
target.splice(clampedIndex, 0, draggingNavKey)
|
|
|
|
setEditingBottomNavKeys(bottom)
|
|
setEditingMoreNavKeys(more)
|
|
void persistMobileBottomKeys(bottom)
|
|
setDraggingNavKey(null)
|
|
setDraggingFromList(null)
|
|
setDragOverSlot(null)
|
|
}
|
|
|
|
return (
|
|
<ConfigProvider
|
|
theme={{
|
|
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
|
|
token: {
|
|
colorPrimary: brandColor,
|
|
fontFamily:
|
|
'"PingFang SC", "Hiragino Sans GB", "Heiti SC", "Noto Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", "微软雅黑", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
|
|
},
|
|
}}
|
|
>
|
|
{contextHolder}
|
|
<Layout
|
|
style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}
|
|
>
|
|
{!isPortraitMode && (
|
|
<div
|
|
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
|
|
style={
|
|
{
|
|
"--ss-sidebar-width": `${sidebarCollapsed ? 64 : 200}px`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<Sidebar
|
|
activeMenu={activeMenu}
|
|
permission={permission}
|
|
onMenuChange={onMenuChange}
|
|
collapsed={sidebarCollapsed}
|
|
floatingExpand={isPortraitMode}
|
|
floatingExpanded={floatingSidebarExpanded}
|
|
onFloatingExpandedChange={setFloatingSidebarExpanded}
|
|
/>
|
|
</div>
|
|
)}
|
|
<ContentArea
|
|
permission={permission}
|
|
oauthUserName={oauthUserName}
|
|
onOAuthLogout={logoutOAuthFromHeader}
|
|
hasAnyPassword={hasAnyPassword}
|
|
onAuthClick={() => setAuthVisible(true)}
|
|
onLogout={logout}
|
|
showWindowControls={!isIosDevice && !isAndroidDevice}
|
|
isPortraitMode={isPortraitMode}
|
|
isMobileDevice={isMobileDevice}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
floatingExpand={isPortraitMode}
|
|
floatingExpanded={floatingSidebarExpanded}
|
|
onToggleSidebar={toggleSidebar}
|
|
immersiveMode={immersiveMode}
|
|
isHomePage={activeMenu === "home"}
|
|
onToggleImmersiveMode={toggleImmersiveMode}
|
|
showSidebarToggle={!isPortraitMode}
|
|
bottomInset={showMobileBottomNav ? 84 : 0}
|
|
/>
|
|
{showMobileBottomNav && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
background: "var(--ss-header-bg)",
|
|
backdropFilter: "blur(14px)",
|
|
WebkitBackdropFilter: "blur(14px)",
|
|
borderTop: "1px solid var(--ss-border-color)",
|
|
zIndex: 1400,
|
|
paddingBottom: "env(safe-area-inset-bottom, 0px)",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", height: "60px" }}>
|
|
{mobileBottomPrimaryItems.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => onMenuChange(item.key)}
|
|
style={{
|
|
flex: 1,
|
|
border: "none",
|
|
background: "transparent",
|
|
color:
|
|
activeMenu === item.key
|
|
? "var(--ant-color-primary)"
|
|
: "var(--ss-text-secondary, var(--ss-text-main))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "2px",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
{mobileBottomNavIconMap[item.key]}
|
|
<span>{t(item.labelKey)}</span>
|
|
</button>
|
|
))}
|
|
{mobileBottomOverflowItems.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setMoreNavVisible(true)}
|
|
style={{
|
|
flex: 1,
|
|
border: "none",
|
|
background: "transparent",
|
|
color: isMoreActive
|
|
? "var(--ant-color-primary)"
|
|
: "var(--ss-text-secondary, var(--ss-text-main))",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "2px",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
<MoreOutlined style={{ fontSize: "18px" }} />
|
|
<span>{t("common.more", "更多")}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Drawer
|
|
title={editingNav ? t("common.edit") : t("common.more", "更多")}
|
|
placement="bottom"
|
|
open={moreNavVisible}
|
|
onClose={() => {
|
|
setMoreNavVisible(false)
|
|
setEditingNav(false)
|
|
setDraggingNavKey(null)
|
|
setDraggingFromList(null)
|
|
setDragOverSlot(null)
|
|
}}
|
|
height={editingNav ? "calc(100vh - env(safe-area-inset-top, 0px))" : "46vh"}
|
|
styles={{
|
|
content: {
|
|
borderTopLeftRadius: editingNav ? 0 : "14px",
|
|
borderTopRightRadius: editingNav ? 0 : "14px",
|
|
background: "var(--ss-card-bg)",
|
|
},
|
|
header: {
|
|
borderBottom: "1px solid var(--ss-border-color)",
|
|
padding: "12px 16px",
|
|
},
|
|
body: {
|
|
padding: "12px 16px 20px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
minHeight: 0,
|
|
},
|
|
}}
|
|
extra={
|
|
permission === "admin" &&
|
|
(editingNav ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingNav(false)}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: "8px",
|
|
background: "transparent",
|
|
color: "var(--ss-text-main)",
|
|
padding: "4px 10px",
|
|
}}
|
|
>
|
|
{t("common.finish", "完成")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={openEditNav}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: "8px",
|
|
background: "transparent",
|
|
color: "var(--ss-text-main)",
|
|
padding: "4px 10px",
|
|
}}
|
|
>
|
|
{t("common.edit")}
|
|
</button>
|
|
))
|
|
}
|
|
>
|
|
{editingNav ? (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "12px",
|
|
flex: 1,
|
|
minHeight: 0,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
|
{t(
|
|
"settings.mobile.dragHint",
|
|
"拖动可调整顺序。前 4 项显示在底栏,其余显示在“更多”。"
|
|
)}
|
|
</div>
|
|
{[
|
|
{
|
|
list: "bottom" as const,
|
|
title: t("settings.mobile.bottomSection", "底栏(最多 4 个)"),
|
|
keys: editingBottomNavKeys,
|
|
},
|
|
{
|
|
list: "more" as const,
|
|
title: t("settings.mobile.moreSection", "更多"),
|
|
keys: editingMoreNavKeys,
|
|
},
|
|
].map((group) => (
|
|
<div key={group.list} style={{ marginTop: group.list === "more" ? 10 : 0 }}>
|
|
<div
|
|
style={{
|
|
marginBottom: "8px",
|
|
fontSize: "12px",
|
|
color: "var(--ss-text-secondary)",
|
|
}}
|
|
>
|
|
{group.title}
|
|
</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
{Array.from({ length: group.keys.length + 1 }, (_, i) => {
|
|
const key = group.keys[i]
|
|
const item = key ? MOBILE_NAV_ITEMS.find((it) => it.key === key) : null
|
|
return (
|
|
<div key={`${group.list}-slot-${i}`}>
|
|
<div
|
|
onDragEnter={() => setDragOverSlot({ list: group.list, index: i })}
|
|
onDragOver={(e) => {
|
|
e.preventDefault()
|
|
setDragOverSlot({ list: group.list, index: i })
|
|
}}
|
|
onDrop={() => handleDropToList(group.list, i)}
|
|
>
|
|
{dragOverSlot?.list === group.list && dragOverSlot?.index === i ? (
|
|
dropPlaceholder
|
|
) : (
|
|
<div style={{ height: "8px" }} />
|
|
)}
|
|
</div>
|
|
{key && item && (
|
|
<button
|
|
type="button"
|
|
draggable
|
|
onDragStart={() => {
|
|
setDraggingNavKey(key)
|
|
setDraggingFromList(group.list)
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingNavKey(null)
|
|
setDraggingFromList(null)
|
|
setDragOverSlot(null)
|
|
}}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: "10px",
|
|
background: "var(--ss-bg-color)",
|
|
color: "var(--ss-text-main)",
|
|
minHeight: "44px",
|
|
padding: "8px 10px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "8px",
|
|
fontSize: "14px",
|
|
opacity: draggingNavKey === key ? 0.35 : 1,
|
|
transform: draggingNavKey === key ? "scale(0.985)" : "scale(1)",
|
|
transition: "opacity 120ms ease, transform 120ms ease",
|
|
cursor: "grab",
|
|
}}
|
|
>
|
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
{mobileBottomNavIconMap[key]}
|
|
<span>{t(item.labelKey)}</span>
|
|
</span>
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
color: "var(--ss-text-secondary)",
|
|
opacity: 0.9,
|
|
}}
|
|
>
|
|
<UpOutlined style={{ fontSize: "11px" }} />
|
|
<DownOutlined style={{ fontSize: "11px" }} />
|
|
<HolderOutlined style={{ fontSize: "12px" }} />
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
{mobileBottomOverflowItems.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
type="button"
|
|
onClick={() => onMenuChange(item.key)}
|
|
style={{
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: "10px",
|
|
background: "var(--ss-bg-color)",
|
|
color: "var(--ss-text-main)",
|
|
height: "44px",
|
|
padding: "0 12px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
fontSize: "14px",
|
|
}}
|
|
>
|
|
{mobileBottomNavIconMap[item.key]}
|
|
<span>{t(item.labelKey)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Drawer>
|
|
|
|
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
|
|
|
<Modal
|
|
title={t("auth.unlock")}
|
|
open={authVisible}
|
|
onCancel={() => setAuthVisible(false)}
|
|
onOk={login}
|
|
confirmLoading={authLoading}
|
|
okText={t("auth.unlockButton")}
|
|
cancelText={t("common.cancel")}
|
|
>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
<div style={{ color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
|
{t("auth.unlockHint")}
|
|
</div>
|
|
<Input
|
|
value={authPassword}
|
|
onChange={(e) => setAuthPassword(e.target.value)}
|
|
placeholder={t("auth.passwordPlaceholder")}
|
|
maxLength={6}
|
|
/>
|
|
<div style={{ textAlign: "center", marginTop: "8px" }}>
|
|
<Button
|
|
type="link"
|
|
onClick={() => {
|
|
setAuthVisible(false)
|
|
setOAuthVisible(true)
|
|
}}
|
|
>
|
|
{t("auth.useOAuth", "使用 SECTL Auth 登录")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<OAuthLogin
|
|
visible={oauthVisible}
|
|
onClose={() => setOAuthVisible(false)}
|
|
onSuccess={handleOAuthSuccess}
|
|
/>
|
|
|
|
<Modal
|
|
title="检测到本地与远程数据冲突"
|
|
open={syncConflictVisible}
|
|
onCancel={() => {
|
|
if (syncApplyLoading) return
|
|
setSyncConflictVisible(false)
|
|
}}
|
|
footer={null}
|
|
closable={!syncApplyLoading}
|
|
maskClosable={false}
|
|
destroyOnHidden
|
|
>
|
|
<div
|
|
style={{ marginBottom: "10px", color: "var(--ss-text-secondary)", fontSize: "12px" }}
|
|
>
|
|
自动同步发现冲突,请选择冲突时优先保留哪一侧的数据。
|
|
</div>
|
|
<div
|
|
style={{
|
|
maxHeight: "280px",
|
|
overflow: "auto",
|
|
border: "1px solid var(--ss-border-color)",
|
|
borderRadius: "6px",
|
|
padding: "8px",
|
|
marginBottom: "12px",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
{syncConflicts.slice(0, 30).map((item) => (
|
|
<div key={`${item.table}-${item.key}`} style={{ marginBottom: "8px" }}>
|
|
<div>
|
|
<b>{item.table}</b> / <b>{item.key}</b>
|
|
</div>
|
|
<div>本地: {item.local_summary}</div>
|
|
<div>远程: {item.remote_summary}</div>
|
|
</div>
|
|
))}
|
|
{syncConflicts.length > 30 && <div>仅显示前 30 条冲突...</div>}
|
|
</div>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
|
|
<button
|
|
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
|
|
disabled={syncApplyLoading}
|
|
onClick={() => applySyncStrategy("keep_remote")}
|
|
>
|
|
保留远程
|
|
</button>
|
|
<button
|
|
style={{ padding: "6px 10px", cursor: syncApplyLoading ? "not-allowed" : "pointer" }}
|
|
disabled={syncApplyLoading}
|
|
onClick={() => applySyncStrategy("keep_local")}
|
|
>
|
|
保留本地
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
{import.meta.env.DEV ? (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
display: "flex",
|
|
bottom: "2px",
|
|
left: "20px",
|
|
opacity: 0.6,
|
|
zIndex: 9999,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<p
|
|
style={{
|
|
color: "#df0000",
|
|
fontWeight: "bold",
|
|
fontSize: "14px",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
开发中画面,不代表最终品质
|
|
</p>
|
|
<p
|
|
style={{
|
|
color: currentTheme?.mode === "dark" ? "#fff" : "#44474b",
|
|
fontWeight: "bold",
|
|
fontSize: "13px",
|
|
paddingLeft: "5px",
|
|
}}
|
|
>
|
|
SecScore Dev ({getPlatform()}-{getArchitecture()})
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</Layout>
|
|
</ConfigProvider>
|
|
)
|
|
}
|
|
|
|
function getArchitecture(): string {
|
|
const userAgent = navigator.userAgent.toLowerCase()
|
|
|
|
if (userAgent.includes("arm64") || userAgent.includes("aarch64")) {
|
|
return "ARM64"
|
|
} else if (
|
|
userAgent.includes("x64") ||
|
|
userAgent.includes("amd64") ||
|
|
userAgent.includes("x86_64") ||
|
|
userAgent.includes("intel")
|
|
) {
|
|
return "x64"
|
|
} else if (userAgent.includes("i386") || userAgent.includes("i686")) {
|
|
return "x86"
|
|
}
|
|
|
|
return "Unknown"
|
|
}
|
|
|
|
function getPlatform(): string {
|
|
const userAgent = navigator.userAgent.toLowerCase()
|
|
|
|
if (userAgent.includes("iphone") || userAgent.includes("ipad") || userAgent.includes("ipod")) {
|
|
return "iOS"
|
|
} else if (userAgent.includes("android")) {
|
|
return "Android"
|
|
}
|
|
|
|
if (userAgent.includes("windows")) {
|
|
return "Windows"
|
|
} else if (userAgent.includes("mac")) {
|
|
return "Mac"
|
|
} else if (userAgent.includes("linux")) {
|
|
return "Linux"
|
|
}
|
|
|
|
return "Unknown"
|
|
}
|
|
|
|
function getIosDeviceInfo(): { isIosDevice: boolean; isIosPhone: boolean } {
|
|
const userAgent = navigator.userAgent.toLowerCase()
|
|
const isIosDevice = /iphone|ipad|ipod/.test(userAgent)
|
|
const isIosTablet = userAgent.includes("ipad")
|
|
return {
|
|
isIosDevice,
|
|
isIosPhone: isIosDevice && !isIosTablet,
|
|
}
|
|
}
|
|
|
|
function getMobileDeviceInfo(): {
|
|
isIosDevice: boolean
|
|
isAndroidDevice: boolean
|
|
defaultPortraitMode: boolean
|
|
} {
|
|
const { isIosDevice, isIosPhone } = getIosDeviceInfo()
|
|
const isAndroidDevice = navigator.userAgent.toLowerCase().includes("android")
|
|
return {
|
|
isIosDevice,
|
|
isAndroidDevice,
|
|
defaultPortraitMode: isIosPhone || isAndroidDevice,
|
|
}
|
|
}
|
|
function App(): React.JSX.Element {
|
|
return (
|
|
<ThemeProvider>
|
|
<HashRouter>
|
|
<Routes>
|
|
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
|
<Route path="/*" element={<MainContent />} />
|
|
</Routes>
|
|
</HashRouter>
|
|
</ThemeProvider>
|
|
)
|
|
}
|
|
|
|
export default App
|