mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 20:29:03 +08:00
chore: 完成项目多维度优化与功能迭代
本次提交包含多项改进: 1. 新增Vitest测试配置与相关测试用例 2. 完善Deep Link监听与OAuth回调支持 3. 修复并优化多个React组件的依赖与性能 4. 更新后端API域名与端点地址 5. 优化KV存储服务实现与类型定义 6. 调整用户信息展示与权限相关逻辑 7. 重构部分业务逻辑,使用useCallback优化性能 8. 更新依赖包与Cargo.lock依赖版本
This commit is contained in:
@@ -31,7 +31,10 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const fallbackDraft = useMemo(() => createDefaultActionDraft(), [])
|
||||
const safeValue = value.length > 0 ? value : [fallbackDraft]
|
||||
const safeValue = useMemo(
|
||||
() => (value.length > 0 ? value : [fallbackDraft]),
|
||||
[value, fallbackDraft]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (value.length === 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -156,7 +156,7 @@ const buildOfflineBackfillPlan = (
|
||||
}
|
||||
|
||||
function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm<RuleFormValues>()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const [tags, setTags] = useState<TagItem[]>([])
|
||||
@@ -183,10 +183,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
[rewards]
|
||||
)
|
||||
|
||||
const triggerConfig = useMemo(
|
||||
() => createTriggerQueryConfig(t, tagOptions),
|
||||
[i18n.resolvedLanguage, i18n.language, tagOptions]
|
||||
)
|
||||
const triggerConfig = useMemo(() => createTriggerQueryConfig(t, tagOptions), [t, tagOptions])
|
||||
const [triggerTree, setTriggerTree] = useState<ImmutableTree>(() =>
|
||||
createEmptyTriggerTree(triggerConfig)
|
||||
)
|
||||
@@ -282,7 +279,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category: "all" } }))
|
||||
}
|
||||
|
||||
const fetchStudents = async () => {
|
||||
const fetchStudents = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
|
||||
@@ -294,9 +291,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}, [canEdit])
|
||||
|
||||
const fetchTags = async () => {
|
||||
const fetchTags = useCallback(async () => {
|
||||
if (!canEdit) return
|
||||
|
||||
try {
|
||||
@@ -307,9 +304,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}, [canEdit])
|
||||
|
||||
const fetchRewards = async () => {
|
||||
const fetchRewards = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
|
||||
@@ -321,9 +318,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}, [canEdit])
|
||||
|
||||
const fetchRules = async () => {
|
||||
const fetchRules = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
|
||||
@@ -340,9 +337,9 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [canEdit, messageApi, t])
|
||||
|
||||
const fetchBatches = async () => {
|
||||
const fetchBatches = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api || !canEdit) return
|
||||
try {
|
||||
@@ -353,7 +350,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}, [canEdit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) return
|
||||
@@ -362,7 +359,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
fetchStudents().catch(() => void 0)
|
||||
fetchRules().catch(() => void 0)
|
||||
fetchBatches().catch(() => void 0)
|
||||
}, [canEdit])
|
||||
}, [canEdit, fetchTags, fetchRewards, fetchStudents, fetchRules, fetchBatches])
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).api
|
||||
@@ -426,7 +423,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [backfillPrompted, canEdit, messageApi, rules, t])
|
||||
}, [backfillPrompted, canEdit, messageApi, rules, t, fetchRules, fetchBatches])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const api = (window as any).api
|
||||
|
||||
@@ -25,7 +25,14 @@ import { useTranslation } from "react-i18next"
|
||||
type BoardStudentViewMode = "list" | "card" | "grid" | "largeAvatar"
|
||||
type BoardScoreDisplayMode = "total" | "split"
|
||||
type SplitDirection = "horizontal" | "vertical"
|
||||
type BoardTimeRange = "last7d" | "last30d" | "thisWeek" | "lastWeek" | "thisMonth" | "lastMonth" | "custom"
|
||||
type BoardTimeRange =
|
||||
| "last7d"
|
||||
| "last30d"
|
||||
| "thisWeek"
|
||||
| "lastWeek"
|
||||
| "thisMonth"
|
||||
| "lastMonth"
|
||||
| "custom"
|
||||
type BoardReasonMode = "all" | "selected" | "keyword"
|
||||
type BoardScoreDirection = "all" | "add" | "deduct"
|
||||
type BoardMetric = "addScore" | "deductScore" | "netChange" | "addCount" | "eventCount"
|
||||
@@ -177,9 +184,7 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => {
|
||||
const fallback = createDefaultRule()
|
||||
const raw = typeof input === "object" && input ? (input as Record<string, unknown>) : {}
|
||||
const reasonValues = Array.isArray(raw.reasonValues)
|
||||
? raw.reasonValues
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean)
|
||||
? raw.reasonValues.map((item) => (typeof item === "string" ? item.trim() : "")).filter(Boolean)
|
||||
: []
|
||||
|
||||
const allowedTimeRange = new Set<BoardTimeRange>([
|
||||
@@ -216,7 +221,9 @@ const normalizeRule = (input: unknown): BoardQueryRuleConfig => {
|
||||
scoreDirection: allowedScoreDirection.has(raw.scoreDirection as BoardScoreDirection)
|
||||
? (raw.scoreDirection as BoardScoreDirection)
|
||||
: fallback.scoreDirection,
|
||||
metric: allowedMetric.has(raw.metric as BoardMetric) ? (raw.metric as BoardMetric) : fallback.metric,
|
||||
metric: allowedMetric.has(raw.metric as BoardMetric)
|
||||
? (raw.metric as BoardMetric)
|
||||
: fallback.metric,
|
||||
sortDirection: allowedSortDirection.has(raw.sortDirection as BoardSortDirection)
|
||||
? (raw.sortDirection as BoardSortDirection)
|
||||
: fallback.sortDirection,
|
||||
@@ -324,7 +331,11 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => {
|
||||
|
||||
const currentStartMs = new Date(currentStart).getTime()
|
||||
const currentEndMs = new Date(currentEnd).getTime()
|
||||
if (!Number.isFinite(currentStartMs) || !Number.isFinite(currentEndMs) || currentEndMs <= currentStartMs) {
|
||||
if (
|
||||
!Number.isFinite(currentStartMs) ||
|
||||
!Number.isFinite(currentEndMs) ||
|
||||
currentEndMs <= currentStartMs
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -358,8 +369,7 @@ const buildSqlFromRule = (ruleInput: BoardQueryRuleConfig): string | null => {
|
||||
)
|
||||
}
|
||||
|
||||
const baseWhereSql =
|
||||
baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : ""
|
||||
const baseWhereSql = baseWhereClauses.length > 0 ? `AND ${baseWhereClauses.join(" AND ")}` : ""
|
||||
const metricExpression = getMetricExpression(rule.metric)
|
||||
const topN = clampTopN(rule.topN)
|
||||
const filterClauses: string[] = []
|
||||
@@ -560,24 +570,23 @@ const normalizeBoards = (input: unknown): BoardConfig[] => {
|
||||
const boards = input
|
||||
.map((board: any) => {
|
||||
const lists = Array.isArray(board?.lists)
|
||||
? board.lists
|
||||
.map((list: any) => ({
|
||||
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
|
||||
name:
|
||||
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
||||
rule: normalizeRule(list?.rule),
|
||||
viewMode:
|
||||
list?.viewMode === "list" ||
|
||||
list?.viewMode === "card" ||
|
||||
list?.viewMode === "grid" ||
|
||||
list?.viewMode === "largeAvatar"
|
||||
? list.viewMode
|
||||
: "card",
|
||||
scoreDisplayMode:
|
||||
list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split"
|
||||
? list.scoreDisplayMode
|
||||
: "total",
|
||||
}))
|
||||
? board.lists.map((list: any) => ({
|
||||
id: typeof list?.id === "string" && list.id.trim() ? list.id : makeId(),
|
||||
name:
|
||||
typeof list?.name === "string" && list.name.trim() ? list.name.trim() : "学生列表",
|
||||
rule: normalizeRule(list?.rule),
|
||||
viewMode:
|
||||
list?.viewMode === "list" ||
|
||||
list?.viewMode === "card" ||
|
||||
list?.viewMode === "grid" ||
|
||||
list?.viewMode === "largeAvatar"
|
||||
? list.viewMode
|
||||
: "card",
|
||||
scoreDisplayMode:
|
||||
list?.scoreDisplayMode === "total" || list?.scoreDisplayMode === "split"
|
||||
? list.scoreDisplayMode
|
||||
: "total",
|
||||
}))
|
||||
: []
|
||||
|
||||
const normalizedLists = lists.length > 0 ? lists : [createDefaultList()]
|
||||
@@ -965,7 +974,7 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
||||
}
|
||||
const firstLeafId = getFirstLeafId(activeBoard.layout)
|
||||
setSelectedLeafNodeId((prev) => prev || firstLeafId)
|
||||
}, [activeBoard?.id, activeBoard?.layout])
|
||||
}, [activeBoard?.id, activeBoard?.layout, activeBoard])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBoard) return
|
||||
@@ -973,7 +982,7 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
||||
runAllInBoard(activeBoard).catch(() => void 0)
|
||||
}, 260)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeBoard?.id, listConfigSignature, runAllInBoard])
|
||||
}, [activeBoard?.id, listConfigSignature, runAllInBoard, activeBoard])
|
||||
|
||||
useEffect(() => {
|
||||
const onDataUpdated = (e: Event) => {
|
||||
@@ -1235,27 +1244,27 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
||||
? item.metricValue !== undefined
|
||||
? { label: metricTitle, value: item.metricValue }
|
||||
: item.addScore !== undefined && item.addScore !== 0
|
||||
? { label: t("board.metrics.addScore"), value: item.addScore }
|
||||
: item.deductScore !== undefined && item.deductScore !== 0
|
||||
? { label: t("board.metrics.deductScore"), value: item.deductScore }
|
||||
: item.addScore !== undefined
|
||||
? { 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
|
||||
? { label: t("board.metrics.addScore"), value: item.addScore }
|
||||
: item.deductScore !== undefined && item.deductScore !== 0
|
||||
? { label: t("board.metrics.deductScore"), value: item.deductScore }
|
||||
: item.addScore !== undefined
|
||||
? { 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
|
||||
? item.metricValue !== undefined
|
||||
? { label: metricTitle, value: item.metricValue }
|
||||
@@ -1594,8 +1603,12 @@ export const BoardManager: React.FC<BoardManagerProps> = ({ canManage }) => {
|
||||
</Tag>
|
||||
)}
|
||||
{item.metricDelta !== undefined && (
|
||||
<Tag color={item.metricDelta >= 0 ? "success" : "error"} style={{ margin: 0 }}>
|
||||
较上期: {item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta}
|
||||
<Tag
|
||||
color={item.metricDelta >= 0 ? "success" : "error"}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
较上期:{" "}
|
||||
{item.metricDelta > 0 ? `+${item.metricDelta}` : item.metricDelta}
|
||||
</Tag>
|
||||
)}
|
||||
{rankChangeText && (
|
||||
|
||||
+14
-11
@@ -553,7 +553,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
return a.localeCompare(b, "zh-CN")
|
||||
})
|
||||
.map(([key, students]) => ({ key, students }))
|
||||
}, [sortedStudents, sortType, searchKeyword, layoutType, getGroupName, t])
|
||||
}, [sortedStudents, sortType, searchKeyword, getGroupName, t])
|
||||
|
||||
const firstStudentIdByGroup = useMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
@@ -577,7 +577,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
if (b === t("home.category.others")) return -1
|
||||
return a.localeCompare(b, "zh-CN")
|
||||
})
|
||||
}, [reasons])
|
||||
}, [reasons, t])
|
||||
|
||||
const selectedStudents = useMemo(() => {
|
||||
if (selectedStudentIds.length === 0) return []
|
||||
@@ -2374,18 +2374,21 @@ export const Home: React.FC<HomeProps> = ({
|
||||
document.addEventListener("mouseup", onGlobalMouseUp)
|
||||
}
|
||||
|
||||
const onGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (isNavDragging.current) {
|
||||
if (e.cancelable) e.preventDefault()
|
||||
handleNavAction(e.clientY)
|
||||
}
|
||||
}
|
||||
const onGlobalMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isNavDragging.current) {
|
||||
if (e.cancelable) e.preventDefault()
|
||||
handleNavAction(e.clientY)
|
||||
}
|
||||
},
|
||||
[handleNavAction]
|
||||
)
|
||||
|
||||
const onGlobalMouseUp = () => {
|
||||
const onGlobalMouseUp = useCallback(() => {
|
||||
setNavDraggingState(false)
|
||||
document.removeEventListener("mousemove", onGlobalMouseMove)
|
||||
document.removeEventListener("mouseup", onGlobalMouseUp)
|
||||
}
|
||||
}, [onGlobalMouseMove, setNavDraggingState])
|
||||
|
||||
const onNavTouchStart = (e: React.TouchEvent) => {
|
||||
navHapticIndexRef.current = null
|
||||
@@ -2421,7 +2424,7 @@ export const Home: React.FC<HomeProps> = ({
|
||||
document.body.style.userSelect = bodyUserSelectRef.current
|
||||
document.body.style.webkitUserSelect = bodyWebkitUserSelectRef.current
|
||||
}
|
||||
}, [])
|
||||
}, [onGlobalMouseMove, onGlobalMouseUp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupedStudents.length) {
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { sectlAuth } from "../../services/sectlAuth"
|
||||
|
||||
export function OAuthCallback() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<"processing" | "success" | "error">("processing")
|
||||
const [errorMessage, setErrorMessage] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const error = searchParams.get("error")
|
||||
const errorDescription = searchParams.get("error_description")
|
||||
const handleCallback = async () => {
|
||||
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 (error) {
|
||||
setStatus("error")
|
||||
setErrorMessage(errorDescription || error)
|
||||
setTimeout(() => navigate("/"), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
try {
|
||||
await sectlAuth.exchangeCodeForToken(code)
|
||||
setStatus("success")
|
||||
setTimeout(() => navigate("/"), 1500)
|
||||
} catch (err: any) {
|
||||
setStatus("error")
|
||||
setErrorMessage(err.message || "Token 交换失败")
|
||||
setTimeout(() => navigate("/"), 3000)
|
||||
}
|
||||
} else {
|
||||
setStatus("error")
|
||||
setErrorMessage("未收到授权码")
|
||||
setTimeout(() => navigate("/"), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
window.postMessage(
|
||||
{
|
||||
code,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
navigate("/")
|
||||
}
|
||||
handleCallback()
|
||||
}, [searchParams, navigate])
|
||||
|
||||
return (
|
||||
@@ -43,7 +52,18 @@ export function OAuthCallback() {
|
||||
color: "var(--ss-text-main)",
|
||||
}}
|
||||
>
|
||||
<div>正在处理登录...</div>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{status === "processing" && <div>正在处理登录...</div>}
|
||||
{status === "success" && <div>登录成功,正在跳转...</div>}
|
||||
{status === "error" && (
|
||||
<div>
|
||||
<div style={{ color: "#ff4d4f" }}>登录失败</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "8px", color: "var(--ss-text-secondary)" }}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,243 +1,71 @@
|
||||
import { Button, message, Modal, Space, Spin } from "antd"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { open } from "@tauri-apps/plugin-shell"
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth"
|
||||
|
||||
interface OAuthLoginProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSuccess: (userInfo: {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
user_id?: string
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
avatar_url?: string
|
||||
}) => void
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
platform_id: string
|
||||
}
|
||||
|
||||
interface OAuthCallbackResult {
|
||||
code?: string
|
||||
state?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
const OAUTH_EXPECTED_STATE_KEY = "oauth_expected_state"
|
||||
const OAUTH_CODE_VERIFIER_KEY = "oauth_code_verifier"
|
||||
const OAUTH_CALLBACK_URL_KEY = "oauth_callback_url"
|
||||
const DEFAULT_CALLBACK_URL = "http://127.0.0.1:16888/oauth/callback"
|
||||
|
||||
const getExpectedState = () => sessionStorage.getItem(OAUTH_EXPECTED_STATE_KEY)
|
||||
const setExpectedState = (state: string) => sessionStorage.setItem(OAUTH_EXPECTED_STATE_KEY, state)
|
||||
const clearExpectedState = () => sessionStorage.removeItem(OAUTH_EXPECTED_STATE_KEY)
|
||||
|
||||
const getCodeVerifier = () => sessionStorage.getItem(OAUTH_CODE_VERIFIER_KEY)
|
||||
const setCodeVerifier = (codeVerifier: string) =>
|
||||
sessionStorage.setItem(OAUTH_CODE_VERIFIER_KEY, codeVerifier)
|
||||
const clearCodeVerifier = () => sessionStorage.removeItem(OAUTH_CODE_VERIFIER_KEY)
|
||||
|
||||
const getCallbackUrl = () => sessionStorage.getItem(OAUTH_CALLBACK_URL_KEY) || DEFAULT_CALLBACK_URL
|
||||
const setCallbackUrl = (callbackUrl: string) =>
|
||||
sessionStorage.setItem(OAUTH_CALLBACK_URL_KEY, callbackUrl)
|
||||
const clearCallbackUrl = () => sessionStorage.removeItem(OAUTH_CALLBACK_URL_KEY)
|
||||
|
||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const isProcessingCallbackRef = useRef(false)
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const api = (window as any).api
|
||||
if (!api) return null
|
||||
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
|
||||
if (!platformId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
platform_id: platformId,
|
||||
}
|
||||
}
|
||||
|
||||
const stopCallbackServer = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api?.oauthStopCallbackServer) return
|
||||
try {
|
||||
await api.oauthStopCallbackServer()
|
||||
} catch (error) {
|
||||
console.error("[OAuth] 停止回调服务器失败:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearOAuthTempState = useCallback(() => {
|
||||
clearExpectedState()
|
||||
clearCodeVerifier()
|
||||
clearCallbackUrl()
|
||||
}, [])
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (result: OAuthCallbackResult) => {
|
||||
if (isProcessingCallbackRef.current) {
|
||||
console.log("[OAuth] 回调正在处理中,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[OAuth] 收到回调:", result)
|
||||
const config = getOAuthConfig()
|
||||
if (!config) {
|
||||
console.error("[OAuth] 配置不存在")
|
||||
message.error("OAuth 配置未设置")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const api = (window as any).api
|
||||
if (!api) {
|
||||
message.error("API 不可用")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessingCallbackRef.current = true
|
||||
|
||||
if (result.error) {
|
||||
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||
message.error(result.error_description || result.error || "授权失败")
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.code) return
|
||||
|
||||
console.log("[OAuth] 授权码:", result.code)
|
||||
console.log("[OAuth] State:", result.state, "期望:", getExpectedState())
|
||||
|
||||
const expectedState = getExpectedState()
|
||||
if (expectedState && result.state !== expectedState) {
|
||||
message.error("安全验证失败:state 不匹配")
|
||||
return
|
||||
}
|
||||
|
||||
const codeVerifier = getCodeVerifier()
|
||||
if (!codeVerifier) {
|
||||
message.error("安全验证失败:缺少 code_verifier")
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = getCallbackUrl()
|
||||
console.log("[OAuth] 开始换取 token...")
|
||||
const tokenRes = await api.oauthExchangeCode(
|
||||
result.code,
|
||||
config.platform_id,
|
||||
callbackUrl,
|
||||
codeVerifier
|
||||
)
|
||||
console.log("[OAuth] token 响应:", tokenRes)
|
||||
|
||||
if (!tokenRes.success) {
|
||||
message.error(tokenRes.message || "获取访问令牌失败")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[OAuth] 开始获取用户信息...")
|
||||
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
|
||||
console.log("[OAuth] 用户信息响应:", userRes)
|
||||
|
||||
if (!userRes.success) {
|
||||
message.error(userRes.message || "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[OAuth] 登录成功,保存登录状态...")
|
||||
|
||||
const loginState = {
|
||||
access_token: tokenRes.data.access_token,
|
||||
refresh_token: tokenRes.data.refresh_token,
|
||||
token_type: tokenRes.data.token_type,
|
||||
expires_in: tokenRes.data.expires_in,
|
||||
user_id: userRes.data.user_id,
|
||||
email: userRes.data.email,
|
||||
name: userRes.data.name,
|
||||
github_username: userRes.data.github_username,
|
||||
login_time: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
await api.oauthSaveLoginState(loginState)
|
||||
console.log("[OAuth] 登录状态已保存")
|
||||
} catch (saveError) {
|
||||
console.error("[OAuth] 保存登录状态失败:", saveError)
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:oauth-user-updated", {
|
||||
detail: { user: userRes.data },
|
||||
})
|
||||
)
|
||||
console.log("[OAuth] 调用 onSuccess...")
|
||||
onSuccess(userRes.data)
|
||||
console.log("[OAuth] 调用 onClose...")
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
console.error("[OAuth] 处理回调时发生错误:", error)
|
||||
message.error(error.message || "登录失败")
|
||||
} finally {
|
||||
await stopCallbackServer()
|
||||
clearOAuthTempState()
|
||||
setLoading(false)
|
||||
window.setTimeout(() => {
|
||||
isProcessingCallbackRef.current = false
|
||||
console.log("[OAuth] 局部锁已释放")
|
||||
}, 300)
|
||||
}
|
||||
},
|
||||
[clearOAuthTempState, onClose, onSuccess, stopCallbackServer]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let disposed = false
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
if (platformId) {
|
||||
// 使用 deep link 回调地址
|
||||
sectlAuth.initialize(platformId, "secscore://oauth")
|
||||
SECTL_CONFIG.platformId = platformId
|
||||
SECTL_CONFIG.callbackUrl = "secscore://oauth"
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 监听 Deep Link 回调
|
||||
useEffect(() => {
|
||||
if (!visible || !loading) return
|
||||
|
||||
const handleDeepLink = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ code: string; state: string }>
|
||||
const { code, state } = customEvent.detail
|
||||
|
||||
console.log("[OAuthLogin] Received deep link callback:", { code, state })
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
||||
if (event.payload) {
|
||||
await handleOAuthCallback(event.payload)
|
||||
}
|
||||
})
|
||||
|
||||
if (disposed) {
|
||||
unlisten?.()
|
||||
unlisten = null
|
||||
// 使用 code 交换 token
|
||||
const result = await sectlAuth.exchangeCode(code, state)
|
||||
if (result) {
|
||||
const userInfo = await sectlAuth.getUserInfo()
|
||||
onSuccess(userInfo)
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to setup OAuth callback listener:", error)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || "登录失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void setupListener()
|
||||
|
||||
window.addEventListener("ss:oauth-deep-link", handleDeepLink)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
window.removeEventListener("ss:oauth-deep-link", handleDeepLink)
|
||||
}
|
||||
}, [handleOAuthCallback, visible])
|
||||
}, [visible, loading, onClose, onSuccess])
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const config = getOAuthConfig()
|
||||
if (!config) {
|
||||
if (!SECTL_CONFIG.platformId) {
|
||||
message.error("OAuth 配置未设置")
|
||||
return
|
||||
}
|
||||
@@ -245,35 +73,17 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const api = (window as any).api
|
||||
const authUrl = await sectlAuth.getAuthorizationUrl()
|
||||
// 使用 Tauri shell 打开系统浏览器
|
||||
await open(authUrl)
|
||||
|
||||
const serverRes = await api.oauthStartCallbackServer()
|
||||
if (!serverRes.success) {
|
||||
message.error(serverRes.message || "启动回调服务器失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = serverRes.data.url
|
||||
setCallbackUrl(callbackUrl)
|
||||
|
||||
const state = generateRandomState()
|
||||
console.log("[OAuth] 生成 state:", state)
|
||||
setExpectedState(state)
|
||||
console.log("[OAuth] state 已设置:", getExpectedState())
|
||||
|
||||
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, callbackUrl, state)
|
||||
|
||||
if (!urlRes.success) {
|
||||
message.error(urlRes.message || "获取授权链接失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setCodeVerifier(urlRes.data.code_verifier)
|
||||
console.log("[OAuth] code_verifier 已存储")
|
||||
|
||||
await open(urlRes.data.url)
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (loading) {
|
||||
setLoading(false)
|
||||
message.warning("登录超时,请重试")
|
||||
}
|
||||
}, 300000)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || "登录失败")
|
||||
setLoading(false)
|
||||
@@ -282,20 +92,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
|
||||
const handleModalClose = () => {
|
||||
setLoading(false)
|
||||
clearOAuthTempState()
|
||||
void stopCallbackServer()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const generateRandomState = (): string => {
|
||||
const array = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(array)
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "")
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
||||
|
||||
@@ -137,16 +137,19 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF"
|
||||
const isDark = workingTheme?.mode === "dark"
|
||||
|
||||
const showOobeMessage = (type: "success" | "error" | "warning" | "info", content: string) => {
|
||||
messageApi.open({
|
||||
type,
|
||||
content,
|
||||
style: {
|
||||
marginTop: 8,
|
||||
zIndex: 12000,
|
||||
},
|
||||
})
|
||||
}
|
||||
const showOobeMessage = useCallback(
|
||||
(type: "success" | "error" | "warning" | "info", content: string) => {
|
||||
messageApi.open({
|
||||
type,
|
||||
content,
|
||||
style: {
|
||||
marginTop: 8,
|
||||
zIndex: 12000,
|
||||
},
|
||||
})
|
||||
},
|
||||
[messageApi]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTheme) return
|
||||
@@ -157,7 +160,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
: { ...base, id: "custom-default", name: t("theme.myTheme") }
|
||||
setWorkingTheme(editable)
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
|
||||
}, [currentTheme])
|
||||
}, [currentTheme, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workingTheme) return
|
||||
@@ -331,7 +334,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
showOobeMessage("error", t("oobe.steps.students.unsupportedFormat"))
|
||||
}
|
||||
},
|
||||
[students, messageApi]
|
||||
[students, showOobeMessage, t]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 提供云端数据同步功能
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import { Card, Button, Space, Alert, Descriptions, message, Modal, Typography, Divider } from "antd"
|
||||
import {
|
||||
CloudSyncOutlined,
|
||||
@@ -43,7 +43,7 @@ export const ScoreSyncPanel: React.FC<SyncPanelProps> = ({ onGetLocalData, onRes
|
||||
const [hasData, setHasData] = useState(false)
|
||||
|
||||
// 检查云端数据状态
|
||||
const checkCloudStatus = async () => {
|
||||
const checkCloudStatus = useCallback(async () => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
try {
|
||||
@@ -60,13 +60,13 @@ export const ScoreSyncPanel: React.FC<SyncPanelProps> = ({ onGetLocalData, onRes
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
checkCloudStatus()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
}, [isAuthenticated, checkCloudStatus])
|
||||
|
||||
// 上传数据到云端
|
||||
const handleSyncToCloud = async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* SECTL 云存储管理组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -45,7 +45,7 @@ export const SectlCloudStorageManager: React.FC = () => {
|
||||
const [newFilename, setNewFilename] = useState("")
|
||||
|
||||
// 加载文件列表
|
||||
const loadFiles = async () => {
|
||||
const loadFiles = useCallback(async () => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
try {
|
||||
@@ -57,10 +57,10 @@ export const SectlCloudStorageManager: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// 加载存储使用情况
|
||||
const loadStorageUsage = async () => {
|
||||
const loadStorageUsage = useCallback(async () => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
try {
|
||||
@@ -69,14 +69,14 @@ export const SectlCloudStorageManager: React.FC = () => {
|
||||
} catch (error: any) {
|
||||
console.error("加载存储使用情况失败:", error)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadFiles()
|
||||
loadStorageUsage()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
}, [isAuthenticated, loadFiles, loadStorageUsage])
|
||||
|
||||
// 处理文件上传
|
||||
const handleUpload = async (file: UploadFile): Promise<void> => {
|
||||
@@ -95,11 +95,17 @@ export const SectlCloudStorageManager: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件下载
|
||||
const handleDownload = async (file: CloudFile) => {
|
||||
try {
|
||||
const { download_url } = await sectlCloudStorage.downloadFile(file.file_id)
|
||||
window.open(download_url, "_blank")
|
||||
const blob = await sectlCloudStorage.downloadFile(file.file_id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
message.success("开始下载")
|
||||
} catch (error: any) {
|
||||
message.error(`下载失败:${error.message}`)
|
||||
@@ -137,7 +143,7 @@ export const SectlCloudStorageManager: React.FC = () => {
|
||||
const handleCreateShare = async (file: CloudFile) => {
|
||||
try {
|
||||
const share = await sectlCloudStorage.createShare(file.file_id, {
|
||||
expires_in: 86400, // 1 天
|
||||
expiresIn: 86400,
|
||||
})
|
||||
|
||||
// 复制到剪贴板
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 基于 SECTL-One-Stop SDK 规范实现
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react"
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -61,42 +61,45 @@ export const SectlKVStorageManager: React.FC = () => {
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 加载 KV 列表
|
||||
const loadKVList = async (resetOffset = false) => {
|
||||
if (!isAuthenticated) return
|
||||
const loadKVList = useCallback(
|
||||
async (resetOffset = false) => {
|
||||
if (!isAuthenticated) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const currentOffset = resetOffset ? 0 : offset
|
||||
const options: ListKVOptions = {
|
||||
limit: 20,
|
||||
offset: currentOffset,
|
||||
}
|
||||
if (prefix) {
|
||||
options.prefix = prefix
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const currentOffset = resetOffset ? 0 : offset
|
||||
const options: ListKVOptions = {
|
||||
limit: 20,
|
||||
offset: currentOffset,
|
||||
}
|
||||
if (prefix) {
|
||||
options.prefix = prefix
|
||||
}
|
||||
|
||||
const result = await sectlKVStorage.listKV(options)
|
||||
setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list])
|
||||
setTotal(result.total)
|
||||
setHasMore(result.has_more)
|
||||
if (resetOffset) {
|
||||
setOffset(20)
|
||||
} else {
|
||||
setOffset(currentOffset + 20)
|
||||
const result = await sectlKVStorage.listKV(options)
|
||||
setKvList(resetOffset ? result.kv_list : [...kvList, ...result.kv_list])
|
||||
setTotal(result.total)
|
||||
setHasMore(result.has_more)
|
||||
if (resetOffset) {
|
||||
setOffset(20)
|
||||
} else {
|
||||
setOffset(currentOffset + 20)
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(`加载键值对列表失败:${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(`加载键值对列表失败:${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[isAuthenticated, offset, prefix, kvList]
|
||||
)
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadKVList(true)
|
||||
}
|
||||
}, [isAuthenticated, prefix])
|
||||
}, [isAuthenticated, prefix, loadKVList])
|
||||
|
||||
// 处理创建/更新
|
||||
const handleSave = async (values: {
|
||||
|
||||
@@ -82,13 +82,21 @@ export const SectlSettingsPanel: React.FC = () => {
|
||||
|
||||
{isAuthenticated && userInfo && (
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="用户 ID">{userInfo.user_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户 ID">
|
||||
{userInfo.user_id || userInfo.id}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户名">{userInfo.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="权限">{userInfo.permission}</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{userInfo.role}</Descriptions.Item>
|
||||
{userInfo.github_username && (
|
||||
<Descriptions.Item label="GitHub">{userInfo.github_username}</Descriptions.Item>
|
||||
{userInfo.status && (
|
||||
<Descriptions.Item label="状态">{userInfo.status}</Descriptions.Item>
|
||||
)}
|
||||
{userInfo.created_at && (
|
||||
<Descriptions.Item label="注册时间">{userInfo.created_at}</Descriptions.Item>
|
||||
)}
|
||||
{userInfo.email_verified !== undefined && (
|
||||
<Descriptions.Item label="邮箱验证">
|
||||
{userInfo.email_verified ? "已验证" : "未验证"}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
+65
-63
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
Tabs,
|
||||
Card,
|
||||
@@ -221,10 +221,12 @@ export const Settings: React.FC<{
|
||||
|
||||
const [oauthLoginVisible, setOAuthLoginVisible] = useState(false)
|
||||
const [oauthUserInfo, setOAuthUserInfo] = useState<{
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
user_id?: string
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
avatar_url?: string
|
||||
} | null>(null)
|
||||
|
||||
const permissionTag = useMemo(() => {
|
||||
@@ -264,58 +266,61 @@ export const Settings: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const loadSystemFonts = async (selectedFontValue?: string) => {
|
||||
setIsLoadingFonts(true)
|
||||
const loadSystemFonts = useCallback(
|
||||
async (selectedFontValue?: string) => {
|
||||
setIsLoadingFonts(true)
|
||||
|
||||
const applySelectedFont = (options: FontOption[]) => {
|
||||
const current = findFontOption(options, selectedFontValue || settings.font_family)
|
||||
if (current) applyFontFamily(current.fontFamily)
|
||||
}
|
||||
const applySelectedFont = (options: FontOption[]) => {
|
||||
const current = findFontOption(options, selectedFontValue || settings.font_family)
|
||||
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 {
|
||||
const api = (window as any).api
|
||||
if (!api?.getSystemFonts) {
|
||||
setFontOptions(defaultFontOptions)
|
||||
applySelectedFont(defaultFontOptions)
|
||||
setIsLoadingFonts(false)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load system fonts:", error)
|
||||
setFontOptions(defaultFontOptions)
|
||||
applySelectedFont(defaultFontOptions)
|
||||
} finally {
|
||||
setIsLoadingFonts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
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)
|
||||
}
|
||||
},
|
||||
[settings.font_family]
|
||||
)
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const api = (window as any).api
|
||||
if (!api) {
|
||||
setFontOptions(defaultFontOptions)
|
||||
@@ -345,7 +350,6 @@ export const Settings: React.FC<{
|
||||
user_id: oauthStateRes.data.user_id,
|
||||
email: oauthStateRes.data.email,
|
||||
name: oauthStateRes.data.name,
|
||||
github_username: oauthStateRes.data.github_username,
|
||||
})
|
||||
} else {
|
||||
setOAuthUserInfo(null)
|
||||
@@ -375,7 +379,7 @@ export const Settings: React.FC<{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [settings, loadSystemFonts])
|
||||
|
||||
const handleOAuthLogout = async () => {
|
||||
const api = (window as any).api
|
||||
@@ -564,7 +568,7 @@ export const Settings: React.FC<{
|
||||
disposed = true
|
||||
if (unlisten) unlisten()
|
||||
}
|
||||
}, [])
|
||||
}, [loadAll])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthUserUpdated = (event: Event) => {
|
||||
@@ -582,7 +586,6 @@ export const Settings: React.FC<{
|
||||
user_id: user.user_id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
github_username: user.github_username,
|
||||
})
|
||||
} else {
|
||||
setOAuthUserInfo(null)
|
||||
@@ -1289,19 +1292,18 @@ export const Settings: React.FC<{
|
||||
{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
|
||||
size={64}
|
||||
src={oauthUserInfo.avatar || oauthUserInfo.avatar_url}
|
||||
style={{ backgroundColor: "#1890ff" }}
|
||||
>
|
||||
{(oauthUserInfo.name || "U").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>
|
||||
|
||||
@@ -1310,7 +1312,7 @@ export const Settings: React.FC<{
|
||||
<div>
|
||||
<Text type="secondary">{t("settings.account.userId")}:</Text>
|
||||
<Text code style={{ marginLeft: "8px" }}>
|
||||
{oauthUserInfo.user_id}
|
||||
{oauthUserInfo.user_id || oauthUserInfo.id}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [messageApi])
|
||||
}, [messageApi, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettlements()
|
||||
@@ -103,7 +103,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
render: (score: number) => <span style={{ fontWeight: "bold" }}>{score}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
[t]
|
||||
)
|
||||
|
||||
if (selectedId !== null && selectedSettlement) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { UploadOutlined, MoreOutlined, PlusOutlined, CopyOutlined } from "@ant-d
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TagEditorDialog } from "./TagEditorDialog"
|
||||
import { getAvatarFromExtraJson, setAvatarInExtraJson } from "../utils/studentAvatar"
|
||||
import { useResponsive, useIsMobile, useIsTablet } from "../hooks/useResponsive"
|
||||
import { useIsMobile } from "../hooks/useResponsive"
|
||||
|
||||
const createXlsxWorker = () => {
|
||||
return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), {
|
||||
@@ -157,8 +157,6 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const addFormGroupName = Form.useWatch("group_name", form)
|
||||
const editFormGroupName = Form.useWatch("group_name", groupForm)
|
||||
const isMobile = useIsMobile()
|
||||
const isTablet = useIsTablet()
|
||||
const breakpoint = useResponsive()
|
||||
|
||||
useEffect(() => {
|
||||
xlsxWorkerRef.current = createXlsxWorker()
|
||||
@@ -291,7 +289,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
@@ -305,18 +303,18 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
} else {
|
||||
messageApi.error(res.message || t("students.deleteFailed"))
|
||||
}
|
||||
}
|
||||
}, [canEdit, messageApi, t, fetchStudents])
|
||||
|
||||
const handleOpenTagEditor = (student: student) => {
|
||||
const handleOpenTagEditor = useCallback((student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setEditingStudent(student)
|
||||
setTagEditVisible(true)
|
||||
}
|
||||
}, [canEdit, messageApi, t])
|
||||
|
||||
const handleOpenAvatarEditor = (student: student) => {
|
||||
const handleOpenAvatarEditor = useCallback((student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
@@ -324,9 +322,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setAvatarStudent(student)
|
||||
setAvatarValue(student.avatarUrl || null)
|
||||
setAvatarVisible(true)
|
||||
}
|
||||
}, [canEdit, messageApi, t])
|
||||
|
||||
const handleOpenGroupEditor = (student: student) => {
|
||||
const handleOpenGroupEditor = useCallback((student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
@@ -334,7 +332,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setGroupEditStudent(student)
|
||||
groupForm.setFieldsValue({ group_name: student.group_name || "" })
|
||||
setGroupEditVisible(true)
|
||||
}
|
||||
}, [canEdit, messageApi, t, groupForm])
|
||||
|
||||
const handleSaveGroup = async () => {
|
||||
if (!(window as any).api || !groupEditStudent) return
|
||||
@@ -1370,7 +1368,15 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
return baseColumns
|
||||
}, [t, canEdit, isMobile, isTablet, breakpoint])
|
||||
}, [
|
||||
t,
|
||||
canEdit,
|
||||
isMobile,
|
||||
handleDelete,
|
||||
handleOpenAvatarEditor,
|
||||
handleOpenGroupEditor,
|
||||
handleOpenTagEditor,
|
||||
])
|
||||
|
||||
const existingGroupNames = useMemo(() => {
|
||||
const groups = new Set<string>()
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
: { ...base, id: "custom-default", name: t("theme.myTheme") }
|
||||
setWorkingTheme(editable)
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
|
||||
}, [currentTheme])
|
||||
}, [currentTheme, t])
|
||||
|
||||
// Real-time theme preview
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user