feat(auth): 添加 SECTL Auth OAuth 登录功能

- 新增 OAuth 登录组件和回调页面
- 集成 Tauri 深度链接插件处理 OAuth 回调
- 添加账户设置页面显示登录信息
- 更新 CI 配置添加 OAuth 环境变量
- 扩展 API 接口支持 OAuth 相关操作
- 添加国际化支持
This commit is contained in:
Yukino_fox
2026-03-29 19:30:27 +08:00
parent 3f3bfbf284
commit 7a59572f11
33 changed files with 8477 additions and 200 deletions
+61 -11
View File
@@ -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>
+26 -16
View File
@@ -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
+1 -4
View File
@@ -371,10 +371,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
View File
@@ -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 ? (
+49
View File
@@ -0,0 +1,49 @@
import { useEffect } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
export function OAuthCallback() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
useEffect(() => {
const code = searchParams.get("code")
const error = searchParams.get("error")
const errorDescription = searchParams.get("error_description")
if (error) {
window.postMessage(
{
error: errorDescription || error,
},
window.location.origin
)
navigate("/")
return
}
if (code) {
window.postMessage(
{
code,
},
window.location.origin
)
navigate("/")
}
}, [searchParams, navigate])
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
background: "var(--ss-bg-color)",
color: "var(--ss-text-main)",
}}
>
<div>...</div>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { Button, message, Modal, Space, Spin } from "antd"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { open } from "@tauri-apps/plugin-shell"
import { listen, UnlistenFn } from "@tauri-apps/api/event"
interface OAuthLoginProps {
visible: boolean
onClose: () => void
onSuccess: (userInfo: {
user_id: string
email: string
name: string
github_username?: string
permission: number
}) => void
}
interface OAuthConfig {
platform_id: string
platform_secret: string
callback_url: string
}
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const deepLinkUnlistenRef = useRef<UnlistenFn | null>(null)
const getOAuthConfig = (): OAuthConfig | null => {
const api = (window as any).api
if (!api) return null
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
const callbackUrl = import.meta.env.VITE_OAUTH_CALLBACK_URL || "secscore://oauth/callback"
if (!platformId || !platformSecret) {
return null
}
return {
platform_id: platformId,
platform_secret: platformSecret,
callback_url: callbackUrl,
}
}
const handleDeepLink = async (url: string) => {
const config = getOAuthConfig()
if (!config) return
try {
const urlObj = new URL(url)
const code = urlObj.searchParams.get("code")
const error = urlObj.searchParams.get("error")
if (error) {
message.error(decodeURIComponent(error))
setLoading(false)
return
}
if (code) {
const api = (window as any).api
const tokenRes = await api.oauthExchangeCode(
code,
config.platform_id,
config.platform_secret,
config.callback_url
)
if (!tokenRes.success) {
message.error(tokenRes.message || "获取访问令牌失败")
setLoading(false)
return
}
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
if (!userRes.success) {
message.error(userRes.message || "获取用户信息失败")
setLoading(false)
return
}
onSuccess(userRes.data)
onClose()
}
} catch (error: any) {
message.error(error.message || "登录失败")
} finally {
setLoading(false)
}
}
useEffect(() => {
const setupDeepLink = async () => {
try {
const unlisten = await listen<string>("deep-link://new-url", (event) => {
if (event.payload) {
handleDeepLink(event.payload)
}
})
deepLinkUnlistenRef.current = unlisten
} catch (error) {
console.error("Failed to setup deep link listener:", error)
}
}
setupDeepLink()
return () => {
if (deepLinkUnlistenRef.current) {
deepLinkUnlistenRef.current()
deepLinkUnlistenRef.current = null
}
}
}, [])
const handleOAuthLogin = async () => {
const config = getOAuthConfig()
if (!config) {
message.error("OAuth 配置未设置")
return
}
setLoading(true)
try {
const api = (window as any).api
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, config.callback_url)
if (!urlRes.success) {
message.error(urlRes.message || "获取授权链接失败")
setLoading(false)
return
}
await open(urlRes.data)
} catch (error: any) {
message.error(error.message || "登录失败")
setLoading(false)
}
}
return (
<Modal
title={t("auth.oauthLogin", "SECTL Auth 登录")}
open={visible}
onCancel={onClose}
footer={null}
width={400}
centered
>
<div style={{ textAlign: "center", padding: "20px 0" }}>
{loading ? (
<Space direction="vertical" size="middle">
<Spin size="large" />
<div>{t("auth.oauthLoggingIn", "正在登录...")}</div>
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
</div>
</Space>
) : (
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div style={{ color: "var(--ss-text-secondary)" }}>
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
</div>
<Button
type="primary"
size="large"
onClick={handleOAuthLogin}
style={{ width: "100%" }}
>
{t("auth.oauthButton", "使用 SECTL Auth 登录")}
</Button>
</Space>
)}
</div>
</Modal>
)
}
+122 -5
View File
@@ -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
@@ -76,7 +81,13 @@ export const Settings: React.FC<{
const [recoveryDialogString, setRecoveryDialogString] = useState("")
const [recoveryDialogFilename, setRecoveryDialogFilename] = useState("")
const [aboutContent, setAboutContent] = useState<{ title: string; description: string; content: string; rawMarkdown: string } | null>(null)
const [aboutContent, setAboutContent] = useState<{
title: string
description: string
content: string
rawMarkdown: string
version?: string
} | null>(null)
const [logsDialogVisible, setLogsDialogVisible] = useState(false)
const [logsText, setLogsText] = useState("")
@@ -120,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
@@ -652,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"))
@@ -668,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>
@@ -693,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>
@@ -734,7 +765,6 @@ export const Settings: React.FC<{
{t("settings.zoomHint")}
</div>
</Form.Item>
</Form>
</Card>
),
@@ -843,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"),
@@ -1420,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>
)
}
+28 -8
View File
@@ -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)",
+11
View File
@@ -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",
+11
View File
@@ -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
View File
@@ -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
// 检查是否点击了不可拖动的元素(如按钮)
+53 -2
View File
@@ -87,7 +87,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<{
@@ -280,6 +282,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 }> =>
@@ -442,7 +493,7 @@ const api = {
appRestart: (): Promise<void> => invoke("app_restart"),
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
invoke: async (channel: string, ...args: any[]): Promise<any> => {
invoke: async (channel: string): Promise<any> => {
switch (channel) {
default:
throw new Error(`Unsupported legacy invoke channel: ${channel}`)
+1
View File
@@ -2,6 +2,7 @@ export const MOBILE_NAV_ALL_KEYS = [
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",