mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 09:39:03 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
+61
-11
@@ -1,4 +1,13 @@
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme, Drawer } from "antd"
|
||||
import {
|
||||
Layout,
|
||||
Modal,
|
||||
Input,
|
||||
message,
|
||||
ConfigProvider,
|
||||
theme as antTheme,
|
||||
Drawer,
|
||||
Button,
|
||||
} from "antd"
|
||||
import {
|
||||
HomeOutlined,
|
||||
SettingOutlined,
|
||||
@@ -20,15 +29,16 @@ 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/OAuthLogin"
|
||||
import { OAuthCallback } from "./components/OAuthCallback"
|
||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
import {
|
||||
MOBILE_NAV_ITEMS,
|
||||
MobileNavKey,
|
||||
sanitizeMobileNavKeys,
|
||||
} from "./shared/mobileNavigation"
|
||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||
|
||||
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 DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
||||
0,
|
||||
4
|
||||
)
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -88,6 +98,7 @@ function MainContent(): React.JSX.Element {
|
||||
const [authVisible, setAuthVisible] = useState(false)
|
||||
const [authPassword, setAuthPassword] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
const [oauthVisible, setOAuthVisible] = useState(false)
|
||||
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
|
||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
||||
)
|
||||
@@ -97,9 +108,10 @@ function MainContent(): React.JSX.Element {
|
||||
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 [dragOverSlot, setDragOverSlot] = useState<{
|
||||
list: "bottom" | "more"
|
||||
index: number
|
||||
} | null>(null)
|
||||
const [isPortraitMode] = useState(defaultPortraitMode)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
||||
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
||||
@@ -379,6 +391,24 @@ function MainContent(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthSuccess = (userInfo: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
}) => {
|
||||
let newPermission: "admin" | "points" | "view" = "view"
|
||||
if (userInfo.permission >= 18) {
|
||||
newPermission = "admin"
|
||||
} else if (userInfo.permission >= 1) {
|
||||
newPermission = "points"
|
||||
}
|
||||
|
||||
setPermission(newPermission)
|
||||
messageApi.success(t("auth.oauthSuccess", "登录成功"))
|
||||
}
|
||||
|
||||
const onMenuChange = (v: string) => {
|
||||
const key = String(v)
|
||||
setMoreNavVisible(false)
|
||||
@@ -544,7 +574,9 @@ function MainContent(): React.JSX.Element {
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}>
|
||||
<Layout
|
||||
style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}
|
||||
>
|
||||
{!isPortraitMode && (
|
||||
<div
|
||||
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
|
||||
@@ -878,9 +910,26 @@ function MainContent(): React.JSX.Element {
|
||||
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}
|
||||
@@ -1044,6 +1093,7 @@ function App(): React.JSX.Element {
|
||||
<ThemeProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/*" element={<MainContent />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
|
||||
@@ -872,17 +872,20 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
? { label: t("board.metrics.addScore"), value: item.addScore }
|
||||
: item.deductScore !== undefined
|
||||
? { label: t("board.metrics.deductScore"), value: item.deductScore }
|
||||
: item.score !== undefined
|
||||
? { label: t("board.metrics.totalScore"), value: item.score }
|
||||
: item.rewardPoints !== undefined
|
||||
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
|
||||
: item.weekChange !== undefined
|
||||
? { label: t("board.metrics.weekChange"), value: item.weekChange }
|
||||
: item.weekDeducted !== undefined
|
||||
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
|
||||
: item.answeredCount !== undefined
|
||||
? { label: t("board.metrics.todayAnswered"), value: item.answeredCount }
|
||||
: null
|
||||
: item.score !== undefined
|
||||
? { label: t("board.metrics.totalScore"), value: item.score }
|
||||
: item.rewardPoints !== undefined
|
||||
? { label: t("board.metrics.rewardPoints"), value: item.rewardPoints }
|
||||
: item.weekChange !== undefined
|
||||
? { label: t("board.metrics.weekChange"), value: item.weekChange }
|
||||
: item.weekDeducted !== undefined
|
||||
? { label: t("board.metrics.weekDeducted"), value: item.weekDeducted }
|
||||
: item.answeredCount !== undefined
|
||||
? {
|
||||
label: t("board.metrics.todayAnswered"),
|
||||
value: item.answeredCount,
|
||||
}
|
||||
: null
|
||||
: item.score !== undefined
|
||||
? { label: t("board.metrics.totalScore"), value: item.score }
|
||||
: item.rewardPoints !== undefined
|
||||
@@ -1039,10 +1042,13 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
{(list.scoreDisplayMode === "total" ||
|
||||
(list.scoreDisplayMode === "split" && !hasSplitScore)) &&
|
||||
item.score !== undefined && (
|
||||
<Tag color={item.score >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
||||
{t("board.metrics.totalScore")}:{" "}
|
||||
{item.score > 0 ? `+${item.score}` : item.score}
|
||||
</Tag>
|
||||
<Tag
|
||||
color={item.score >= 0 ? "success" : "error"}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{t("board.metrics.totalScore")}:{" "}
|
||||
{item.score > 0 ? `+${item.score}` : item.score}
|
||||
</Tag>
|
||||
)}
|
||||
{list.scoreDisplayMode === "split" && item.addScore !== undefined && (
|
||||
<Tag color="success" style={{ margin: 0 }}>
|
||||
@@ -1428,7 +1434,11 @@ ORDER BY reward_points DESC, score DESC`,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
}}
|
||||
/>
|
||||
<Typography.Link href="https://doubao.com/bot/uEh3mtxq" target="_blank" rel="noreferrer">
|
||||
<Typography.Link
|
||||
href="https://doubao.com/bot/uEh3mtxq"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
豆包智能体一句话生成查询
|
||||
</Typography.Link>
|
||||
<Typography.Link
|
||||
|
||||
@@ -380,10 +380,7 @@ export function ContentArea({
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<Settings permission={permission} />}
|
||||
/>
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
+141
-135
@@ -2356,7 +2356,9 @@ export const Home: React.FC<HomeProps> = ({
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
{keyItem.digit === "⌫"
|
||||
? "⌫"
|
||||
: `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2543,147 +2545,151 @@ export const Home: React.FC<HomeProps> = ({
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
searchAreaRef.current = node
|
||||
immersiveToolbarContentRef.current = node
|
||||
<div
|
||||
ref={(node) => {
|
||||
searchAreaRef.current = node
|
||||
immersiveToolbarContentRef.current = node
|
||||
}}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflowX: "visible",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowPinyinKeyboard(false)
|
||||
}}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
overflowX: "visible",
|
||||
justifyContent: "flex-start",
|
||||
width: isPortraitMode ? "170px" : "220px",
|
||||
borderRadius: "999px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "group", label: t("home.sortBy.group") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={layoutType}
|
||||
onChange={(v) => setLayoutType(v as LayoutType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={handleUndoLastEvent}
|
||||
loading={undoLoading}
|
||||
disabled={!canEdit || !latestEvent || rewardMode}
|
||||
title={
|
||||
latestEvent
|
||||
? t("home.undoLastHint", {
|
||||
name: latestEvent.student_name,
|
||||
delta: latestEvent.delta > 0 ? `+${latestEvent.delta}` : latestEvent.delta,
|
||||
})
|
||||
: t("home.undoUnavailable")
|
||||
}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
<div style={{ flexShrink: 0 }}>{batchToolbar}</div>
|
||||
{canShowSearchKeyboard && showPinyinKeyboard && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "8px" : "12px",
|
||||
width: searchKeyboardLayout === "qwerty26" ? "288px" : "220px",
|
||||
padding: "8px",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (canShowSearchKeyboard) setShowPinyinKeyboard(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowPinyinKeyboard(false)
|
||||
}}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: isPortraitMode ? "170px" : "220px", borderRadius: "999px", flexShrink: 0 }}
|
||||
/>
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "group", label: t("home.sortBy.group") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={layoutType}
|
||||
onChange={(v) => setLayoutType(v as LayoutType)}
|
||||
getPopupContainer={getImmersivePopupContainer}
|
||||
style={{ width: 126, flexShrink: 0 }}
|
||||
options={[
|
||||
{ value: "grouped", label: t("home.layoutBy.grouped") },
|
||||
{ value: "squareGrid", label: t("home.layoutBy.squareGrid") },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={handleUndoLastEvent}
|
||||
loading={undoLoading}
|
||||
disabled={!canEdit || !latestEvent || rewardMode}
|
||||
title={
|
||||
latestEvent
|
||||
? t("home.undoLastHint", {
|
||||
name: latestEvent.student_name,
|
||||
delta: latestEvent.delta > 0 ? `+${latestEvent.delta}` : latestEvent.delta,
|
||||
})
|
||||
: t("home.undoUnavailable")
|
||||
}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{t("home.undoLastAction")}
|
||||
</Button>
|
||||
<Button
|
||||
type={rewardMode ? "default" : "primary"}
|
||||
onClick={handleToggleRewardMode}
|
||||
disabled={!canEdit}
|
||||
style={{ borderRadius: "999px", flexShrink: 0 }}
|
||||
>
|
||||
{rewardMode ? t("rewardExchange.exitMode") : t("rewardExchange.enterMode")}
|
||||
</Button>
|
||||
<div style={{ flexShrink: 0 }}>{batchToolbar}</div>
|
||||
{canShowSearchKeyboard && showPinyinKeyboard && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 8px)",
|
||||
left: isPortraitMode ? "8px" : "12px",
|
||||
width: searchKeyboardLayout === "qwerty26" ? "288px" : "220px",
|
||||
padding: "8px",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.12)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{searchKeyboardLayout === "qwerty26"
|
||||
? qwertyKeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`qwerty-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
|
||||
gap: "7px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem)}
|
||||
style={{ height: "32px", fontSize: "12px", padding: 0 }}
|
||||
>
|
||||
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
: t9KeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`pinyin-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem.digit}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{searchKeyboardLayout === "qwerty26"
|
||||
? qwertyKeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`qwerty-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${row.length}, 1fr)`,
|
||||
gap: "7px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem)}
|
||||
style={{ height: "32px", fontSize: "12px", padding: 0 }}
|
||||
>
|
||||
{keyItem === "⌫" ? "⌫" : keyItem.toUpperCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
: t9KeyRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={`pinyin-immersive-row-${rowIndex}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
{row.map((keyItem) => (
|
||||
<Button
|
||||
key={keyItem.digit}
|
||||
size="small"
|
||||
onClick={() => handleSearchKeyPress(keyItem.digit)}
|
||||
style={{ height: "28px", fontSize: "11px", padding: 0 }}
|
||||
>
|
||||
{keyItem.digit === "⌫" ? "⌫" : `${keyItem.digit} ${keyItem.letters}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPortraitMode ? (
|
||||
|
||||
+115
-4
@@ -12,12 +12,17 @@ import {
|
||||
Modal,
|
||||
Switch,
|
||||
message,
|
||||
Avatar,
|
||||
Typography,
|
||||
} from "antd"
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
import { OAuthLogin } from "./OAuthLogin"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
|
||||
import { useResponsive } from "../hooks/useResponsive"
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
type permissionLevel = "admin" | "points" | "view"
|
||||
type appSettings = {
|
||||
is_wizard_completed: boolean
|
||||
@@ -126,6 +131,15 @@ export const Settings: React.FC<{
|
||||
const [pgSwitchLoading, setPgSwitchLoading] = useState(false)
|
||||
const [pgUploadLoading, setPgUploadLoading] = useState(false)
|
||||
|
||||
const [oauthLoginVisible, setOAuthLoginVisible] = useState(false)
|
||||
const [oauthUserInfo, setOAuthUserInfo] = useState<{
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
} | null>(null)
|
||||
|
||||
const permissionTag = useMemo(() => {
|
||||
return (
|
||||
<Tag
|
||||
@@ -658,7 +672,10 @@ export const Settings: React.FC<{
|
||||
onChange={async (v) => {
|
||||
if (!(window as any).api) return
|
||||
const next = String(v) as "t9" | "qwerty26"
|
||||
const res = await (window as any).api.setSetting("search_keyboard_layout", next)
|
||||
const res = await (window as any).api.setSetting(
|
||||
"search_keyboard_layout",
|
||||
next
|
||||
)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, search_keyboard_layout: next }))
|
||||
messageApi.success(t("settings.general.saved"))
|
||||
@@ -674,7 +691,11 @@ export const Settings: React.FC<{
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.searchKeyboard.hint")}
|
||||
</div>
|
||||
@@ -699,7 +720,11 @@ export const Settings: React.FC<{
|
||||
disabled={!canAdmin}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("settings.searchKeyboard.disableHint")}
|
||||
</div>
|
||||
@@ -740,7 +765,6 @@ export const Settings: React.FC<{
|
||||
{t("settings.zoomHint")}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
@@ -849,6 +873,84 @@ export const Settings: React.FC<{
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "account",
|
||||
label: t("settings.tabs.account"),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: "16px" }}>
|
||||
{t("settings.account.title")}
|
||||
</div>
|
||||
|
||||
{oauthUserInfo ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
<Avatar size={64} style={{ backgroundColor: "#1890ff" }}>
|
||||
{oauthUserInfo.name.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div style={{ fontSize: "18px", fontWeight: 600 }}>{oauthUserInfo.name}</div>
|
||||
<div style={{ fontSize: "14px", color: "var(--ss-text-secondary)" }}>
|
||||
{oauthUserInfo.email}
|
||||
</div>
|
||||
{oauthUserInfo.github_username && (
|
||||
<div style={{ fontSize: "13px", color: "var(--ss-text-secondary)" }}>
|
||||
GitHub: @{oauthUserInfo.github_username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text type="secondary">{t("settings.account.userId")}:</Text>
|
||||
<Text code style={{ marginLeft: "8px" }}>
|
||||
{oauthUserInfo.user_id}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text type="secondary">{t("settings.account.permission")}:</Text>
|
||||
<Tag color="blue" style={{ marginLeft: "8px" }}>
|
||||
{oauthUserInfo.permission === 1
|
||||
? t("permissions.admin")
|
||||
: oauthUserInfo.permission === 2
|
||||
? t("permissions.points")
|
||||
: t("permissions.view")}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button danger onClick={() => setOAuthUserInfo(null)}>
|
||||
{t("settings.account.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "20px 0" }}>
|
||||
<Paragraph style={{ color: "var(--ss-text-secondary)" }}>
|
||||
{t("settings.account.notLoggedIn")}
|
||||
</Paragraph>
|
||||
<Paragraph style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("settings.account.oauthHint")}
|
||||
</Paragraph>
|
||||
<Button type="primary" size="large" onClick={() => setOAuthLoginVisible(true)}>
|
||||
{t("settings.account.loginButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "database",
|
||||
label: t("settings.database.title"),
|
||||
@@ -1426,6 +1528,15 @@ export const Settings: React.FC<{
|
||||
>
|
||||
清空后将关闭保护(无密码时默认视为管理权限)。
|
||||
</Modal>
|
||||
|
||||
<OAuthLogin
|
||||
visible={oauthLoginVisible}
|
||||
onClose={() => setOAuthLoginVisible(false)}
|
||||
onSuccess={(userInfo) => {
|
||||
setOAuthUserInfo(userInfo)
|
||||
messageApi.success(t("settings.account.loginSuccess"))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -260,7 +260,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await (window as any).api.createStudent({ ...values, name, group_name: groupName })
|
||||
const res = await (window as any).api.createStudent({
|
||||
...values,
|
||||
name,
|
||||
group_name: groupName,
|
||||
})
|
||||
if (res.success) {
|
||||
messageApi.success(t("students.addSuccess"))
|
||||
setVisible(false)
|
||||
@@ -1080,7 +1084,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const selectedMedals = (banYouDetail.medals || []).filter((m, idx) =>
|
||||
banYouCheckedMedals.includes(medalKey(m, idx))
|
||||
)
|
||||
const selectedTeams = (banYouDetail.teams || []).filter((g) => banYouCheckedTeams.includes(g.teamId))
|
||||
const selectedTeams = (banYouDetail.teams || []).filter((g) =>
|
||||
banYouCheckedTeams.includes(g.teamId)
|
||||
)
|
||||
|
||||
if (!selectedStudents.length && !selectedMedals.length && !selectedTeams.length) {
|
||||
messageApi.warning(t("students.banyouNothingSelected"))
|
||||
@@ -1325,7 +1331,11 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button size={isMobile ? "small" : "middle"} disabled={!canEdit} icon={<MoreOutlined />}>
|
||||
<Button
|
||||
size={isMobile ? "small" : "middle"}
|
||||
disabled={!canEdit}
|
||||
icon={<MoreOutlined />}
|
||||
>
|
||||
{t("common.operation")}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
@@ -1608,7 +1618,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
borderBottom: "1px dashed var(--ss-border-color)",
|
||||
paddingBottom: 8,
|
||||
cursor:
|
||||
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null ? "default" : "grab",
|
||||
groupKey === UNGROUPED_KEY || pointerDraggingStudentId != null
|
||||
? "default"
|
||||
: "grab",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
touchAction: "none",
|
||||
@@ -1632,7 +1644,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
studentsInGroup.map((student) => (
|
||||
<div
|
||||
key={`${groupKey}-${student.id}`}
|
||||
onPointerDown={(e) => beginPointerDrag(e, student.id, student.name, groupKey)}
|
||||
onPointerDown={(e) =>
|
||||
beginPointerDrag(e, student.id, student.name, groupKey)
|
||||
}
|
||||
onPointerMove={(e) => trackPointerTarget(e.clientX, e.clientY)}
|
||||
onPointerUp={finishPointerDrag}
|
||||
onPointerCancel={finishPointerDrag}
|
||||
@@ -1935,7 +1949,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
cancelText={t("common.cancel")}
|
||||
>
|
||||
{banYouDetailLoading ? (
|
||||
<div style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}>
|
||||
<div
|
||||
style={{ padding: "24px 0", textAlign: "center", color: "var(--ss-text-secondary)" }}
|
||||
>
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : !banYouDetail ? (
|
||||
@@ -1973,7 +1989,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouReasonList")}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
||||
{t("students.banyouReasonList")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
@@ -2006,7 +2024,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>{t("students.banyouStudentList")}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 8 }}>
|
||||
{t("students.banyouStudentList")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"tabs": {
|
||||
"appearance": "Appearance",
|
||||
"security": "Security",
|
||||
"account": "Account",
|
||||
"database": "Database Connection",
|
||||
"dataManagement": "Data Management",
|
||||
"urlProtocol": "URL Protocol",
|
||||
@@ -209,6 +210,16 @@
|
||||
"passwordClearedNewRecovery": "Password cleared, new recovery string",
|
||||
"newRecoveryString": "New recovery string (please save it)"
|
||||
},
|
||||
"account": {
|
||||
"title": "SECTL Auth Account",
|
||||
"notLoggedIn": "Not logged in to SECTL Auth",
|
||||
"oauthHint": "Use SECTL Auth account to login, enjoy unified authentication and remote logout",
|
||||
"loginButton": "Login with SECTL Auth",
|
||||
"loginSuccess": "Login successful",
|
||||
"logout": "Logout",
|
||||
"userId": "User ID",
|
||||
"permission": "Permission Level"
|
||||
},
|
||||
"database": {
|
||||
"title": "Database Connection",
|
||||
"currentStatus": "Current Database Status",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"tabs": {
|
||||
"appearance": "外观",
|
||||
"security": "安全",
|
||||
"account": "账户",
|
||||
"database": "数据库连接",
|
||||
"dataManagement": "数据管理",
|
||||
"urlProtocol": "URL 链接",
|
||||
@@ -209,6 +210,16 @@
|
||||
"passwordClearedNewRecovery": "密码已清空,新的找回字符串",
|
||||
"newRecoveryString": "新的找回字符串(请保存)"
|
||||
},
|
||||
"account": {
|
||||
"title": "SECTL Auth 账户",
|
||||
"notLoggedIn": "尚未登录 SECTL Auth 账户",
|
||||
"oauthHint": "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能",
|
||||
"loginButton": "使用 SECTL Auth 登录",
|
||||
"loginSuccess": "登录成功",
|
||||
"logout": "退出登录",
|
||||
"userId": "用户ID",
|
||||
"permission": "权限等级"
|
||||
},
|
||||
"database": {
|
||||
"title": "数据库连接",
|
||||
"currentStatus": "当前数据库状态",
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ const enableTouchWindowDrag = () => {
|
||||
if (!target) return
|
||||
|
||||
// 检查是否在可拖动区域内
|
||||
const dragRegion = target.closest('[data-tauri-drag-region]')
|
||||
const dragRegion = target.closest("[data-tauri-drag-region]")
|
||||
if (!dragRegion) return
|
||||
|
||||
// 检查是否点击了不可拖动的元素(如按钮)
|
||||
|
||||
+52
-1
@@ -111,7 +111,9 @@ const api = {
|
||||
names: string[]
|
||||
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
|
||||
invoke("student_import_from_xlsx", { params }),
|
||||
fetchBanYouClassrooms: (params: { cookie: string }): Promise<{
|
||||
fetchBanYouClassrooms: (params: {
|
||||
cookie: string
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
classrooms: Array<{
|
||||
@@ -344,6 +346,55 @@ const api = {
|
||||
invoke("auth_reset_by_recovery", { recoveryString }),
|
||||
authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"),
|
||||
|
||||
// OAuth
|
||||
oauthGetAuthorizationUrl: (
|
||||
platformId: string,
|
||||
callbackUrl: string
|
||||
): Promise<{ success: boolean; data: string; message?: string }> =>
|
||||
invoke("oauth_get_authorization_url", { platformId, callbackUrl }),
|
||||
oauthExchangeCode: (
|
||||
code: string,
|
||||
platformId: string,
|
||||
platformSecret: string,
|
||||
callbackUrl: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl }),
|
||||
oauthGetUserInfo: (
|
||||
accessToken: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_get_user_info", { accessToken }),
|
||||
oauthRefreshToken: (
|
||||
refreshToken: string,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
||||
importDataJson: (jsonText: string): Promise<{ success: boolean }> =>
|
||||
|
||||
Reference in New Issue
Block a user