mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 18:19:03 +08:00
@@ -630,9 +630,9 @@ export const normalizeActionDrafts = (drafts: ActionDraft[] | null | undefined):
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
: draft.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(Array.isArray(draft.value) ? draft.value[0] : draft.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
@@ -664,9 +664,9 @@ export const actionsToDrafts = (actions: AutoScoreAction[]): ActionDraft[] => {
|
||||
? stringifyRewardActionValue(parsedValue.rewardId, parsedValue.rewardName) || ""
|
||||
: ""
|
||||
})()
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
: action.event === "settle_score"
|
||||
? ""
|
||||
: toStringValue(action.value),
|
||||
} satisfies ActionDraft
|
||||
})
|
||||
.filter((item): item is ActionDraft => Boolean(item))
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import React from "react"
|
||||
import { Card, List, Button, Tag, Space, Typography, Spin, Empty, message } from "antd"
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
DashboardOutlined,
|
||||
HistoryOutlined,
|
||||
CloudOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useBuiltinPlugins } from "../hooks/useBuiltinPlugins"
|
||||
import { BuiltinPluginMeta } from "../plugins/builtin"
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
automation: <ThunderboltOutlined />,
|
||||
visualization: <DashboardOutlined />,
|
||||
management: <HistoryOutlined />,
|
||||
integration: <CloudOutlined />,
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
automation: "自动化",
|
||||
visualization: "可视化",
|
||||
management: "管理",
|
||||
integration: "集成",
|
||||
}
|
||||
|
||||
interface BuiltinPluginManagerProps {
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export const BuiltinPluginManager: React.FC<BuiltinPluginManagerProps> = ({ canEdit }) => {
|
||||
const { plugins, loading, install, uninstall, enable, disable } = useBuiltinPlugins()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const handleInstall = async (pluginId: string) => {
|
||||
try {
|
||||
await install(pluginId)
|
||||
messageApi.success("插件安装成功")
|
||||
} catch (error) {
|
||||
messageApi.error("插件安装失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstall = async (pluginId: string) => {
|
||||
try {
|
||||
await uninstall(pluginId)
|
||||
messageApi.success("插件已卸载")
|
||||
} catch (error) {
|
||||
messageApi.error("插件卸载失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnable = async (pluginId: string) => {
|
||||
try {
|
||||
await enable(pluginId)
|
||||
messageApi.success("插件已启用")
|
||||
} catch (error) {
|
||||
messageApi.error("插件启用失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async (pluginId: string) => {
|
||||
try {
|
||||
await disable(pluginId)
|
||||
messageApi.success("插件已禁用")
|
||||
} catch (error) {
|
||||
messageApi.error("插件禁用失败")
|
||||
}
|
||||
}
|
||||
|
||||
const renderPluginActions = (
|
||||
plugin: BuiltinPluginMeta & { installed: boolean; enabled: boolean }
|
||||
) => {
|
||||
if (!plugin.installed) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleInstall(plugin.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{plugin.enabled ? (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => handleDisable(plugin.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
禁用
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleEnable(plugin.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleUninstall(plugin.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
卸载
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<Title level={4}>内置插件管理</Title>
|
||||
<Paragraph type="secondary">
|
||||
管理 SecScore 的内置功能插件。安装后可在侧边栏访问对应功能。
|
||||
</Paragraph>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "40px" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<Empty description="暂无可用插件" />
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
|
||||
dataSource={plugins}
|
||||
renderItem={(plugin) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
borderColor: plugin.enabled
|
||||
? "var(--ant-color-primary)"
|
||||
: "var(--ss-border-color)",
|
||||
}}
|
||||
title={
|
||||
<Space>
|
||||
{categoryIcons[plugin.category]}
|
||||
<span>{plugin.name}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tag color={plugin.enabled ? "success" : "default"}>
|
||||
{plugin.enabled ? "已启用" : plugin.installed ? "已安装" : "未安装"}
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<Text type="secondary">{plugin.description}</Text>
|
||||
</div>
|
||||
<Space direction="vertical" size="small" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Tag>{categoryLabels[plugin.category]}</Tag>
|
||||
{plugin.requiresAdmin && <Tag color="warning">需要管理员权限</Tag>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: "12px" }}>
|
||||
版本 {plugin.version}
|
||||
</Text>
|
||||
{renderPluginActions(plugin)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { WindowControls } from "./WindowControls"
|
||||
import appLogo from "../assets/logoHD.svg"
|
||||
import { usePluginEnabled } from "../hooks/useBuiltinPlugins"
|
||||
|
||||
const loadHome = () => import("./Home")
|
||||
const loadStudentManager = () => import("./StudentManager")
|
||||
const loadSettings = () => import("./Settings")
|
||||
const loadReasonManager = () => import("./ReasonManager")
|
||||
const loadScoreManager = () => import("./ScoreManager")
|
||||
const loadAutoScoreManager = () => import("./AutoScoreManager")
|
||||
@@ -26,6 +26,7 @@ const loadPluginManager = () => import("./PluginManager")
|
||||
|
||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||
const Settings = lazy(() => loadSettings().then((m) => ({ default: m.Settings })))
|
||||
const ReasonManager = lazy(() => loadReasonManager().then((m) => ({ default: m.ReasonManager })))
|
||||
const ScoreManager = lazy(() => loadScoreManager().then((m) => ({ default: m.ScoreManager })))
|
||||
const AutoScoreManager = lazy(loadAutoScoreManager)
|
||||
@@ -41,6 +42,7 @@ const warmupRouteChunks = () =>
|
||||
Promise.allSettled([
|
||||
loadHome(),
|
||||
loadStudentManager(),
|
||||
loadSettings(),
|
||||
loadReasonManager(),
|
||||
loadScoreManager(),
|
||||
loadAutoScoreManager(),
|
||||
@@ -112,13 +114,6 @@ export function ContentArea({
|
||||
const isPrimaryMobilePage =
|
||||
normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings")
|
||||
const showMobileBack = isMobileHeaderMode && !isPrimaryMobilePage
|
||||
|
||||
// 检查插件启用状态
|
||||
const autoScoreEnabled = usePluginEnabled("auto-score")
|
||||
const boardsEnabled = usePluginEnabled("boards")
|
||||
const settlementsEnabled = usePluginEnabled("settlements")
|
||||
const rewardSettingsEnabled = usePluginEnabled("reward-settings")
|
||||
|
||||
const mobilePageTitle = (() => {
|
||||
if (normalizedPath.startsWith("/home")) return t("sidebar.home")
|
||||
if (normalizedPath.startsWith("/students")) return t("sidebar.students")
|
||||
@@ -669,28 +664,20 @@ export function ContentArea({
|
||||
<ScoreManager canEdit={permission === "admin" || permission === "points"} />
|
||||
}
|
||||
/>
|
||||
{autoScoreEnabled && (
|
||||
<Route
|
||||
path="/auto-score"
|
||||
element={<AutoScoreManager canEdit={permission === "admin"} />}
|
||||
/>
|
||||
)}
|
||||
{boardsEnabled && (
|
||||
<Route
|
||||
path="/boards"
|
||||
element={<BoardManager canManage={permission === "admin"} />}
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path="/auto-score"
|
||||
element={<AutoScoreManager canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
{settlementsEnabled && <Route path="/settlements" element={<SettlementHistory />} />}
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
{rewardSettingsEnabled && (
|
||||
<Route
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route path="/plugins" element={<PluginManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
@@ -699,3 +686,4 @@ export function ContentArea({
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@ import {
|
||||
Descriptions,
|
||||
Upload,
|
||||
Tooltip,
|
||||
Tabs,
|
||||
} from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { UploadOutlined, DeleteOutlined, FolderOpenOutlined } from "@ant-design/icons"
|
||||
import { BuiltinPluginManager } from "./BuiltinPluginManager"
|
||||
|
||||
interface Plugin {
|
||||
id: string
|
||||
@@ -51,8 +49,6 @@ export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [installLoading, setInstallLoading] = useState(false)
|
||||
const [selectedPath, setSelectedPath] = useState<string>("")
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [activeTab, setActiveTab] = useState("external")
|
||||
|
||||
const emitPluginsUpdated = (action: "install" | "uninstall" | "toggle", pluginId?: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:plugins-updated", {
|
||||
@@ -236,61 +232,44 @@ export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
},
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "external",
|
||||
label: "外部插件",
|
||||
children: (
|
||||
<>
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label={t("plugin.totalPlugins")}>
|
||||
<Tag color="blue">{stats.total_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.enabledPlugins")}>
|
||||
<Tag color="success">{stats.enabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.disabledPlugins")}>
|
||||
<Tag color="default">{stats.disabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FolderOpenOutlined />}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setInstallModalVisible(true)}
|
||||
>
|
||||
{t("plugin.install")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: t("plugin.noPlugins") }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "builtin",
|
||||
label: "内置插件",
|
||||
children: <BuiltinPluginManager canEdit={canEdit} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label={t("plugin.totalPlugins")}>
|
||||
<Tag color="blue">{stats.total_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.enabledPlugins")}>
|
||||
<Tag color="success">{stats.enabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.disabledPlugins")}>
|
||||
<Tag color="default">{stats.disabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FolderOpenOutlined />}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setInstallModalVisible(true)}
|
||||
>
|
||||
{t("plugin.install")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: t("plugin.noPlugins") }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("plugin.install")}
|
||||
|
||||
@@ -855,13 +855,7 @@ export const Settings: React.FC<{
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 主题设置 - 预留区域 */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: "12px", fontSize: "16px" }}>
|
||||
{t("settings.theme.title", "主题设置")}
|
||||
</div>
|
||||
<ThemeQuickSettings />
|
||||
</div>
|
||||
<ThemeQuickSettings />
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Menu, Card, Form, Select, Switch, message, Layout, Button, Space } from "antd"
|
||||
import {
|
||||
SettingOutlined,
|
||||
SafetyOutlined,
|
||||
UserOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
LinkOutlined,
|
||||
InfoCircleOutlined,
|
||||
ApiOutlined,
|
||||
CloseOutlined,
|
||||
MinusOutlined,
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { pinyin } from "pinyin-pro"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
import {
|
||||
buildSystemFontFamily,
|
||||
buildSystemFontValue,
|
||||
SYSTEM_FONT_STACK,
|
||||
} from "../shared/fontFamily"
|
||||
import { useConfig } from "../hooks/useConfig"
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
type permissionLevel = "admin" | "points" | "view"
|
||||
|
||||
interface FontOption {
|
||||
value: string
|
||||
label: string
|
||||
fontFamily: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const CHINESE_FONT_CHAR_PATTERN = /[\u3400-\u9fff]/
|
||||
const CHINESE_FONT_SEGMENT_PATTERN = /[\u3400-\u9fff]+/g
|
||||
|
||||
const toPascalCasePinyin = (value: string): string =>
|
||||
pinyin(value, { toneType: "none" })
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
||||
.join("")
|
||||
|
||||
const formatFontDisplayName = (name: string): string => {
|
||||
if (!CHINESE_FONT_CHAR_PATTERN.test(name)) return name
|
||||
return name.replace(CHINESE_FONT_SEGMENT_PATTERN, (segment) => toPascalCasePinyin(segment))
|
||||
}
|
||||
|
||||
const buildFontSearchText = (name: string, label: string): string =>
|
||||
`${name} ${label}`.toLowerCase()
|
||||
|
||||
const defaultFontOptions: FontOption[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: "系统默认",
|
||||
fontFamily: SYSTEM_FONT_STACK,
|
||||
searchText: "系统默认 system default",
|
||||
},
|
||||
]
|
||||
|
||||
const mergeFontOptions = (options: FontOption[]): FontOption[] => {
|
||||
const map = new Map<string, FontOption>()
|
||||
for (const option of options) {
|
||||
if (!map.has(option.value)) {
|
||||
map.set(option.value, option)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => {
|
||||
if (!value) return options.find((item) => item.value === "system") || options[0]
|
||||
return (
|
||||
options.find((item) => item.value === value) || options.find((item) => item.value === "system")
|
||||
)
|
||||
}
|
||||
|
||||
const applyFontFamily = (fontFamily: string) => {
|
||||
document.documentElement.style.setProperty("--ss-font-family", fontFamily)
|
||||
document.body.style.fontFamily = fontFamily
|
||||
}
|
||||
|
||||
export const SettingsWindow: React.FC<{
|
||||
permission: permissionLevel
|
||||
}> = ({ permission }) => {
|
||||
const { t } = useTranslation()
|
||||
const breakpoint = useResponsive()
|
||||
const isMobile = breakpoint === "xs" || breakpoint === "sm"
|
||||
const [activeTab, setActiveTab] = useState("appearance")
|
||||
const [currentLanguage, setCurrentLanguage] = useState<AppLanguage>(getCurrentLanguage())
|
||||
const [fontFamily, setFontFamily, fontLoading] = useConfig("font_family", "system")
|
||||
const [windowZoom, setWindowZoom] = useConfig("window_zoom", 1.0)
|
||||
const [searchKeyboardLayout, setSearchKeyboardLayout] = useConfig(
|
||||
"search_keyboard_layout",
|
||||
"qwerty26" as any
|
||||
)
|
||||
const [disableSearchKeyboard, setDisableSearchKeyboard] = useConfig(
|
||||
"disable_search_keyboard",
|
||||
false
|
||||
)
|
||||
|
||||
const [fontOptions, setFontOptions] = useState<FontOption[]>(defaultFontOptions)
|
||||
const [isLoadingFonts, setIsLoadingFonts] = useState(false)
|
||||
const fontOptionsRef = useRef<FontOption[]>(defaultFontOptions)
|
||||
|
||||
const canAdmin = permission === "admin"
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
// 窗口控制状态
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
// 窗口控制函数
|
||||
const handleMinimize = async () => {
|
||||
const api = (window as any).api
|
||||
if (api?.windowMinimize) {
|
||||
await api.windowMinimize()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
const api = (window as any).api
|
||||
if (api?.windowMaximize) {
|
||||
const result = await api.windowMaximize()
|
||||
setIsMaximized(result)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
const api = (window as any).api
|
||||
if (api?.closeSettingsWindow) {
|
||||
await api.closeSettingsWindow()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听最大化状态变化
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
if (api?.onWindowMaximizedChanged) {
|
||||
api.onWindowMaximizedChanged((maximized: boolean) => {
|
||||
setIsMaximized(maximized)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSystemFonts = async (selectedFontValue?: string) => {
|
||||
setIsLoadingFonts(true)
|
||||
|
||||
const applySelectedFont = (options: FontOption[]) => {
|
||||
const current = findFontOption(options, selectedFontValue || fontFamily)
|
||||
if (current) applyFontFamily(current.fontFamily)
|
||||
}
|
||||
|
||||
const api = (window as any).api
|
||||
if (!api?.getSystemFonts) {
|
||||
setFontOptions(defaultFontOptions)
|
||||
applySelectedFont(defaultFontOptions)
|
||||
setIsLoadingFonts(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.getSystemFonts()
|
||||
if (res.success && Array.isArray(res.data)) {
|
||||
const remoteOptions = res.data
|
||||
.map((name: string) => String(name || "").trim())
|
||||
.filter((name: string) => Boolean(name))
|
||||
.map((name: string) => {
|
||||
const label = formatFontDisplayName(name)
|
||||
return {
|
||||
value: buildSystemFontValue(name),
|
||||
label,
|
||||
fontFamily: buildSystemFontFamily(name),
|
||||
searchText: buildFontSearchText(name, label),
|
||||
} satisfies FontOption
|
||||
})
|
||||
.sort((a: FontOption, b: FontOption) =>
|
||||
a.label.localeCompare(b.label, "zh-Hans-CN-u-co-pinyin")
|
||||
)
|
||||
|
||||
const mergedOptions = mergeFontOptions([...defaultFontOptions, ...remoteOptions])
|
||||
setFontOptions(mergedOptions)
|
||||
applySelectedFont(mergedOptions)
|
||||
} else {
|
||||
setFontOptions(defaultFontOptions)
|
||||
applySelectedFont(defaultFontOptions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load system fonts:", error)
|
||||
setFontOptions(defaultFontOptions)
|
||||
applySelectedFont(defaultFontOptions)
|
||||
} finally {
|
||||
setIsLoadingFonts(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fontOptionsRef.current = fontOptions
|
||||
}, [fontOptions])
|
||||
|
||||
useEffect(() => {
|
||||
loadSystemFonts(fontFamily)
|
||||
}, [])
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: "appearance",
|
||||
icon: <SettingOutlined />,
|
||||
label: t("settings.tabs.appearance"),
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
icon: <SafetyOutlined />,
|
||||
label: t("settings.tabs.security"),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
icon: <UserOutlined />,
|
||||
label: t("settings.tabs.account"),
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
icon: <DatabaseOutlined />,
|
||||
label: t("settings.database.title"),
|
||||
},
|
||||
{
|
||||
key: "data",
|
||||
icon: <FileTextOutlined />,
|
||||
label: t("settings.data.title"),
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
icon: <LinkOutlined />,
|
||||
label: t("settings.url.title"),
|
||||
},
|
||||
{
|
||||
key: "about",
|
||||
icon: <InfoCircleOutlined />,
|
||||
label: t("settings.about.title"),
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
icon: <ApiOutlined />,
|
||||
label: t("settings.mcp.title"),
|
||||
},
|
||||
]
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case "appearance":
|
||||
return (
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t("settings.language")}>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={async (v: AppLanguage) => {
|
||||
await changeLanguage(v)
|
||||
setCurrentLanguage(v)
|
||||
messageApi.success(t("common.success"))
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
options={languageOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<ThemeQuickSettings />
|
||||
|
||||
<Form.Item label={t("settings.fontFamily")}>
|
||||
<Select
|
||||
value={fontFamily}
|
||||
onChange={async (v) => {
|
||||
const fontOpt = findFontOption(fontOptions, String(v))
|
||||
if (!fontOpt) return
|
||||
applyFontFamily(fontOpt.fontFamily)
|
||||
await setFontFamily(String(v))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
loading={isLoadingFonts || fontLoading}
|
||||
options={fontOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
searchText: opt.searchText,
|
||||
}))}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
String(option?.searchText ?? option?.label ?? "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Form.Item label={t("settings.searchKeyboard.title")}>
|
||||
<Select
|
||||
value={searchKeyboardLayout}
|
||||
onChange={async (v) => {
|
||||
await setSearchKeyboardLayout(String(v) as any)
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
disabled={!canAdmin}
|
||||
options={[
|
||||
{ value: "qwerty26", label: t("settings.searchKeyboard.options.qwerty26") },
|
||||
{ value: "t9", label: t("settings.searchKeyboard.options.t9") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("settings.searchKeyboard.disableToggle")}>
|
||||
<Switch
|
||||
checked={disableSearchKeyboard}
|
||||
onChange={async (checked) => {
|
||||
await setDisableSearchKeyboard(checked)
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
}}
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={t("settings.interfaceZoom")}>
|
||||
<Select
|
||||
value={String(windowZoom)}
|
||||
onChange={async (v) => {
|
||||
await setWindowZoom(Number(v))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
}}
|
||||
style={{ width: "320px" }}
|
||||
disabled={!canAdmin}
|
||||
options={[
|
||||
{ value: "0.7", label: t("settings.zoomOptions.small70") },
|
||||
{ value: "0.8", label: "80%" },
|
||||
{ value: "0.9", label: "90%" },
|
||||
{ value: "1.0", label: t("settings.zoomOptions.default100") },
|
||||
{ value: "1.1", label: "110%" },
|
||||
{ value: "1.2", label: "120%" },
|
||||
{ value: "1.3", label: "130%" },
|
||||
{ value: "1.5", label: t("settings.zoomOptions.large150") },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}>
|
||||
<div
|
||||
style={{ textAlign: "center", padding: "40px", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("common.comingSoon")}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh", background: "var(--ss-bg)" }}>
|
||||
{contextHolder}
|
||||
{!isMobile && (
|
||||
<Layout.Sider
|
||||
width={240}
|
||||
style={{
|
||||
background: "var(--ss-card-bg)",
|
||||
borderRight: "1px solid var(--ss-border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px", color: "var(--ss-text-main)", fontWeight: 600 }}>
|
||||
{t("settings.title")}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeTab]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => setActiveTab(key)}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
)}
|
||||
<Layout>
|
||||
{/* 窗口标题栏 */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={{
|
||||
height: "40px",
|
||||
background: "var(--ss-card-bg)",
|
||||
borderBottom: "1px solid var(--ss-border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 16px",
|
||||
userSelect: "none",
|
||||
// @ts-ignore - Tauri specific property
|
||||
WebkitAppRegion: "drag",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, color: "var(--ss-text-main)" }}>{t("settings.title")}</div>
|
||||
<Space
|
||||
size={4}
|
||||
style={{
|
||||
// @ts-ignore - Tauri specific property
|
||||
WebkitAppRegion: "no-drag",
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" icon={<MinusOutlined />} onClick={handleMinimize} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isMaximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={handleMaximize}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Content style={{ padding: "24px", overflow: "auto" }}>
|
||||
<div style={{ maxWidth: "900px", margin: "0 auto" }}>{renderContent()}</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
{isMobile && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "var(--ss-card-bg)",
|
||||
borderTop: "1px solid var(--ss-border)",
|
||||
display: "flex",
|
||||
justifyContent: "space-around",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color:
|
||||
activeTab === item.key ? "var(--ant-color-primary)" : "var(--ss-text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
+60
-121
@@ -16,7 +16,6 @@ import {
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import appLogo from "../assets/logoHD.svg"
|
||||
import { usePluginEnabled } from "../hooks/useBuiltinPlugins"
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
@@ -36,25 +35,6 @@ interface DbStatus {
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 插件菜单项配置
|
||||
const PLUGIN_MENU_ITEMS: Record<
|
||||
string,
|
||||
{ icon: React.ReactElement; labelKey: string; requiresAdmin: boolean }
|
||||
> = {
|
||||
"auto-score": { icon: <SyncOutlined />, labelKey: "sidebar.autoScore", requiresAdmin: true },
|
||||
boards: { icon: <ApartmentOutlined />, labelKey: "sidebar.boards", requiresAdmin: false },
|
||||
settlements: {
|
||||
icon: <FileTextOutlined />,
|
||||
labelKey: "sidebar.settlements",
|
||||
requiresAdmin: false,
|
||||
},
|
||||
"reward-settings": {
|
||||
icon: <AppstoreAddOutlined />,
|
||||
labelKey: "sidebar.rewardSettings",
|
||||
requiresAdmin: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
activeMenu,
|
||||
permission,
|
||||
@@ -69,12 +49,6 @@ export function Sidebar({
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
// 检查插件启用状态
|
||||
const autoScoreEnabled = usePluginEnabled("auto-score")
|
||||
const boardsEnabled = usePluginEnabled("boards")
|
||||
const settlementsEnabled = usePluginEnabled("settlements")
|
||||
const rewardSettingsEnabled = usePluginEnabled("reward-settings")
|
||||
|
||||
useEffect(() => {
|
||||
loadDbStatus()
|
||||
const handleStatusChange = () => {
|
||||
@@ -173,95 +147,70 @@ export function Sidebar({
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单项
|
||||
const buildMenuItems = () => {
|
||||
const items = [
|
||||
{
|
||||
key: "home",
|
||||
icon: <HomeOutlined />,
|
||||
label: t("sidebar.home"),
|
||||
},
|
||||
{
|
||||
key: "students",
|
||||
icon: <UserOutlined />,
|
||||
label: t("sidebar.students"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
icon: <HistoryOutlined />,
|
||||
label: t("sidebar.score"),
|
||||
},
|
||||
]
|
||||
|
||||
// 根据插件状态添加菜单项
|
||||
if (autoScoreEnabled) {
|
||||
items.push({
|
||||
key: "auto-score",
|
||||
icon: PLUGIN_MENU_ITEMS["auto-score"].icon,
|
||||
label: t(PLUGIN_MENU_ITEMS["auto-score"].labelKey),
|
||||
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["auto-score"].requiresAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
if (rewardSettingsEnabled) {
|
||||
items.push({
|
||||
key: "reward-settings",
|
||||
icon: PLUGIN_MENU_ITEMS["reward-settings"].icon,
|
||||
label: t(PLUGIN_MENU_ITEMS["reward-settings"].labelKey),
|
||||
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["reward-settings"].requiresAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
if (boardsEnabled) {
|
||||
items.push({
|
||||
key: "boards",
|
||||
icon: PLUGIN_MENU_ITEMS["boards"].icon,
|
||||
label: t(PLUGIN_MENU_ITEMS["boards"].labelKey),
|
||||
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["boards"].requiresAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
const menuItems = [
|
||||
{
|
||||
key: "home",
|
||||
icon: <HomeOutlined />,
|
||||
label: t("sidebar.home"),
|
||||
},
|
||||
{
|
||||
key: "students",
|
||||
icon: <UserOutlined />,
|
||||
label: t("sidebar.students"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "score",
|
||||
icon: <HistoryOutlined />,
|
||||
label: t("sidebar.score"),
|
||||
},
|
||||
{
|
||||
key: "auto-score",
|
||||
icon: <SyncOutlined />,
|
||||
label: t("sidebar.autoScore"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "reward-settings",
|
||||
icon: <AppstoreAddOutlined />,
|
||||
label: t("sidebar.rewardSettings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "boards",
|
||||
icon: <ApartmentOutlined />,
|
||||
label: t("sidebar.boards"),
|
||||
},
|
||||
{
|
||||
key: "leaderboard",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: t("sidebar.leaderboard"),
|
||||
})
|
||||
},
|
||||
{
|
||||
key: "settlements",
|
||||
icon: <FileTextOutlined />,
|
||||
label: t("sidebar.settlements"),
|
||||
},
|
||||
{
|
||||
key: "reasons",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: t("sidebar.reasons"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "plugins",
|
||||
icon: <CrownOutlined />,
|
||||
label: t("sidebar.plugins"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: t("sidebar.settings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
]
|
||||
|
||||
if (settlementsEnabled) {
|
||||
items.push({
|
||||
key: "settlements",
|
||||
icon: PLUGIN_MENU_ITEMS["settlements"].icon,
|
||||
label: t(PLUGIN_MENU_ITEMS["settlements"].labelKey),
|
||||
disabled: permission !== "admin" && PLUGIN_MENU_ITEMS["settlements"].requiresAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: "reasons",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: t("sidebar.reasons"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "plugins",
|
||||
icon: <CrownOutlined />,
|
||||
label: t("sidebar.plugins"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: t("sidebar.settings"),
|
||||
disabled: permission !== "admin",
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
const menuItems = buildMenuItems()
|
||||
const showFloatingPanel = floatingExpand && collapsed && floatingExpanded
|
||||
|
||||
const renderSidebarBody = (isCollapsedView: boolean, hideMenu = false) => (
|
||||
@@ -328,16 +277,6 @@ export function Sidebar({
|
||||
inlineCollapsed={isCollapsedView}
|
||||
selectedKeys={[activeMenu]}
|
||||
onClick={({ key }) => {
|
||||
if (key === "settings") {
|
||||
// 打开设置窗口
|
||||
const api = (window as any).api
|
||||
if (api?.openSettingsWindow) {
|
||||
api.openSettingsWindow().catch((err: any) => {
|
||||
console.error("Failed to open settings window:", err)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
onMenuChange(key)
|
||||
if (floatingExpand && collapsed) {
|
||||
onFloatingExpandedChange(false)
|
||||
|
||||
Reference in New Issue
Block a user