先提交上去
@@ -1,11 +1,11 @@
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { ContentArea } from './components/ContentArea'
|
||||
import { OOBE } from './components/OOBE/OOBE'
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
import { ContentArea } from "./components/ContentArea"
|
||||
import { OOBE } from "./components/OOBE/OOBE"
|
||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -17,8 +17,8 @@ function MainContent(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!(window as any).api) return
|
||||
const unlisten = (window as any).api.onNavigate((route: string) => {
|
||||
const currentPath = location.pathname === '/' ? '/home' : location.pathname
|
||||
const targetPath = route === '/' ? '/home' : route
|
||||
const currentPath = location.pathname === "/" ? "/home" : location.pathname
|
||||
const targetPath = route === "/" ? "/home" : route
|
||||
|
||||
if (currentPath !== targetPath) {
|
||||
navigate(route)
|
||||
@@ -28,23 +28,23 @@ function MainContent(): React.JSX.Element {
|
||||
}, [navigate, location.pathname])
|
||||
|
||||
const [wizardVisible, setWizardVisible] = useState(false)
|
||||
const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view')
|
||||
const [permission, setPermission] = useState<"admin" | "points" | "view">("view")
|
||||
const [hasAnyPassword, setHasAnyPassword] = useState(false)
|
||||
const [authVisible, setAuthVisible] = useState(false)
|
||||
const [authPassword, setAuthPassword] = useState('')
|
||||
const [authPassword, setAuthPassword] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
|
||||
const activeMenu = useMemo(() => {
|
||||
const p = location.pathname
|
||||
if (p === '/' || p.startsWith('/home')) return 'home'
|
||||
if (p.startsWith('/students')) return 'students'
|
||||
if (p.startsWith('/score')) return 'score'
|
||||
if (p.startsWith('/leaderboard')) return 'leaderboard'
|
||||
if (p.startsWith('/settlements')) return 'settlements'
|
||||
if (p.startsWith('/reasons')) return 'reasons'
|
||||
if (p.startsWith('/auto-score')) return 'auto-score'
|
||||
if (p.startsWith('/settings')) return 'settings'
|
||||
return 'home'
|
||||
if (p === "/" || p.startsWith("/home")) return "home"
|
||||
if (p.startsWith("/students")) return "students"
|
||||
if (p.startsWith("/score")) return "score"
|
||||
if (p.startsWith("/leaderboard")) return "leaderboard"
|
||||
if (p.startsWith("/settlements")) return "settlements"
|
||||
if (p.startsWith("/reasons")) return "reasons"
|
||||
if (p.startsWith("/auto-score")) return "auto-score"
|
||||
if (p.startsWith("/settings")) return "settings"
|
||||
return "home"
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +66,7 @@ function MainContent(): React.JSX.Element {
|
||||
setPermission(authRes.data.permission)
|
||||
const anyPwd = Boolean(authRes.data.hasAdminPassword || authRes.data.hasPointsPassword)
|
||||
setHasAnyPassword(anyPwd)
|
||||
if (anyPwd && authRes.data.permission === 'view') setAuthVisible(true)
|
||||
if (anyPwd && authRes.data.permission === "view") setAuthVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,10 @@ function MainContent(): React.JSX.Element {
|
||||
if (res.success && res.data) {
|
||||
setPermission(res.data.permission)
|
||||
setAuthVisible(false)
|
||||
setAuthPassword('')
|
||||
messageApi.success(t('auth.unlocked'))
|
||||
setAuthPassword("")
|
||||
messageApi.success(t("auth.unlocked"))
|
||||
} else {
|
||||
messageApi.error(res.message || t('common.error'))
|
||||
messageApi.error(res.message || t("common.error"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,36 +93,36 @@ function MainContent(): React.JSX.Element {
|
||||
const res = await (window as any).api.authLogout()
|
||||
if (res?.success && res.data) {
|
||||
setPermission(res.data.permission)
|
||||
messageApi.success(t('auth.logout'))
|
||||
messageApi.success(t("auth.logout"))
|
||||
}
|
||||
}
|
||||
|
||||
const onMenuChange = (v: string) => {
|
||||
const key = String(v)
|
||||
if (key === 'home') navigate('/')
|
||||
if (key === 'students') navigate('/students')
|
||||
if (key === 'score') navigate('/score')
|
||||
if (key === 'leaderboard') navigate('/leaderboard')
|
||||
if (key === 'settlements') navigate('/settlements')
|
||||
if (key === 'reasons') navigate('/reasons')
|
||||
if (key === 'auto-score') navigate('/auto-score')
|
||||
if (key === 'settings') navigate('/settings')
|
||||
if (key === "home") navigate("/")
|
||||
if (key === "students") navigate("/students")
|
||||
if (key === "score") navigate("/score")
|
||||
if (key === "leaderboard") navigate("/leaderboard")
|
||||
if (key === "settlements") navigate("/settlements")
|
||||
if (key === "reasons") navigate("/reasons")
|
||||
if (key === "auto-score") navigate("/auto-score")
|
||||
if (key === "settings") navigate("/settings")
|
||||
}
|
||||
|
||||
const isDark = currentTheme?.mode === 'dark'
|
||||
const brandColor = currentTheme?.config?.tdesign?.brandColor || '#0052D9'
|
||||
const isDark = currentTheme?.mode === "dark"
|
||||
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: brandColor
|
||||
}
|
||||
colorPrimary: brandColor,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Layout style={{ height: '100vh', flexDirection: 'row', overflow: 'hidden' }}>
|
||||
<Layout style={{ height: "100vh", flexDirection: "row", overflow: "hidden" }}>
|
||||
<Sidebar activeMenu={activeMenu} permission={permission} onMenuChange={onMenuChange} />
|
||||
<ContentArea
|
||||
permission={permission}
|
||||
@@ -134,22 +134,22 @@ function MainContent(): React.JSX.Element {
|
||||
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||
|
||||
<Modal
|
||||
title={t('auth.unlock')}
|
||||
title={t("auth.unlock")}
|
||||
open={authVisible}
|
||||
onCancel={() => setAuthVisible(false)}
|
||||
onOk={login}
|
||||
confirmLoading={authLoading}
|
||||
okText={t('auth.unlockButton')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("auth.unlockButton")}
|
||||
cancelText={t("common.cancel")}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
{t('auth.unlockHint')}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||
<div style={{ color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
||||
{t("auth.unlockHint")}
|
||||
</div>
|
||||
<Input
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
@@ -158,30 +158,30 @@ function MainContent(): React.JSX.Element {
|
||||
{import.meta.env.DEV ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
display: 'flex',
|
||||
bottom: '2px',
|
||||
left: '20px',
|
||||
position: "fixed",
|
||||
display: "flex",
|
||||
bottom: "2px",
|
||||
left: "20px",
|
||||
opacity: 0.6,
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
color: '#df0000',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
pointerEvents: 'none'
|
||||
color: "#df0000",
|
||||
fontWeight: "bold",
|
||||
fontSize: "14px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
开发中画面,不代表最终品质
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: currentTheme?.mode === 'dark' ? '#fff' : '#44474b',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '13px',
|
||||
paddingLeft: '5px'
|
||||
color: currentTheme?.mode === "dark" ? "#fff" : "#44474b",
|
||||
fontWeight: "bold",
|
||||
fontSize: "13px",
|
||||
paddingLeft: "5px",
|
||||
}}
|
||||
>
|
||||
SecScore Dev ({getPlatform()}-{getArchitecture()})
|
||||
@@ -196,12 +196,12 @@ function MainContent(): React.JSX.Element {
|
||||
function getArchitecture(): string {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
|
||||
if (userAgent.includes('arm64') || userAgent.includes('aarch64')) {
|
||||
return 'ARM64'
|
||||
} else if (userAgent.includes('x64') || userAgent.includes('amd64')) {
|
||||
return 'x64'
|
||||
} else if (userAgent.includes('i386') || userAgent.includes('i686')) {
|
||||
return 'x86'
|
||||
if (userAgent.includes("arm64") || userAgent.includes("aarch64")) {
|
||||
return "ARM64"
|
||||
} else if (userAgent.includes("x64") || userAgent.includes("amd64")) {
|
||||
return "x64"
|
||||
} else if (userAgent.includes("i386") || userAgent.includes("i686")) {
|
||||
return "x86"
|
||||
}
|
||||
|
||||
return userAgent
|
||||
@@ -210,15 +210,15 @@ function getArchitecture(): string {
|
||||
function getPlatform(): string {
|
||||
const userAgent = navigator.userAgent.toLowerCase()
|
||||
|
||||
if (userAgent.includes('windows')) {
|
||||
return 'Windows'
|
||||
} else if (userAgent.includes('mac')) {
|
||||
return 'Mac'
|
||||
} else if (userAgent.includes('linux')) {
|
||||
return 'Linux'
|
||||
if (userAgent.includes("windows")) {
|
||||
return "Windows"
|
||||
} else if (userAgent.includes("mac")) {
|
||||
return "Mac"
|
||||
} else if (userAgent.includes("linux")) {
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
return 'Unknown'
|
||||
return "Unknown"
|
||||
}
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Context } from '../../shared/kernel'
|
||||
import { Context } from "./shared/kernel"
|
||||
|
||||
export class ClientContext extends Context {
|
||||
constructor() {
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,4 +1,4 @@
|
||||
@import 'antd/dist/reset.css';
|
||||
@import "antd/dist/reset.css";
|
||||
|
||||
html,
|
||||
body,
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HolderOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RuleComponent from './autoScore/ruleComponent'
|
||||
import { useState, useEffect } from "react"
|
||||
import { HolderOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import RuleComponent from "./autoScore/ruleComponent"
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Popconfirm,
|
||||
Select,
|
||||
Tooltip,
|
||||
Pagination
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
Pagination,
|
||||
} from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
|
||||
interface AutoScoreRule {
|
||||
id: number
|
||||
@@ -32,7 +32,7 @@ interface TriggerItem {
|
||||
id: number
|
||||
eventName: string
|
||||
value: string
|
||||
relation: 'AND' | 'OR'
|
||||
relation: "AND" | "OR"
|
||||
}
|
||||
|
||||
interface ActionItem {
|
||||
@@ -48,13 +48,13 @@ interface AutoScoreRuleFormValues {
|
||||
}
|
||||
|
||||
const TRIGGER_DEFINITIONS = [
|
||||
{ eventName: 'interval_time_passed', labelKey: 'autoScore.triggerIntervalTime' },
|
||||
{ eventName: 'student_has_tag', labelKey: 'autoScore.triggerStudentTag' }
|
||||
{ eventName: "interval_time_passed", labelKey: "autoScore.triggerIntervalTime" },
|
||||
{ eventName: "student_has_tag", labelKey: "autoScore.triggerStudentTag" },
|
||||
]
|
||||
|
||||
const ACTION_DEFINITIONS = [
|
||||
{ eventName: 'add_score', labelKey: 'autoScore.actionAddScore' },
|
||||
{ eventName: 'add_tag', labelKey: 'autoScore.actionAddTag' }
|
||||
{ eventName: "add_score", labelKey: "autoScore.actionAddScore" },
|
||||
{ eventName: "add_tag", labelKey: "autoScore.actionAddTag" },
|
||||
]
|
||||
|
||||
export const AutoScoreManager: React.FC = () => {
|
||||
@@ -86,30 +86,30 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error(t('autoScore.adminRequired'))
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== "admin") {
|
||||
messageApi.error(t("autoScore.adminRequired"))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
console.warn("Auth check failed", e)
|
||||
}
|
||||
|
||||
const [rulesRes, studentsRes] = await Promise.all([
|
||||
(window as any).api.invoke('auto-score:getRules', {}),
|
||||
(window as any).api.queryStudents({})
|
||||
(window as any).api.invoke("auto-score:getRules", {}),
|
||||
(window as any).api.queryStudents({}),
|
||||
])
|
||||
if (rulesRes.success) {
|
||||
setRules(rulesRes.data)
|
||||
} else {
|
||||
messageApi.error(rulesRes.message || t('autoScore.fetchFailed'))
|
||||
messageApi.error(rulesRes.message || t("autoScore.fetchFailed"))
|
||||
}
|
||||
if (studentsRes.success) {
|
||||
setStudents(studentsRes.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auto score rules:', error)
|
||||
messageApi.error(t('autoScore.fetchFailed'))
|
||||
console.error("Failed to fetch auto score rules:", error)
|
||||
messageApi.error(t("autoScore.fetchFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -125,17 +125,17 @@ export const AutoScoreManager: React.FC = () => {
|
||||
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
|
||||
|
||||
if (!values.name) {
|
||||
messageApi.warning(t('autoScore.nameRequired'))
|
||||
messageApi.warning(t("autoScore.nameRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
if (triggerList.length === 0) {
|
||||
messageApi.warning(t('autoScore.triggerRequired'))
|
||||
messageApi.warning(t("autoScore.triggerRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
if (actionList.length === 0) {
|
||||
messageApi.warning(t('autoScore.actionRequired'))
|
||||
messageApi.warning(t("autoScore.actionRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,13 +144,13 @@ export const AutoScoreManager: React.FC = () => {
|
||||
const triggers = triggerList.map((t) => ({
|
||||
event: t.eventName,
|
||||
value: t.value,
|
||||
relation: t.relation
|
||||
relation: t.relation,
|
||||
}))
|
||||
|
||||
const actions = actionList.map((a) => ({
|
||||
event: a.eventName,
|
||||
value: a.value,
|
||||
reason: a.reason
|
||||
reason: a.reason,
|
||||
}))
|
||||
|
||||
const ruleDataToSubmit = {
|
||||
@@ -158,37 +158,37 @@ export const AutoScoreManager: React.FC = () => {
|
||||
name: values.name,
|
||||
studentNames,
|
||||
triggers,
|
||||
actions
|
||||
actions,
|
||||
}
|
||||
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error(t('autoScore.adminCreateRequired'))
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== "admin") {
|
||||
messageApi.error(t("autoScore.adminCreateRequired"))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
console.warn("Auth check failed", e)
|
||||
}
|
||||
|
||||
try {
|
||||
let res: { success: boolean; message?: string; data?: any }
|
||||
if (editingRuleId !== null) {
|
||||
res = await (window as any).api.invoke('auto-score:updateRule', {
|
||||
res = await (window as any).api.invoke("auto-score:updateRule", {
|
||||
id: editingRuleId,
|
||||
...ruleDataToSubmit
|
||||
...ruleDataToSubmit,
|
||||
})
|
||||
} else {
|
||||
res = await (window as any).api.invoke('auto-score:addRule', ruleDataToSubmit)
|
||||
res = await (window as any).api.invoke("auto-score:addRule", ruleDataToSubmit)
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
messageApi.success(
|
||||
editingRuleId !== null ? t('autoScore.updateSuccess') : t('autoScore.createSuccess')
|
||||
editingRuleId !== null ? t("autoScore.updateSuccess") : t("autoScore.createSuccess")
|
||||
)
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
studentNames: []
|
||||
name: "",
|
||||
studentNames: [],
|
||||
})
|
||||
setEditingRuleId(null)
|
||||
setTriggerList([])
|
||||
@@ -197,13 +197,13 @@ export const AutoScoreManager: React.FC = () => {
|
||||
} else {
|
||||
messageApi.error(
|
||||
res.message ||
|
||||
(editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed'))
|
||||
(editingRuleId !== null ? t("autoScore.updateFailed") : t("autoScore.createFailed"))
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit auto score rule:', error)
|
||||
console.error("Failed to submit auto score rule:", error)
|
||||
messageApi.error(
|
||||
editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed')
|
||||
editingRuleId !== null ? t("autoScore.updateFailed") : t("autoScore.createFailed")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,22 +212,22 @@ export const AutoScoreManager: React.FC = () => {
|
||||
setEditingRuleId(rule.id)
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
studentNames: rule.studentNames
|
||||
studentNames: rule.studentNames,
|
||||
})
|
||||
setTriggerList(
|
||||
(rule.triggers || []).map((t, index) => ({
|
||||
id: index + 1,
|
||||
eventName: t.eventName,
|
||||
value: t.value || '',
|
||||
relation: t.relation || 'AND'
|
||||
value: t.value || "",
|
||||
relation: t.relation || "AND",
|
||||
}))
|
||||
)
|
||||
setActionList(
|
||||
(rule.actions || []).map((a, index) => ({
|
||||
id: index + 1,
|
||||
eventName: a.eventName,
|
||||
value: a.value || '',
|
||||
reason: a.reason || ''
|
||||
value: a.value || "",
|
||||
reason: a.reason || "",
|
||||
}))
|
||||
)
|
||||
}
|
||||
@@ -236,25 +236,25 @@ export const AutoScoreManager: React.FC = () => {
|
||||
if (!(window as any).api) return
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error(t('autoScore.adminDeleteRequired'))
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== "admin") {
|
||||
messageApi.error(t("autoScore.adminDeleteRequired"))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
console.warn("Auth check failed", e)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
|
||||
const res = await (window as any).api.invoke("auto-score:deleteRule", ruleId)
|
||||
if (res.success) {
|
||||
messageApi.success(t('autoScore.deleteSuccess'))
|
||||
messageApi.success(t("autoScore.deleteSuccess"))
|
||||
fetchRules()
|
||||
} else {
|
||||
messageApi.error(res.message || t('autoScore.deleteFailed'))
|
||||
messageApi.error(res.message || t("autoScore.deleteFailed"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete auto score rule:', error)
|
||||
messageApi.error(t('autoScore.deleteFailed'))
|
||||
console.error("Failed to delete auto score rule:", error)
|
||||
messageApi.error(t("autoScore.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,34 +262,34 @@ export const AutoScoreManager: React.FC = () => {
|
||||
if (!(window as any).api) return
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error(t('autoScore.adminToggleRequired'))
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== "admin") {
|
||||
messageApi.error(t("autoScore.adminToggleRequired"))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Auth check failed', e)
|
||||
console.warn("Auth check failed", e)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
|
||||
const res = await (window as any).api.invoke("auto-score:toggleRule", { ruleId, enabled })
|
||||
if (res.success) {
|
||||
messageApi.success(enabled ? t('autoScore.enabled') : t('autoScore.disabled'))
|
||||
messageApi.success(enabled ? t("autoScore.enabled") : t("autoScore.disabled"))
|
||||
fetchRules()
|
||||
} else {
|
||||
messageApi.error(
|
||||
res.message || (enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed'))
|
||||
res.message || (enabled ? t("autoScore.enableFailed") : t("autoScore.disableFailed"))
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle auto score rule:', error)
|
||||
messageApi.error(enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed'))
|
||||
console.error("Failed to toggle auto score rule:", error)
|
||||
messageApi.error(enabled ? t("autoScore.enableFailed") : t("autoScore.disableFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetForm = () => {
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
studentNames: []
|
||||
name: "",
|
||||
studentNames: [],
|
||||
})
|
||||
setEditingRuleId(null)
|
||||
setTriggerList([])
|
||||
@@ -298,127 +298,127 @@ export const AutoScoreManager: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<AutoScoreRule> = [
|
||||
{
|
||||
key: 'drag',
|
||||
title: t('autoScore.sort'),
|
||||
key: "drag",
|
||||
title: t("autoScore.sort"),
|
||||
width: 60,
|
||||
render: () => <HolderOutlined style={{ cursor: 'move' }} />
|
||||
render: () => <HolderOutlined style={{ cursor: "move" }} />,
|
||||
},
|
||||
{
|
||||
title: t('autoScore.status'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
title: t("autoScore.status"),
|
||||
dataIndex: "enabled",
|
||||
key: "enabled",
|
||||
width: 80,
|
||||
render: (enabled: boolean, row) => (
|
||||
<Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
|
||||
)
|
||||
),
|
||||
},
|
||||
{ title: t('autoScore.name'), dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: t("autoScore.name"), dataIndex: "name", key: "name", width: 150 },
|
||||
{
|
||||
title: t('autoScore.triggers'),
|
||||
dataIndex: 'triggers',
|
||||
key: 'triggers',
|
||||
title: t("autoScore.triggers"),
|
||||
dataIndex: "triggers",
|
||||
key: "triggers",
|
||||
width: 150,
|
||||
render: (triggers: AutoScoreRule['triggers']) => {
|
||||
render: (triggers: AutoScoreRule["triggers"]) => {
|
||||
if (!triggers || triggers.length === 0) {
|
||||
return <span>{t('common.none')}</span>
|
||||
return <span>{t("common.none")}</span>
|
||||
}
|
||||
const triggerLabels = triggers.map((t) => getTriggerLabel(t.eventName))
|
||||
return (
|
||||
<Tooltip title={triggerLabels.join(', ')}>
|
||||
<span>{t('autoScore.triggerCount', { count: triggers.length })}</span>
|
||||
<Tooltip title={triggerLabels.join(", ")}>
|
||||
<span>{t("autoScore.triggerCount", { count: triggers.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('autoScore.actions'),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
title: t("autoScore.actions"),
|
||||
dataIndex: "actions",
|
||||
key: "actions",
|
||||
width: 150,
|
||||
render: (actions: AutoScoreRule['actions']) => {
|
||||
render: (actions: AutoScoreRule["actions"]) => {
|
||||
if (!actions || actions.length === 0) {
|
||||
return <span>{t('common.none')}</span>
|
||||
return <span>{t("common.none")}</span>
|
||||
}
|
||||
const actionLabels = actions.map((a) => getActionLabel(a.eventName))
|
||||
return (
|
||||
<Tooltip title={actionLabels.join(', ')}>
|
||||
<span>{t('autoScore.actionCount', { count: actions.length })}</span>
|
||||
<Tooltip title={actionLabels.join(", ")}>
|
||||
<span>{t("autoScore.actionCount", { count: actions.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('autoScore.applicableStudents'),
|
||||
dataIndex: 'studentNames',
|
||||
key: 'studentNames',
|
||||
title: t("autoScore.applicableStudents"),
|
||||
dataIndex: "studentNames",
|
||||
key: "studentNames",
|
||||
width: 130,
|
||||
render: (studentNames: string[]) => {
|
||||
if (!studentNames || studentNames.length === 0) {
|
||||
return <span>{t('autoScore.allStudents')}</span>
|
||||
return <span>{t("autoScore.allStudents")}</span>
|
||||
}
|
||||
const studentList = studentNames.join(',\n')
|
||||
const studentList = studentNames.join(",\n")
|
||||
return (
|
||||
<Tooltip title={studentList}>
|
||||
<span>{t('autoScore.studentCount', { count: studentNames.length })}</span>
|
||||
<span>{t("autoScore.studentCount", { count: studentNames.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('autoScore.lastExecuted'),
|
||||
dataIndex: 'lastExecuted',
|
||||
key: 'lastExecuted',
|
||||
title: t("autoScore.lastExecuted"),
|
||||
dataIndex: "lastExecuted",
|
||||
key: "lastExecuted",
|
||||
width: 180,
|
||||
render: (lastExecuted: string) => {
|
||||
if (!lastExecuted) return <span>{t('autoScore.notExecuted')}</span>
|
||||
if (!lastExecuted) return <span>{t("autoScore.notExecuted")}</span>
|
||||
try {
|
||||
const date = new Date(lastExecuted)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return <span>{t('autoScore.invalidTime')}</span>
|
||||
return <span>{t("autoScore.invalidTime")}</span>
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEdit(row)}>
|
||||
{t('common.edit')}
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<Popconfirm title={t('autoScore.deleteConfirm')} onConfirm={() => handleDelete(row.id)}>
|
||||
<Popconfirm title={t("autoScore.deleteConfirm")} onConfirm={() => handleDelete(row.id)}>
|
||||
<Button size="small" danger>
|
||||
{t('common.delete')}
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}>{t('autoScore.title')}</h2>
|
||||
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("autoScore.title")}</h2>
|
||||
|
||||
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
||||
<Form.Item
|
||||
label={t('autoScore.name')}
|
||||
label={t("autoScore.name")}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('autoScore.nameRequired') }]}
|
||||
rules={[{ required: true, message: t("autoScore.nameRequired") }]}
|
||||
>
|
||||
<Input placeholder={t('autoScore.namePlaceholder')} />
|
||||
<Input placeholder={t("autoScore.namePlaceholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('autoScore.applicableStudents')} name="studentNames">
|
||||
<Form.Item label={t("autoScore.applicableStudents")} name="studentNames">
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t('autoScore.studentPlaceholder')}
|
||||
placeholder={t("autoScore.studentPlaceholder")}
|
||||
options={students.map((student) => ({ label: student.name, value: student.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -464,25 +464,25 @@ export const AutoScoreManager: React.FC = () => {
|
||||
</Space>
|
||||
</Card> */}
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '12px' }}>
|
||||
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
{editingRuleId !== null ? t('autoScore.updateAutomation') : t('autoScore.addAutomation')}
|
||||
{editingRuleId !== null ? t("autoScore.updateAutomation") : t("autoScore.addAutomation")}
|
||||
</Button>
|
||||
<Button onClick={handleResetForm}>
|
||||
{editingRuleId !== null ? t('autoScore.cancelEdit') : t('autoScore.resetForm')}
|
||||
{editingRuleId !== null ? t("autoScore.cancelEdit") : t("autoScore.resetForm")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Table
|
||||
dataSource={rules.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
style={{ color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 16, textAlign: "right" }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
@@ -492,7 +492,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
setPageSize(size)
|
||||
}}
|
||||
showSizeChanger
|
||||
showTotal={(total) => t('common.total', { count: total })}
|
||||
showTotal={(total) => t("common.total", { count: total })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1,29 +1,29 @@
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import { Layout, Space, Button, Tag, Spin } from 'antd'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { Suspense, lazy } from "react"
|
||||
import { Layout, Space, Button, Tag, Spin } from "antd"
|
||||
import { Routes, Route, Navigate } from "react-router-dom"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home })))
|
||||
const Home = lazy(() => import("./Home").then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() =>
|
||||
import('./StudentManager').then((m) => ({ default: m.StudentManager }))
|
||||
import("./StudentManager").then((m) => ({ default: m.StudentManager }))
|
||||
)
|
||||
const Settings = lazy(() => import('./Settings').then((m) => ({ default: m.Settings })))
|
||||
const Settings = lazy(() => import("./Settings").then((m) => ({ default: m.Settings })))
|
||||
const ReasonManager = lazy(() =>
|
||||
import('./ReasonManager').then((m) => ({ default: m.ReasonManager }))
|
||||
import("./ReasonManager").then((m) => ({ default: m.ReasonManager }))
|
||||
)
|
||||
const ScoreManager = lazy(() => import('./ScoreManager').then((m) => ({ default: m.ScoreManager })))
|
||||
const Leaderboard = lazy(() => import('./Leaderboard').then((m) => ({ default: m.Leaderboard })))
|
||||
const ScoreManager = lazy(() => import("./ScoreManager").then((m) => ({ default: m.ScoreManager })))
|
||||
const Leaderboard = lazy(() => import("./Leaderboard").then((m) => ({ default: m.Leaderboard })))
|
||||
const SettlementHistory = lazy(() =>
|
||||
import('./SettlementHistory').then((m) => ({ default: m.SettlementHistory }))
|
||||
import("./SettlementHistory").then((m) => ({ default: m.SettlementHistory }))
|
||||
)
|
||||
const AutoScoreManager = lazy(() =>
|
||||
import('./AutoScoreManager').then((m) => ({ default: m.AutoScoreManager }))
|
||||
import("./AutoScoreManager").then((m) => ({ default: m.AutoScoreManager }))
|
||||
)
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
interface ContentAreaProps {
|
||||
permission: 'admin' | 'points' | 'view'
|
||||
permission: "admin" | "points" | "view"
|
||||
hasAnyPassword: boolean
|
||||
onAuthClick: () => void
|
||||
onLogout: () => void
|
||||
@@ -33,18 +33,18 @@ export function ContentArea({
|
||||
permission,
|
||||
hasAnyPassword,
|
||||
onAuthClick,
|
||||
onLogout
|
||||
onLogout,
|
||||
}: ContentAreaProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const permissionTag = (
|
||||
<Tag
|
||||
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
|
||||
color={permission === "admin" ? "success" : permission === "points" ? "warning" : "default"}
|
||||
>
|
||||
{permission === 'admin'
|
||||
? t('permissions.admin')
|
||||
: permission === 'points'
|
||||
? t('permissions.points')
|
||||
: t('permissions.view')}
|
||||
{permission === "admin"
|
||||
? t("permissions.admin")
|
||||
: permission === "points"
|
||||
? t("permissions.points")
|
||||
: t("permissions.view")}
|
||||
</Tag>
|
||||
)
|
||||
|
||||
@@ -52,21 +52,21 @@ export function ContentArea({
|
||||
<Layout
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--ss-bg-color)'
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
background: "var(--ss-bg-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'var(--ss-header-bg)',
|
||||
borderBottom: '1px solid var(--ss-border-color)',
|
||||
flexShrink: 0
|
||||
height: "40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "var(--ss-header-bg)",
|
||||
borderBottom: "1px solid var(--ss-border-color)",
|
||||
flexShrink: 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -74,9 +74,9 @@ export function ContentArea({
|
||||
<div
|
||||
style={
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: '12px'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingRight: "12px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -85,10 +85,10 @@ export function ContentArea({
|
||||
{hasAnyPassword && (
|
||||
<>
|
||||
<Button size="small" onClick={onAuthClick}>
|
||||
{t('auth.enterPassword')}
|
||||
{t("auth.enterPassword")}
|
||||
</Button>
|
||||
<Button size="small" danger onClick={onLogout}>
|
||||
{t('auth.lock')}
|
||||
{t("auth.lock")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -96,17 +96,17 @@ export function ContentArea({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Content style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Content style={{ flex: 1, overflowY: "auto" }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: 16
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
@@ -116,16 +116,16 @@ export function ContentArea({
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Home canEdit={permission === 'admin' || permission === 'points'} />}
|
||||
element={<Home canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
|
||||
<Route path="/students" element={<StudentManager canEdit={permission === "admin"} />} />
|
||||
<Route
|
||||
path="/score"
|
||||
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
|
||||
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />}
|
||||
/>
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/settlements" element={<SettlementHistory />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
|
||||
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/auto-score" element={<AutoScoreManager />} />
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Table, Tag } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { Table, Tag } from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
|
||||
interface scoreEvent {
|
||||
id: number
|
||||
@@ -32,31 +32,31 @@ export const EventHistory: React.FC = () => {
|
||||
}, [])
|
||||
|
||||
const columns: ColumnsType<scoreEvent> = [
|
||||
{ title: '学生姓名', dataIndex: 'student_name', key: 'student_name', width: 120 },
|
||||
{ title: '积分理由', dataIndex: 'reason_content', key: 'reason_content', width: 200 },
|
||||
{ title: "学生姓名", dataIndex: "student_name", key: "student_name", width: 120 },
|
||||
{ title: "积分理由", dataIndex: "reason_content", key: "reason_content", width: 200 },
|
||||
{
|
||||
title: '分值变动',
|
||||
dataIndex: 'delta',
|
||||
key: 'delta',
|
||||
title: "分值变动",
|
||||
dataIndex: "delta",
|
||||
key: "delta",
|
||||
width: 100,
|
||||
render: (delta: number) => (
|
||||
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||
)
|
||||
<Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '原分值', dataIndex: 'val_prev', key: 'val_prev', width: 100 },
|
||||
{ title: '新分值', dataIndex: 'val_curr', key: 'val_curr', width: 100 },
|
||||
{ title: "原分值", dataIndex: "val_prev", key: "val_prev", width: 100 },
|
||||
{ title: "新分值", dataIndex: "val_curr", key: "val_curr", width: 100 },
|
||||
{
|
||||
title: '发生时间',
|
||||
dataIndex: 'event_time',
|
||||
key: 'event_time',
|
||||
title: "发生时间",
|
||||
dataIndex: "event_time",
|
||||
key: "event_time",
|
||||
width: 180,
|
||||
render: (time: string) => new Date(time).toLocaleString()
|
||||
}
|
||||
render: (time: string) => new Date(time).toLocaleString(),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}>积分流水</h2>
|
||||
<div style={{ padding: "24px" }}>
|
||||
<h2 style={{ marginBottom: "16px", color: "var(--ss-text-main)" }}>积分流水</h2>
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
@@ -64,7 +64,7 @@ export const EventHistory: React.FC = () => {
|
||||
loading={loading}
|
||||
bordered
|
||||
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
|
||||
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { match, pinyin } from 'pinyin-pro'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from "antd"
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { match, pinyin } from "pinyin-pro"
|
||||
|
||||
interface student {
|
||||
id: number
|
||||
@@ -19,15 +19,15 @@ interface reason {
|
||||
category: string
|
||||
}
|
||||
|
||||
type SortType = 'alphabet' | 'surname' | 'score'
|
||||
type SortType = "alphabet" | "surname" | "score"
|
||||
|
||||
export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sortType, setSortType] = useState<SortType>('alphabet')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [sortType, setSortType] = useState<SortType>("alphabet")
|
||||
const [searchKeyword, setSearchKeyword] = useState("")
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
@@ -35,25 +35,25 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
|
||||
const [operationVisible, setOperationVisible] = useState(false)
|
||||
const [customScore, setCustomScore] = useState<number | undefined>(undefined)
|
||||
const [reasonContent, setReasonContent] = useState('')
|
||||
const [reasonContent, setReasonContent] = useState("")
|
||||
const [submitLoading, setSubmitLoading] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
|
||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||
}
|
||||
|
||||
const getSurname = (name: string) => {
|
||||
if (!name) return ''
|
||||
if (!name) return ""
|
||||
return name.charAt(0)
|
||||
}
|
||||
|
||||
const getFirstLetter = (name: string) => {
|
||||
if (!name) return ''
|
||||
if (!name) return ""
|
||||
const firstChar = name.charAt(0)
|
||||
if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase()
|
||||
const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' })
|
||||
return py ? py.toUpperCase() : '#'
|
||||
const py = pinyin(firstChar, { pattern: "first", toneType: "none" })
|
||||
return py ? py.toUpperCase() : "#"
|
||||
}
|
||||
|
||||
const fetchData = useCallback(async (silent = false) => {
|
||||
@@ -61,14 +61,14 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
if (!silent) setLoading(true)
|
||||
const [stuRes, reaRes] = await Promise.all([
|
||||
(window as any).api.queryStudents({}),
|
||||
(window as any).api.queryReasons()
|
||||
(window as any).api.queryReasons(),
|
||||
])
|
||||
|
||||
if (stuRes.success) {
|
||||
const enrichedStudents = (stuRes.data as student[]).map((s) => ({
|
||||
...s,
|
||||
pinyinName: pinyin(s.name, { toneType: 'none' }).toLowerCase(),
|
||||
pinyinFirst: getFirstLetter(s.name)
|
||||
pinyinName: pinyin(s.name, { toneType: "none" }).toLowerCase(),
|
||||
pinyinFirst: getFirstLetter(s.name),
|
||||
}))
|
||||
setStudents(enrichedStudents)
|
||||
}
|
||||
@@ -80,16 +80,16 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fetchData()
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (category === 'students' || category === 'reasons' || category === 'all') {
|
||||
if (category === "students" || category === "reasons" || category === "all") {
|
||||
fetchData(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchData])
|
||||
|
||||
const getDisplayText = (name: string) => {
|
||||
if (!name) return ''
|
||||
if (!name) return ""
|
||||
return name.length > 2 ? name.substring(name.length - 2) : name
|
||||
}
|
||||
|
||||
@@ -100,13 +100,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const nameLower = String(s.name).toLowerCase()
|
||||
if (nameLower.includes(q0)) return true
|
||||
|
||||
const pyLower = s.pinyinName || ''
|
||||
const pyLower = s.pinyinName || ""
|
||||
if (pyLower.includes(q0)) return true
|
||||
|
||||
const q1 = q0.replace(/\s+/g, '')
|
||||
const q1 = q0.replace(/\s+/g, "")
|
||||
if (
|
||||
q1 &&
|
||||
(nameLower.replace(/\s+/g, '').includes(q1) || pyLower.replace(/\s+/g, '').includes(q1))
|
||||
(nameLower.replace(/\s+/g, "").includes(q1) || pyLower.replace(/\s+/g, "").includes(q1))
|
||||
)
|
||||
return true
|
||||
|
||||
@@ -124,71 +124,71 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const filtered = students.filter((s) => matchStudentName(s, searchKeyword))
|
||||
|
||||
switch (sortType) {
|
||||
case 'alphabet':
|
||||
case "alphabet":
|
||||
return filtered.sort((a, b) => {
|
||||
const pyA = a.pinyinName || ''
|
||||
const pyB = b.pinyinName || ''
|
||||
const pyA = a.pinyinName || ""
|
||||
const pyB = b.pinyinName || ""
|
||||
return pyA.localeCompare(pyB)
|
||||
})
|
||||
case 'surname':
|
||||
case "surname":
|
||||
return filtered.sort((a, b) => {
|
||||
const surnameA = getSurname(a.name)
|
||||
const surnameB = getSurname(b.name)
|
||||
if (surnameA === surnameB) {
|
||||
return a.name.localeCompare(b.name, 'zh-CN')
|
||||
return a.name.localeCompare(b.name, "zh-CN")
|
||||
}
|
||||
return surnameA.localeCompare(surnameB, 'zh-CN')
|
||||
return surnameA.localeCompare(surnameB, "zh-CN")
|
||||
})
|
||||
case 'score':
|
||||
return filtered.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
case "score":
|
||||
return filtered.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "zh-CN"))
|
||||
default:
|
||||
return filtered
|
||||
}
|
||||
}, [students, searchKeyword, sortType, matchStudentName])
|
||||
|
||||
const groupedStudents = useMemo(() => {
|
||||
if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) {
|
||||
return [{ key: 'all', students: sortedStudents }]
|
||||
if (sortType === "score" || (sortType === "alphabet" && searchKeyword)) {
|
||||
return [{ key: "all", students: sortedStudents }]
|
||||
}
|
||||
|
||||
const groups: Record<string, student[]> = {}
|
||||
sortedStudents.forEach((s) => {
|
||||
const key = sortType === 'alphabet' ? s.pinyinFirst || '#' : getSurname(s.name)
|
||||
const key = sortType === "alphabet" ? s.pinyinFirst || "#" : getSurname(s.name)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(s)
|
||||
})
|
||||
|
||||
return Object.entries(groups)
|
||||
.sort(([a], [b]) => a.localeCompare(b, 'zh-CN'))
|
||||
.sort(([a], [b]) => a.localeCompare(b, "zh-CN"))
|
||||
.map(([key, students]) => ({ key, students }))
|
||||
}, [sortedStudents, sortType, searchKeyword])
|
||||
|
||||
const groupedReasons = useMemo(() => {
|
||||
const groups: Record<string, reason[]> = {}
|
||||
reasons.forEach((r) => {
|
||||
const cat = r.category || t('home.category.others')
|
||||
const cat = r.category || t("home.category.others")
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(r)
|
||||
})
|
||||
return Object.entries(groups).sort(([a], [b]) => {
|
||||
if (a === t('home.category.others')) return 1
|
||||
if (b === t('home.category.others')) return -1
|
||||
return a.localeCompare(b, 'zh-CN')
|
||||
if (a === t("home.category.others")) return 1
|
||||
if (b === t("home.category.others")) return -1
|
||||
return a.localeCompare(b, "zh-CN")
|
||||
})
|
||||
}, [reasons])
|
||||
|
||||
const getAvatarColor = (name: string) => {
|
||||
const colors = [
|
||||
'#FF6B6B',
|
||||
'#4ECDC4',
|
||||
'#45B7D1',
|
||||
'#FFA07A',
|
||||
'#98D8C8',
|
||||
'#F7DC6F',
|
||||
'#BB8FCE',
|
||||
'#85C1E2',
|
||||
'#F8B739',
|
||||
'#52B788'
|
||||
"#FF6B6B",
|
||||
"#4ECDC4",
|
||||
"#45B7D1",
|
||||
"#FFA07A",
|
||||
"#98D8C8",
|
||||
"#F7DC6F",
|
||||
"#BB8FCE",
|
||||
"#85C1E2",
|
||||
"#F8B739",
|
||||
"#52B788",
|
||||
]
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
@@ -201,25 +201,25 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const scrollToGroup = (key: string) => {
|
||||
const element = groupRefs.current[key]
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
element.scrollIntoView({ behavior: "auto", block: "start" })
|
||||
}
|
||||
}
|
||||
|
||||
const openOperation = (student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setSelectedStudent(student)
|
||||
setCustomScore(undefined)
|
||||
setReasonContent('')
|
||||
setReasonContent("")
|
||||
setOperationVisible(true)
|
||||
}
|
||||
|
||||
const performSubmit = async (student: student, delta: number, content: string) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,14 +227,14 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const res = await (window as any).api.createEvent({
|
||||
student_name: student.name,
|
||||
reason_content: content,
|
||||
delta: delta
|
||||
delta: delta,
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
messageApi.success(
|
||||
delta > 0
|
||||
? t('home.scoreAdded', { name: student.name, points: Math.abs(delta) })
|
||||
: t('home.scoreDeducted', { name: student.name, points: Math.abs(delta) })
|
||||
? t("home.scoreAdded", { name: student.name, points: Math.abs(delta) })
|
||||
: t("home.scoreDeducted", { name: student.name, points: Math.abs(delta) })
|
||||
)
|
||||
setOperationVisible(false)
|
||||
|
||||
@@ -242,9 +242,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s))
|
||||
)
|
||||
|
||||
emitDataUpdated('events')
|
||||
emitDataUpdated("events")
|
||||
} else {
|
||||
messageApi.error(res.message || t('home.submitFailed'))
|
||||
messageApi.error(res.message || t("home.submitFailed"))
|
||||
}
|
||||
setSubmitLoading(false)
|
||||
}
|
||||
@@ -254,17 +254,17 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const delta = customScore
|
||||
if (delta === undefined || !Number.isFinite(delta)) {
|
||||
messageApi.warning(t('home.pleaseSelectPoints'))
|
||||
messageApi.warning(t("home.pleaseSelectPoints"))
|
||||
return
|
||||
}
|
||||
|
||||
const content =
|
||||
reasonContent ||
|
||||
(delta > 0
|
||||
? t('home.addPoints')
|
||||
? t("home.addPoints")
|
||||
: delta < 0
|
||||
? t('home.deductPoints')
|
||||
: t('home.pointsChange'))
|
||||
? t("home.deductPoints")
|
||||
: t("home.pointsChange"))
|
||||
await performSubmit(selectedStudent, delta, content)
|
||||
}
|
||||
|
||||
@@ -278,76 +278,76 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const avatarColor = getAvatarColor(student.name)
|
||||
|
||||
let rankBadge: string | null = null
|
||||
if (sortType === 'score' && !searchKeyword) {
|
||||
if (index === 0) rankBadge = '🥇'
|
||||
else if (index === 1) rankBadge = '🥈'
|
||||
else if (index === 2) rankBadge = '🥉'
|
||||
if (sortType === "score" && !searchKeyword) {
|
||||
if (index === 0) rankBadge = "🥇"
|
||||
else if (index === 1) rankBadge = "🥈"
|
||||
else if (index === 2) rankBadge = "🥉"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
onClick={() => openOperation(student)}
|
||||
style={{ cursor: 'pointer', position: 'relative' }}
|
||||
style={{ cursor: "pointer", position: "relative" }}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
transition: 'all 0.2s cubic-bezier(0.38, 0, 0.24, 1)',
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
overflow: 'visible'
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
transition: "all 0.2s cubic-bezier(0.38, 0, 0.24, 1)",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
overflow: "visible",
|
||||
}}
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
styles={{ body: { padding: "12px" } }}
|
||||
>
|
||||
{rankBadge && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
left: '-10px',
|
||||
fontSize: '24px',
|
||||
zIndex: 1
|
||||
position: "absolute",
|
||||
top: "-10px",
|
||||
left: "-10px",
|
||||
fontSize: "24px",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{rankBadge}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '12px',
|
||||
width: "44px",
|
||||
height: "44px",
|
||||
borderRadius: "12px",
|
||||
backgroundColor: avatarColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: avatarText.length > 1 ? '14px' : '18px',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: avatarText.length > 1 ? "14px" : "18px",
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 4px 10px ${avatarColor}40`
|
||||
boxShadow: `0 4px 10px ${avatarColor}40`,
|
||||
}}
|
||||
>
|
||||
{avatarText}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '15px',
|
||||
color: 'var(--ss-text-main)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
fontSize: "15px",
|
||||
color: "var(--ss-text-main)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{student.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", marginTop: "2px" }}>
|
||||
<Tag
|
||||
color={student.score > 0 ? 'success' : student.score < 0 ? 'error' : 'default'}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
color={student.score > 0 ? "success" : student.score < 0 ? "error" : "default"}
|
||||
style={{ fontWeight: "bold" }}
|
||||
>
|
||||
{student.score > 0 ? `+${student.score}` : student.score}
|
||||
</Tag>
|
||||
@@ -363,38 +363,38 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return groupedStudents.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
style={{ marginBottom: '32px' }}
|
||||
style={{ marginBottom: "32px" }}
|
||||
ref={(el) => {
|
||||
groupRefs.current[group.key] = el
|
||||
}}
|
||||
>
|
||||
{group.key !== 'all' && (
|
||||
{group.key !== "all" && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--ss-text-main)',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
borderLeft: '4px solid var(--ant-color-primary, #1890ff)',
|
||||
paddingLeft: '12px'
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--ss-text-main)",
|
||||
marginBottom: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
borderLeft: "4px solid var(--ant-color-primary, #1890ff)",
|
||||
paddingLeft: "12px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ant-color-primary, #1890ff)' }}>{group.key}</span>
|
||||
<span style={{ color: "var(--ant-color-primary, #1890ff)" }}>{group.key}</span>
|
||||
<span
|
||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
|
||||
style={{ fontSize: "12px", color: "var(--ss-text-secondary)", fontWeight: "normal" }}
|
||||
>
|
||||
({t('home.studentCount', { count: group.students.length })})
|
||||
({t("home.studentCount", { count: group.students.length })})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: '16px'
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{group.students.map((student, idx) => renderStudentCard(student, idx))}
|
||||
@@ -430,8 +430,8 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const onNavMouseDown = (e: React.MouseEvent) => {
|
||||
isNavDragging.current = true
|
||||
handleNavAction(e.clientY)
|
||||
document.addEventListener('mousemove', onGlobalMouseMove)
|
||||
document.addEventListener('mouseup', onGlobalMouseUp)
|
||||
document.addEventListener("mousemove", onGlobalMouseMove)
|
||||
document.addEventListener("mouseup", onGlobalMouseUp)
|
||||
}
|
||||
|
||||
const onGlobalMouseMove = (e: MouseEvent) => {
|
||||
@@ -442,8 +442,8 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const onGlobalMouseUp = () => {
|
||||
isNavDragging.current = false
|
||||
document.removeEventListener('mousemove', onGlobalMouseMove)
|
||||
document.removeEventListener('mouseup', onGlobalMouseUp)
|
||||
document.removeEventListener("mousemove", onGlobalMouseMove)
|
||||
document.removeEventListener("mouseup", onGlobalMouseUp)
|
||||
}
|
||||
|
||||
const onNavTouchStart = (e: React.TouchEvent) => {
|
||||
@@ -467,8 +467,8 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const renderQuickNav = () => {
|
||||
if (
|
||||
groupedStudents.length <= 1 ||
|
||||
sortType === 'score' ||
|
||||
(sortType === 'alphabet' && searchKeyword)
|
||||
sortType === "score" ||
|
||||
(sortType === "alphabet" && searchKeyword)
|
||||
)
|
||||
return null
|
||||
|
||||
@@ -480,38 +480,38 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
onTouchMove={onNavTouchMove}
|
||||
onTouchEnd={onNavTouchEnd}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
padding: '8px 4px',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
position: "fixed",
|
||||
right: "12px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
padding: "8px 4px",
|
||||
borderRadius: "20px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
||||
zIndex: 100,
|
||||
maxHeight: '80vh',
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
maxHeight: "80vh",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{groupedStudents.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--ant-color-primary, #1890ff)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none'
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "11px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--ant-color-primary, #1890ff)",
|
||||
borderRadius: "50%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{group.key}
|
||||
@@ -522,24 +522,24 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
|
||||
<div style={{ padding: "24px", maxWidth: "1200px", margin: "0 auto", position: "relative" }}>
|
||||
{contextHolder}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '32px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
flexWrap: 'wrap'
|
||||
marginBottom: "32px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "16px",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}>
|
||||
{t('home.title')}
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)", fontSize: "24px" }}>
|
||||
{t("home.title")}
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||
{t('home.subtitle', { count: students.length })}
|
||||
<p style={{ margin: "4px 0 0", color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("home.subtitle", { count: students.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -547,20 +547,20 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder={t('home.searchPlaceholder')}
|
||||
placeholder={t("home.searchPlaceholder")}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: '220px' }}
|
||||
style={{ width: "220px" }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={sortType}
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
style={{ width: '140px' }}
|
||||
style={{ width: "140px" }}
|
||||
options={[
|
||||
{ value: 'alphabet', label: t('home.sortBy.alphabet') },
|
||||
{ value: 'surname', label: t('home.sortBy.surname') },
|
||||
{ value: 'score', label: t('home.sortBy.score') }
|
||||
{ value: "alphabet", label: t("home.sortBy.alphabet") },
|
||||
{ value: "surname", label: t("home.sortBy.surname") },
|
||||
{ value: "score", label: t("home.sortBy.score") },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
@@ -568,27 +568,27 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
{renderQuickNav()}
|
||||
|
||||
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
|
||||
<div style={{ minHeight: "400px" }} ref={scrollContainerRef}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('common.loading')}</div>
|
||||
<div style={{ textAlign: "center", padding: "100px 0" }}>
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>{t("common.loading")}</div>
|
||||
</div>
|
||||
) : sortedStudents.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '100px 0',
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
borderRadius: '12px',
|
||||
border: '1px dashed var(--ss-border-color)'
|
||||
textAlign: "center",
|
||||
padding: "100px 0",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
borderRadius: "12px",
|
||||
border: "1px dashed var(--ss-border-color)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
|
||||
{searchKeyword ? t('home.noMatch') : t('home.noStudents')}
|
||||
<div style={{ fontSize: "16px", color: "var(--ss-text-secondary)" }}>
|
||||
{searchKeyword ? t("home.noMatch") : t("home.noStudents")}
|
||||
</div>
|
||||
{searchKeyword && (
|
||||
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
|
||||
{t('home.clearSearch')}
|
||||
<Button type="link" onClick={() => setSearchKeyword("")} style={{ marginTop: "8px" }}>
|
||||
{t("home.clearSearch")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -598,60 +598,60 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('home.operationTitle', { name: selectedStudent?.name })}
|
||||
title={t("home.operationTitle", { name: selectedStudent?.name })}
|
||||
open={operationVisible}
|
||||
onCancel={() => setOperationVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitLoading}
|
||||
okText={t('home.submitOperation')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("home.submitOperation")}
|
||||
cancelText={t("common.cancel")}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
{selectedStudent && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--ss-bg-color)',
|
||||
borderRadius: '8px'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
backgroundColor: "var(--ss-bg-color)",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: getAvatarColor(selectedStudent.name),
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{getDisplayText(selectedStudent.name)}
|
||||
</div>
|
||||
<span style={{ fontWeight: 600 }}>{selectedStudent.name}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||
{t('home.currentScore')}:
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<span style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||
{t("home.currentScore")}:
|
||||
</span>
|
||||
<Tag
|
||||
color={
|
||||
selectedStudent.score > 0
|
||||
? 'success'
|
||||
? "success"
|
||||
: selectedStudent.score < 0
|
||||
? 'error'
|
||||
: 'default'
|
||||
? "error"
|
||||
: "default"
|
||||
}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
style={{ fontWeight: "bold" }}
|
||||
>
|
||||
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
|
||||
</Tag>
|
||||
@@ -662,35 +662,35 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
marginBottom: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>
|
||||
{t('home.quickOptions')}
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>
|
||||
{t("home.quickOptions")}
|
||||
</span>
|
||||
<Divider style={{ flex: 1, margin: 0 }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '4px'
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
maxHeight: "240px",
|
||||
overflowY: "auto",
|
||||
paddingRight: "4px",
|
||||
}}
|
||||
>
|
||||
{groupedReasons.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--ss-text-secondary)',
|
||||
marginBottom: '6px',
|
||||
paddingLeft: '2px'
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
marginBottom: "6px",
|
||||
paddingLeft: "2px",
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
@@ -704,23 +704,23 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
style={{
|
||||
borderColor:
|
||||
r.delta > 0
|
||||
? 'var(--ant-color-success, #52c41a)'
|
||||
? "var(--ant-color-success, #52c41a)"
|
||||
: r.delta < 0
|
||||
? 'var(--ant-color-error, #ff4d4f)'
|
||||
: undefined
|
||||
? "var(--ant-color-error, #ff4d4f)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{r.content}{' '}
|
||||
{r.content}{" "}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
marginLeft: "4px",
|
||||
color:
|
||||
r.delta > 0
|
||||
? 'var(--ant-color-success, #52c41a)'
|
||||
? "var(--ant-color-success, #52c41a)"
|
||||
: r.delta < 0
|
||||
? 'var(--ant-color-error, #ff4d4f)'
|
||||
: 'inherit',
|
||||
fontWeight: 'bold'
|
||||
? "var(--ant-color-error, #ff4d4f)"
|
||||
: "inherit",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{r.delta > 0 ? `+${r.delta}` : r.delta}
|
||||
@@ -736,60 +736,60 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>{t('home.adjustPoints')}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>{t("home.adjustPoints")}</span>
|
||||
<Divider style={{ flex: 1, margin: 0 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginBottom: "12px" }}>
|
||||
{[-5, -3, -2, -1, 1, 2, 3, 5, 10].map((num) => (
|
||||
<Button
|
||||
key={num}
|
||||
size="small"
|
||||
type={customScore === num ? 'primary' : 'default'}
|
||||
type={customScore === num ? "primary" : "default"}
|
||||
danger={num < 0}
|
||||
onClick={() => setCustomScore(num)}
|
||||
style={{ minWidth: '42px' }}
|
||||
style={{ minWidth: "42px" }}
|
||||
>
|
||||
{num > 0 ? `+${num}` : num}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="small" onClick={() => setCustomScore(0)} style={{ minWidth: '42px' }}>
|
||||
<Button size="small" onClick={() => setCustomScore(0)} style={{ minWidth: "42px" }}>
|
||||
0
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
|
||||
<InputNumber
|
||||
value={customScore}
|
||||
onChange={(v) => setCustomScore(v as number)}
|
||||
min={-99}
|
||||
max={99}
|
||||
step={1}
|
||||
style={{ width: '140px' }}
|
||||
placeholder={t('home.customPoints')}
|
||||
style={{ width: "140px" }}
|
||||
placeholder={t("home.customPoints")}
|
||||
/>
|
||||
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
|
||||
{t('home.customPointsHint')}
|
||||
<span style={{ fontSize: "13px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("home.customPointsHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>{t('home.reason')}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: "14px" }}>{t("home.reason")}</span>
|
||||
<Divider style={{ flex: 1, margin: 0 }} />
|
||||
</div>
|
||||
<Input
|
||||
value={reasonContent}
|
||||
onChange={(e) => setReasonContent(e.target.value)}
|
||||
placeholder={t('home.reasonPlaceholder')}
|
||||
placeholder={t("home.reasonPlaceholder")}
|
||||
suffix={
|
||||
reasonContent ? (
|
||||
<DeleteOutlined
|
||||
onClick={() => setReasonContent('')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setReasonContent("")}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -799,48 +799,48 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
{customScore !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
padding: "16px",
|
||||
background:
|
||||
customScore > 0
|
||||
? 'var(--ant-color-success-bg, #f6ffed)'
|
||||
? "var(--ant-color-success-bg, #f6ffed)"
|
||||
: customScore < 0
|
||||
? 'var(--ant-color-error-bg, #fff2f0)'
|
||||
: 'var(--ss-bg-color)',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${customScore > 0 ? 'var(--ant-color-success-border, #b7eb8f)' : customScore < 0 ? 'var(--ant-color-error-border, #ffccc7)' : 'var(--ss-border-color)'}`,
|
||||
marginTop: '4px'
|
||||
? "var(--ant-color-error-bg, #fff2f0)"
|
||||
: "var(--ss-bg-color)",
|
||||
borderRadius: "8px",
|
||||
border: `1px solid ${customScore > 0 ? "var(--ant-color-success-border, #b7eb8f)" : customScore < 0 ? "var(--ant-color-error-border, #ffccc7)" : "var(--ss-border-color)"}`,
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
marginBottom: '4px',
|
||||
color: 'var(--ss-text-main)'
|
||||
marginBottom: "4px",
|
||||
color: "var(--ss-text-main)",
|
||||
}}
|
||||
>
|
||||
{t('home.preview')}:
|
||||
{t("home.preview")}:
|
||||
</div>
|
||||
<div style={{ fontSize: '15px' }}>
|
||||
{selectedStudent.name}{' '}
|
||||
<div style={{ fontSize: "15px" }}>
|
||||
{selectedStudent.name}{" "}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: "bold",
|
||||
color:
|
||||
customScore > 0
|
||||
? 'var(--ant-color-success, #52c41a)'
|
||||
? "var(--ant-color-success, #52c41a)"
|
||||
: customScore < 0
|
||||
? 'var(--ant-color-error, #ff4d4f)'
|
||||
: 'inherit'
|
||||
? "var(--ant-color-error, #ff4d4f)"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{customScore > 0 ? `+${customScore}` : customScore}
|
||||
</span>{' '}
|
||||
{t('home.points')}
|
||||
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
|
||||
</span>{" "}
|
||||
{t("home.points")}
|
||||
<span style={{ color: "var(--ss-text-secondary)", marginLeft: "8px" }}>
|
||||
{reasonContent
|
||||
? `${t('home.reasonLabel')}${reasonContent}`
|
||||
: t('home.noReason')}
|
||||
? `${t("home.reasonLabel")}${reasonContent}`
|
||||
: t("home.noReason")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import * as XLSX from 'xlsx'
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import { Table, Tag, Button, Select, Space, Card, message, Modal } from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { EyeOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||
import * as XLSX from "xlsx"
|
||||
|
||||
interface studentRank {
|
||||
id: number
|
||||
@@ -16,11 +16,11 @@ export const Leaderboard: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<studentRank[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timeRange, setTimeRange] = useState('today')
|
||||
const [timeRange, setTimeRange] = useState("today")
|
||||
const [startTime, setStartTime] = useState<string | null>(null)
|
||||
const [historyVisible, setHistoryVisible] = useState(false)
|
||||
const [historyHeader, setHistoryHeader] = useState('')
|
||||
const [historyText, setHistoryText] = useState('')
|
||||
const [historyHeader, setHistoryHeader] = useState("")
|
||||
const [historyText, setHistoryText] = useState("")
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const fetchRankings = useCallback(async () => {
|
||||
@@ -33,7 +33,7 @@ export const Leaderboard: React.FC = () => {
|
||||
setData(res.data.rows)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch rankings:', e)
|
||||
console.error("Failed to fetch rankings:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -46,10 +46,10 @@ export const Leaderboard: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (category === 'events' || category === 'students' || category === 'all') fetchRankings()
|
||||
if (category === "events" || category === "students" || category === "all") fetchRankings()
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchRankings])
|
||||
|
||||
const handleViewHistory = async (studentName: string) => {
|
||||
@@ -57,10 +57,10 @@ export const Leaderboard: React.FC = () => {
|
||||
const res = await (window as any).api.queryEventsByStudent({
|
||||
student_name: studentName,
|
||||
limit: 200,
|
||||
startTime
|
||||
startTime,
|
||||
})
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || t('leaderboard.queryFailed'))
|
||||
messageApi.error(res.message || t("leaderboard.queryFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,156 +70,156 @@ export const Leaderboard: React.FC = () => {
|
||||
return `${time} ${delta} ${e.reason_content}`
|
||||
})
|
||||
|
||||
setHistoryHeader(t('leaderboard.historyTitle', { name: studentName }))
|
||||
setHistoryText(lines.join('\n') || t('common.noData'))
|
||||
setHistoryHeader(t("leaderboard.historyTitle", { name: studentName }))
|
||||
setHistoryText(lines.join("\n") || t("common.noData"))
|
||||
setHistoryVisible(true)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
setTimeout(() => {
|
||||
const title =
|
||||
timeRange === 'today'
|
||||
? t('leaderboard.today')
|
||||
: timeRange === 'week'
|
||||
? t('leaderboard.week')
|
||||
: t('leaderboard.month')
|
||||
timeRange === "today"
|
||||
? t("leaderboard.today")
|
||||
: timeRange === "week"
|
||||
? t("leaderboard.week")
|
||||
: t("leaderboard.month")
|
||||
|
||||
const sanitizeCell = (v: unknown) => {
|
||||
if (typeof v !== 'string') return v
|
||||
if (typeof v !== "string") return v
|
||||
if (/^[=+\-@]/.test(v)) return `'${v}`
|
||||
return v
|
||||
}
|
||||
|
||||
const sheetData = [
|
||||
[
|
||||
t('leaderboard.rank'),
|
||||
t('leaderboard.name'),
|
||||
t('leaderboard.totalScore'),
|
||||
`${title}${t('leaderboard.change')}`
|
||||
t("leaderboard.rank"),
|
||||
t("leaderboard.name"),
|
||||
t("leaderboard.totalScore"),
|
||||
`${title}${t("leaderboard.change")}`,
|
||||
],
|
||||
...data.map((item, index) => [
|
||||
index + 1,
|
||||
sanitizeCell(item.name),
|
||||
item.score,
|
||||
item.range_change
|
||||
])
|
||||
item.range_change,
|
||||
]),
|
||||
]
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
||||
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
ws["!cols"] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, t('leaderboard.title'))
|
||||
XLSX.utils.book_append_sheet(wb, ws, t("leaderboard.title"))
|
||||
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
|
||||
const xlsxBytes = XLSX.write(wb, { bookType: "xlsx", type: "array" })
|
||||
const blob = new Blob([xlsxBytes], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const link = document.createElement("a")
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute("href", url)
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`${t('leaderboard.title')}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
"download",
|
||||
`${t("leaderboard.title")}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
)
|
||||
link.style.visibility = 'hidden'
|
||||
link.style.visibility = "hidden"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
messageApi.success(t('leaderboard.exportSuccess'))
|
||||
messageApi.success(t("leaderboard.exportSuccess"))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<studentRank> = [
|
||||
{
|
||||
title: t('leaderboard.rank'),
|
||||
key: 'rank',
|
||||
title: t("leaderboard.rank"),
|
||||
key: "rank",
|
||||
width: 70,
|
||||
align: 'center',
|
||||
align: "center",
|
||||
render: (_, __, index) => {
|
||||
const rank = index + 1
|
||||
let color = 'inherit'
|
||||
if (rank === 1) color = '#FFD700'
|
||||
if (rank === 2) color = '#C0C0C0'
|
||||
if (rank === 3) color = '#CD7F32'
|
||||
let color = "inherit"
|
||||
if (rank === 1) color = "#FFD700"
|
||||
if (rank === 2) color = "#C0C0C0"
|
||||
if (rank === 3) color = "#CD7F32"
|
||||
return (
|
||||
<span style={{ fontWeight: 'bold', color, fontSize: rank <= 3 ? '18px' : '14px' }}>
|
||||
<span style={{ fontWeight: "bold", color, fontSize: rank <= 3 ? "18px" : "14px" }}>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{ title: t('leaderboard.name'), dataIndex: 'name', key: 'name', width: 120, align: 'center' },
|
||||
{ title: t("leaderboard.name"), dataIndex: "name", key: "name", width: 120, align: "center" },
|
||||
{
|
||||
title: t('leaderboard.totalScore'),
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
title: t("leaderboard.totalScore"),
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
|
||||
align: "center",
|
||||
render: (score: number) => <span style={{ fontWeight: "bold" }}>{score}</span>,
|
||||
},
|
||||
{
|
||||
title:
|
||||
timeRange === 'today'
|
||||
? t('leaderboard.todayChange')
|
||||
: timeRange === 'week'
|
||||
? t('leaderboard.weekChange')
|
||||
: t('leaderboard.monthChange'),
|
||||
dataIndex: 'range_change',
|
||||
key: 'range_change',
|
||||
timeRange === "today"
|
||||
? t("leaderboard.todayChange")
|
||||
: timeRange === "week"
|
||||
? t("leaderboard.weekChange")
|
||||
: t("leaderboard.monthChange"),
|
||||
dataIndex: "range_change",
|
||||
key: "range_change",
|
||||
width: 100,
|
||||
align: 'center',
|
||||
align: "center",
|
||||
render: (change: number) => (
|
||||
<Tag color={change > 0 ? 'success' : change < 0 ? 'error' : 'default'}>
|
||||
<Tag color={change > 0 ? "success" : change < 0 ? "error" : "default"}>
|
||||
{change > 0 ? `+${change}` : change}
|
||||
</Tag>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('leaderboard.operationRecord'),
|
||||
key: 'operation',
|
||||
title: t("leaderboard.operationRecord"),
|
||||
key: "operation",
|
||||
width: 100,
|
||||
align: 'center',
|
||||
align: "center",
|
||||
render: (_, row) => (
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
|
||||
{t('leaderboard.viewHistory')}
|
||||
{t("leaderboard.viewHistory")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
marginBottom: "24px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('leaderboard.title')}</h2>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("leaderboard.title")}</h2>
|
||||
<Space>
|
||||
<Select
|
||||
value={timeRange}
|
||||
onChange={(v) => setTimeRange(v)}
|
||||
style={{ width: '120px' }}
|
||||
style={{ width: "120px" }}
|
||||
options={[
|
||||
{ value: 'today', label: t('leaderboard.today') },
|
||||
{ value: 'week', label: t('leaderboard.week') },
|
||||
{ value: 'month', label: t('leaderboard.month') }
|
||||
{ value: "today", label: t("leaderboard.today") },
|
||||
{ value: "week", label: t("leaderboard.week") },
|
||||
{ value: "month", label: t("leaderboard.month") },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{t('leaderboard.exportXlsx')}
|
||||
{t("leaderboard.exportXlsx")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
@@ -227,7 +227,7 @@ export const Leaderboard: React.FC = () => {
|
||||
loading={loading}
|
||||
bordered
|
||||
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
style={{ color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -235,20 +235,20 @@ export const Leaderboard: React.FC = () => {
|
||||
title={historyHeader}
|
||||
open={historyVisible}
|
||||
onCancel={() => setHistoryVisible(false)}
|
||||
footer={<Button onClick={() => setHistoryVisible(false)}>{t('common.close')}</Button>}
|
||||
footer={<Button onClick={() => setHistoryVisible(false)}>{t("common.close")}</Button>}
|
||||
width="80%"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
fontSize: '12px',
|
||||
maxHeight: "420px",
|
||||
overflowY: "auto",
|
||||
fontSize: "12px",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: '10px'
|
||||
whiteSpace: "pre-wrap",
|
||||
backgroundColor: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
padding: "10px",
|
||||
}}
|
||||
>
|
||||
{historyText}
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Segmented, Input, Tag, message, Typography, InputNumber, Space } from 'antd'
|
||||
import { PlusOutlined, UploadOutlined, FileExcelOutlined } from '@ant-design/icons'
|
||||
import { OOBEBackground } from './OOBEBackground'
|
||||
import { useTheme } from '../../contexts/ThemeContext'
|
||||
import { changeLanguage, AppLanguage, languageOptions } from '../../i18n'
|
||||
import type { themeConfig } from '../../../../preload/types'
|
||||
import logoSvg from '../../assets/logoHD.svg'
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button, Segmented, Input, Tag, message, Typography, InputNumber, Space } from "antd"
|
||||
import { PlusOutlined, UploadOutlined, FileExcelOutlined } from "@ant-design/icons"
|
||||
import { OOBEBackground } from "./OOBEBackground"
|
||||
import { useTheme } from "../../contexts/ThemeContext"
|
||||
import { changeLanguage, AppLanguage, languageOptions } from "../../i18n"
|
||||
import type { themeConfig } from "../../preload/types"
|
||||
import logoSvg from "../../assets/logoHD.svg"
|
||||
|
||||
interface oobeProps {
|
||||
visible: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
type oobeStep = 'language' | 'theme' | 'password' | 'students' | 'reasons' | 'start'
|
||||
type oobeStep = "language" | "theme" | "password" | "students" | "reasons" | "start"
|
||||
|
||||
interface studentItem {
|
||||
name: string
|
||||
@@ -27,15 +27,15 @@ interface reasonItem {
|
||||
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
|
||||
|
||||
const presetPrimaryColors = [
|
||||
'#1677FF',
|
||||
'#2F54EB',
|
||||
'#722ED1',
|
||||
'#EB2F96',
|
||||
'#F5222D',
|
||||
'#FA8C16',
|
||||
'#FADB14',
|
||||
'#52C41A',
|
||||
'#13C2C2'
|
||||
"#1677FF",
|
||||
"#2F54EB",
|
||||
"#722ED1",
|
||||
"#EB2F96",
|
||||
"#F5222D",
|
||||
"#FA8C16",
|
||||
"#FADB14",
|
||||
"#52C41A",
|
||||
"#13C2C2",
|
||||
]
|
||||
|
||||
const presetGradients: {
|
||||
@@ -45,29 +45,29 @@ const presetGradients: {
|
||||
dark: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'blue',
|
||||
labelKey: 'blue',
|
||||
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
||||
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
|
||||
id: "blue",
|
||||
labelKey: "blue",
|
||||
light: "linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)",
|
||||
dark: "linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)",
|
||||
},
|
||||
{
|
||||
id: 'pink',
|
||||
labelKey: 'pink',
|
||||
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
||||
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
|
||||
id: "pink",
|
||||
labelKey: "pink",
|
||||
light: "linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)",
|
||||
dark: "linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)",
|
||||
},
|
||||
{
|
||||
id: 'cyan',
|
||||
labelKey: 'cyan',
|
||||
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
|
||||
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
|
||||
id: "cyan",
|
||||
labelKey: "cyan",
|
||||
light: "linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)",
|
||||
dark: "linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)",
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
labelKey: 'purple',
|
||||
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)',
|
||||
dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)'
|
||||
}
|
||||
id: "purple",
|
||||
labelKey: "purple",
|
||||
light: "linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)",
|
||||
dark: "linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)",
|
||||
},
|
||||
]
|
||||
|
||||
export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
@@ -75,38 +75,38 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<oobeStep>('language')
|
||||
const [currentStep, setCurrentStep] = useState<oobeStep>("language")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<AppLanguage>('zh-CN')
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<AppLanguage>("zh-CN")
|
||||
const [workingTheme, setWorkingTheme] = useState<themeConfig | null>(null)
|
||||
const [primaryInput, setPrimaryInput] = useState('')
|
||||
const [primaryInput, setPrimaryInput] = useState("")
|
||||
const [students, setStudents] = useState<studentItem[]>([])
|
||||
const [newStudentName, setNewStudentName] = useState('')
|
||||
const [newStudentName, setNewStudentName] = useState("")
|
||||
const [reasons, setReasons] = useState<reasonItem[]>([])
|
||||
const [newReasonContent, setNewReasonContent] = useState('')
|
||||
const [newReasonContent, setNewReasonContent] = useState("")
|
||||
const [newReasonDelta, setNewReasonDelta] = useState(1)
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [pointsPassword, setPointsPassword] = useState('')
|
||||
const [adminPassword, setAdminPassword] = useState("")
|
||||
const [pointsPassword, setPointsPassword] = useState("")
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const steps: oobeStep[] = ['language', 'theme', 'password', 'students', 'reasons', 'start']
|
||||
const steps: oobeStep[] = ["language", "theme", "password", "students", "reasons", "start"]
|
||||
const stepIndex = steps.indexOf(currentStep) + 1
|
||||
const totalSteps = steps.length
|
||||
|
||||
const primaryColor = workingTheme?.config?.tdesign?.brandColor || '#1677FF'
|
||||
const isDark = workingTheme?.mode === 'dark'
|
||||
const primaryColor = workingTheme?.config?.tdesign?.brandColor || "#1677FF"
|
||||
const isDark = workingTheme?.mode === "dark"
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTheme) return
|
||||
const base = deepClone(currentTheme)
|
||||
const editable =
|
||||
base.id === 'custom-default' || base.id.startsWith('custom-')
|
||||
base.id === "custom-default" || base.id.startsWith("custom-")
|
||||
? base
|
||||
: { ...base, id: 'custom-default', name: t('theme.myTheme') }
|
||||
: { ...base, id: "custom-default", name: t("theme.myTheme") }
|
||||
setWorkingTheme(editable)
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
|
||||
}, [currentTheme])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,7 +134,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
|
||||
const setPrimary = async (hex: string) => {
|
||||
setPrimaryInput(hex)
|
||||
setWorkingTheme((prev) => {
|
||||
setWorkingTheme((prev: themeConfig | null) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
@@ -145,18 +145,18 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
}
|
||||
|
||||
const setGradientPreset = async (value: string) => {
|
||||
setWorkingTheme((prev) => {
|
||||
setWorkingTheme((prev: themeConfig | null) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': value }
|
||||
next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": value }
|
||||
saveThemeToDb(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setMode = async (mode: 'light' | 'dark') => {
|
||||
const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
|
||||
const setMode = async (mode: "light" | "dark") => {
|
||||
const targetThemeId = mode === "light" ? "light-default" : "dark-default"
|
||||
await setTheme(targetThemeId)
|
||||
}
|
||||
|
||||
@@ -169,11 +169,11 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
const name = newStudentName.trim()
|
||||
if (!name) return
|
||||
if (students.some((s) => s.name === name)) {
|
||||
messageApi.warning(t('oobe.steps.students.studentExists'))
|
||||
messageApi.warning(t("oobe.steps.students.studentExists"))
|
||||
return
|
||||
}
|
||||
setStudents([...students, { name }])
|
||||
setNewStudentName('')
|
||||
setNewStudentName("")
|
||||
}
|
||||
|
||||
const removeStudent = (name: string) => {
|
||||
@@ -184,11 +184,11 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
const content = newReasonContent.trim()
|
||||
if (!content) return
|
||||
if (reasons.some((r) => r.content === content)) {
|
||||
messageApi.warning(t('oobe.steps.reasons.reasonExists'))
|
||||
messageApi.warning(t("oobe.steps.reasons.reasonExists"))
|
||||
return
|
||||
}
|
||||
setReasons([...reasons, { content, delta: newReasonDelta }])
|
||||
setNewReasonContent('')
|
||||
setNewReasonContent("")
|
||||
setNewReasonDelta(1)
|
||||
}
|
||||
|
||||
@@ -198,48 +198,48 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
|
||||
const handleFileImport = useCallback(
|
||||
async (file: File) => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'json') {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (ext === "json") {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
let studentList: string[] = []
|
||||
if (Array.isArray(data)) {
|
||||
studentList = data.map((item: any) =>
|
||||
typeof item === 'string' ? item : item.name || item.student_name
|
||||
typeof item === "string" ? item : item.name || item.student_name
|
||||
)
|
||||
} else if (data.students && Array.isArray(data.students)) {
|
||||
studentList = data.students.map((item: any) =>
|
||||
typeof item === 'string' ? item : item.name || item.student_name
|
||||
typeof item === "string" ? item : item.name || item.student_name
|
||||
)
|
||||
}
|
||||
const newStudents = studentList
|
||||
.filter((name) => name && typeof name === 'string')
|
||||
.filter((name) => name && typeof name === "string")
|
||||
.filter((name) => !students.some((s) => s.name === name))
|
||||
.map((name) => ({ name: name.trim() }))
|
||||
if (newStudents.length > 0) {
|
||||
setStudents([...students, ...newStudents])
|
||||
messageApi.success(
|
||||
t('oobe.steps.students.importSuccess', { count: newStudents.length })
|
||||
t("oobe.steps.students.importSuccess", { count: newStudents.length })
|
||||
)
|
||||
} else {
|
||||
messageApi.info(t('oobe.steps.students.noNewStudents'))
|
||||
messageApi.info(t("oobe.steps.students.noNewStudents"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('oobe.steps.students.parseFailed'))
|
||||
messageApi.error(t("oobe.steps.students.parseFailed"))
|
||||
}
|
||||
} else if (ext === 'xlsx') {
|
||||
} else if (ext === "xlsx") {
|
||||
try {
|
||||
const { read, utils } = await import('xlsx')
|
||||
const { read, utils } = await import("xlsx")
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = read(arrayBuffer, { type: 'array' })
|
||||
const workbook = read(arrayBuffer, { type: "array" })
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
|
||||
const jsonData = utils.sheet_to_json(firstSheet, { header: 1 }) as string[][]
|
||||
const names: string[] = []
|
||||
jsonData.forEach((row, idx) => {
|
||||
if (idx === 0) return
|
||||
const name = row[0]
|
||||
if (name && typeof name === 'string') {
|
||||
if (name && typeof name === "string") {
|
||||
names.push(name.trim())
|
||||
}
|
||||
})
|
||||
@@ -249,16 +249,16 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
if (newStudents.length > 0) {
|
||||
setStudents([...students, ...newStudents])
|
||||
messageApi.success(
|
||||
t('oobe.steps.students.importSuccess', { count: newStudents.length })
|
||||
t("oobe.steps.students.importSuccess", { count: newStudents.length })
|
||||
)
|
||||
} else {
|
||||
messageApi.info(t('oobe.steps.students.noNewStudents'))
|
||||
messageApi.info(t("oobe.steps.students.noNewStudents"))
|
||||
}
|
||||
} catch {
|
||||
messageApi.error(t('oobe.steps.students.parseFailed'))
|
||||
messageApi.error(t("oobe.steps.students.parseFailed"))
|
||||
}
|
||||
} else {
|
||||
messageApi.error(t('oobe.steps.students.unsupportedFormat'))
|
||||
messageApi.error(t("oobe.steps.students.unsupportedFormat"))
|
||||
}
|
||||
},
|
||||
[students, messageApi]
|
||||
@@ -294,7 +294,7 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
const handleFinish = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!(window as any).api) throw new Error('api not ready')
|
||||
if (!(window as any).api) throw new Error("api not ready")
|
||||
|
||||
if (workingTheme) {
|
||||
const exists = themes.some((t) => t.id === workingTheme.id)
|
||||
@@ -313,24 +313,24 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
for (const reason of reasons) {
|
||||
await (window as any).api.createReason({
|
||||
content: reason.content,
|
||||
delta: reason.delta
|
||||
delta: reason.delta,
|
||||
})
|
||||
}
|
||||
|
||||
if (adminPassword || pointsPassword) {
|
||||
await (window as any).api.authSetPasswords({
|
||||
adminPassword: adminPassword || null,
|
||||
pointsPassword: pointsPassword || null
|
||||
pointsPassword: pointsPassword || null,
|
||||
})
|
||||
}
|
||||
|
||||
const res = await (window as any).api.setSetting('is_wizard_completed', true)
|
||||
if (!res?.success) throw new Error(res?.message || 'failed')
|
||||
const res = await (window as any).api.setSetting("is_wizard_completed", true)
|
||||
if (!res?.success) throw new Error(res?.message || "failed")
|
||||
|
||||
messageApi.success(t('common.success'))
|
||||
messageApi.success(t("common.success"))
|
||||
onComplete()
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || t('common.error'))
|
||||
messageApi.error(e?.message || t("common.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -338,53 +338,53 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const currentBg = workingTheme?.config?.custom?.['--ss-bg-color'] || ''
|
||||
const currentBg = workingTheme?.config?.custom?.["--ss-bg-color"] || ""
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 'language':
|
||||
case "language":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.language.description')}
|
||||
{t("oobe.steps.language.description")}
|
||||
</Typography.Text>
|
||||
<Segmented
|
||||
value={selectedLanguage}
|
||||
onChange={(v) => handleLanguageChange(v as AppLanguage)}
|
||||
options={languageOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label
|
||||
label: opt.label,
|
||||
}))}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'theme':
|
||||
case "theme":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Typography.Text type="secondary">{t('oobe.steps.theme.description')}</Typography.Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text strong>{t('oobe.steps.theme.mode')}</Typography.Text>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Typography.Text type="secondary">{t("oobe.steps.theme.description")}</Typography.Text>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text strong>{t("oobe.steps.theme.mode")}</Typography.Text>
|
||||
<Segmented
|
||||
value={workingTheme?.mode || 'light'}
|
||||
value={workingTheme?.mode || "light"}
|
||||
onChange={(v) => setMode(v as any)}
|
||||
options={[
|
||||
{ label: t('oobe.steps.theme.lightMode'), value: 'light' },
|
||||
{ label: t('oobe.steps.theme.darkMode'), value: 'dark' }
|
||||
{ label: t("oobe.steps.theme.lightMode"), value: "light" },
|
||||
{ label: t("oobe.steps.theme.darkMode"), value: "dark" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text strong>{t('oobe.steps.theme.primaryColor')}</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text strong>{t("oobe.steps.theme.primaryColor")}</Typography.Text>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Input
|
||||
value={primaryInput}
|
||||
onChange={(e) => setPrimaryInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
const hex = primaryInput.trim()
|
||||
if (/^#?[0-9a-fA-F]{6}$/.test(hex.replace('#', ''))) {
|
||||
setPrimary(hex.startsWith('#') ? hex : `#${hex}`)
|
||||
if (/^#?[0-9a-fA-F]{6}$/.test(hex.replace("#", ""))) {
|
||||
setPrimary(hex.startsWith("#") ? hex : `#${hex}`)
|
||||
}
|
||||
}}
|
||||
style={{ width: 100 }}
|
||||
@@ -397,26 +397,26 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
borderRadius: "50%",
|
||||
border:
|
||||
primaryInput === c
|
||||
? '2px solid var(--ss-primary-color)'
|
||||
? "2px solid var(--ss-primary-color)"
|
||||
: isDark
|
||||
? '1px solid rgba(255,255,255,0.3)'
|
||||
: '1px solid rgba(0,0,0,0.2)',
|
||||
? "1px solid rgba(255,255,255,0.3)"
|
||||
: "1px solid rgba(0,0,0,0.2)",
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
padding: 0
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text strong>{t('oobe.steps.theme.backgroundGradient')}</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text strong>{t("oobe.steps.theme.backgroundGradient")}</Typography.Text>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{presetGradients.map((g) => {
|
||||
const gradientValue = g[workingTheme?.mode || 'light']
|
||||
const gradientValue = g[workingTheme?.mode || "light"]
|
||||
const active = currentBg === gradientValue
|
||||
return (
|
||||
<button
|
||||
@@ -428,13 +428,13 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
border: active
|
||||
? '2px solid var(--ss-primary-color)'
|
||||
? "2px solid var(--ss-primary-color)"
|
||||
: isDark
|
||||
? '1px solid rgba(255,255,255,0.2)'
|
||||
: '1px solid rgba(0,0,0,0.1)',
|
||||
? "1px solid rgba(255,255,255,0.2)"
|
||||
: "1px solid rgba(0,0,0,0.1)",
|
||||
background: gradientValue,
|
||||
cursor: 'pointer',
|
||||
padding: 0
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
title={t(`theme.gradientLabels.${g.labelKey}`)}
|
||||
/>
|
||||
@@ -445,71 +445,71 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'password':
|
||||
case "password":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.password.description')}
|
||||
{t("oobe.steps.password.description")}
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Typography.Text strong>{t('oobe.steps.password.adminPassword')}</Typography.Text>
|
||||
<Typography.Text strong>{t("oobe.steps.password.adminPassword")}</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ display: 'block', fontSize: 12, marginTop: 4 }}
|
||||
style={{ display: "block", fontSize: 12, marginTop: 4 }}
|
||||
>
|
||||
{t('oobe.steps.password.adminPasswordHint')}
|
||||
{t("oobe.steps.password.adminPasswordHint")}
|
||||
</Typography.Text>
|
||||
<Input.Password
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
placeholder={t('oobe.steps.password.passwordPlaceholder')}
|
||||
placeholder={t("oobe.steps.password.passwordPlaceholder")}
|
||||
maxLength={6}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{t('oobe.steps.password.pointsPassword')}</Typography.Text>
|
||||
<Typography.Text strong>{t("oobe.steps.password.pointsPassword")}</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ display: 'block', fontSize: 12, marginTop: 4 }}
|
||||
style={{ display: "block", fontSize: 12, marginTop: 4 }}
|
||||
>
|
||||
{t('oobe.steps.password.pointsPasswordHint')}
|
||||
{t("oobe.steps.password.pointsPasswordHint")}
|
||||
</Typography.Text>
|
||||
<Input.Password
|
||||
value={pointsPassword}
|
||||
onChange={(e) => setPointsPassword(e.target.value)}
|
||||
placeholder={t('oobe.steps.password.passwordPlaceholder')}
|
||||
placeholder={t("oobe.steps.password.passwordPlaceholder")}
|
||||
maxLength={6}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('oobe.steps.password.hint')}
|
||||
{t("oobe.steps.password.hint")}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'students':
|
||||
case "students":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.students.description')}
|
||||
{t("oobe.steps.students.description")}
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button icon={<UploadOutlined />} onClick={() => fileInputRef.current?.click()}>
|
||||
{t('oobe.steps.students.import')}
|
||||
{t("oobe.steps.students.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.json"
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileImport(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -517,42 +517,42 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
style={{
|
||||
border: isDark ? '1px dashed rgba(255,255,255,0.3)' : '1px dashed rgba(0,0,0,0.2)',
|
||||
border: isDark ? "1px dashed rgba(255,255,255,0.3)" : "1px dashed rgba(0,0,0,0.2)",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
color: isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.45)'
|
||||
textAlign: "center",
|
||||
color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)",
|
||||
}}
|
||||
>
|
||||
<FileExcelOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<div>{t('oobe.steps.students.dragDrop')}</div>
|
||||
<div>{t("oobe.steps.students.dragDrop")}</div>
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
{t('oobe.steps.students.supportedFormats')}
|
||||
{t("oobe.steps.students.supportedFormats")}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Input
|
||||
value={newStudentName}
|
||||
onChange={(e) => setNewStudentName(e.target.value)}
|
||||
placeholder={t('oobe.steps.students.studentName')}
|
||||
placeholder={t("oobe.steps.students.studentName")}
|
||||
onPressEnter={addStudent}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addStudent}>
|
||||
{t('oobe.steps.students.addStudent')}
|
||||
{t("oobe.steps.students.addStudent")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{students.length === 0 ? (
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.students.noStudents')}
|
||||
{t("oobe.steps.students.noStudents")}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
students.map((s) => (
|
||||
@@ -569,23 +569,23 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
</div>
|
||||
{students.length > 0 && (
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.students.studentCount', { count: students.length })}
|
||||
{t("oobe.steps.students.studentCount", { count: students.length })}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reasons':
|
||||
case "reasons":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.reasons.description')}
|
||||
{t("oobe.steps.reasons.description")}
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Input
|
||||
value={newReasonContent}
|
||||
onChange={(e) => setNewReasonContent(e.target.value)}
|
||||
placeholder={t('oobe.steps.reasons.reasonName')}
|
||||
placeholder={t("oobe.steps.reasons.reasonName")}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<InputNumber
|
||||
@@ -596,21 +596,21 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addReason}>
|
||||
{t('oobe.steps.reasons.addReason')}
|
||||
{t("oobe.steps.reasons.addReason")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{reasons.length === 0 ? (
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.reasons.noReasons')}
|
||||
{t("oobe.steps.reasons.noReasons")}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
reasons.map((r) => (
|
||||
@@ -618,10 +618,10 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
key={r.content}
|
||||
closable
|
||||
onClose={() => removeReason(r.content)}
|
||||
color={r.delta > 0 ? 'success' : 'error'}
|
||||
color={r.delta > 0 ? "success" : "error"}
|
||||
style={{ margin: 2 }}
|
||||
>
|
||||
{r.content} ({r.delta > 0 ? '+' : ''}
|
||||
{r.content} ({r.delta > 0 ? "+" : ""}
|
||||
{r.delta})
|
||||
</Tag>
|
||||
))
|
||||
@@ -629,62 +629,62 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
</div>
|
||||
{reasons.length > 0 && (
|
||||
<Typography.Text type="secondary">
|
||||
{t('oobe.steps.reasons.reasonCount', { count: reasons.length })}
|
||||
{t("oobe.steps.reasons.reasonCount", { count: reasons.length })}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'start':
|
||||
case "start":
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary" style={{ textAlign: 'center' }}>
|
||||
{t('oobe.steps.start.description')}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24, alignItems: "center" }}>
|
||||
<Typography.Text type="secondary" style={{ textAlign: "center" }}>
|
||||
{t("oobe.steps.start.description")}
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||
borderRadius: 8
|
||||
background: isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.03)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#52c41a' }}>✓</span>
|
||||
<span style={{ color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}>
|
||||
{t('oobe.steps.start.features.score')}
|
||||
<span style={{ color: "#52c41a" }}>✓</span>
|
||||
<span style={{ color: isDark ? "#fff" : "rgba(0, 0, 0, 0.88)" }}>
|
||||
{t("oobe.steps.start.features.score")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||
borderRadius: 8
|
||||
background: isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.03)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#52c41a' }}>✓</span>
|
||||
<span style={{ color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}>
|
||||
{t('oobe.steps.start.features.history')}
|
||||
<span style={{ color: "#52c41a" }}>✓</span>
|
||||
<span style={{ color: isDark ? "#fff" : "rgba(0, 0, 0, 0.88)" }}>
|
||||
{t("oobe.steps.start.features.history")}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||
borderRadius: 8
|
||||
background: isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.03)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#52c41a' }}>✓</span>
|
||||
<span style={{ color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}>
|
||||
{t('oobe.steps.start.features.settlement')}
|
||||
<span style={{ color: "#52c41a" }}>✓</span>
|
||||
<span style={{ color: isDark ? "#fff" : "rgba(0, 0, 0, 0.88)" }}>
|
||||
{t("oobe.steps.start.features.settlement")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,33 +696,33 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<OOBEBackground primaryColor={primaryColor} mode={workingTheme?.mode || 'light'} />
|
||||
<OOBEBackground primaryColor={primaryColor} mode={workingTheme?.mode || "light"} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
background: isDark ? 'rgba(15, 25, 45, 0.55)' : 'rgba(255, 255, 255, 0.65)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
background: isDark ? "rgba(15, 25, 45, 0.55)" : "rgba(255, 255, 255, 0.65)",
|
||||
backdropFilter: "blur(20px)",
|
||||
borderRadius: 16,
|
||||
padding: 32,
|
||||
width: 480,
|
||||
maxWidth: '90vw',
|
||||
boxShadow: isDark ? '0 8px 32px rgba(0, 0, 0, 0.3)' : '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.08)' : '1px solid rgba(0, 0, 0, 0.06)',
|
||||
maxWidth: "90vw",
|
||||
boxShadow: isDark ? "0 8px 32px rgba(0, 0, 0, 0.3)" : "0 8px 32px rgba(0, 0, 0, 0.1)",
|
||||
border: isDark ? "1px solid rgba(255, 255, 255, 0.08)" : "1px solid rgba(0, 0, 0, 0.06)",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"'
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
@@ -730,19 +730,19 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}
|
||||
style={{ margin: 0, color: isDark ? "#fff" : "rgba(0, 0, 0, 0.88)" }}
|
||||
>
|
||||
{t('oobe.title')}
|
||||
{t("oobe.title")}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)' }}>
|
||||
{t('oobe.subtitle')}
|
||||
<Typography.Text style={{ color: isDark ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)" }}>
|
||||
{t("oobe.subtitle")}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title
|
||||
level={4}
|
||||
style={{ margin: 0, color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}
|
||||
style={{ margin: 0, color: isDark ? "#fff" : "rgba(0, 0, 0, 0.88)" }}
|
||||
>
|
||||
{t(`oobe.steps.${currentStep}.title`)}
|
||||
</Typography.Title>
|
||||
@@ -752,50 +752,50 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{currentStep !== 'language' && <Button onClick={handlePrev}>{t('common.prev')}</Button>}
|
||||
{currentStep !== 'start' && <Button onClick={handleSkip}>{t('oobe.skip')}</Button>}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{currentStep !== "language" && <Button onClick={handlePrev}>{t("common.prev")}</Button>}
|
||||
{currentStep !== "start" && <Button onClick={handleSkip}>{t("oobe.skip")}</Button>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.45)',
|
||||
fontSize: 12
|
||||
color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 4,
|
||||
background: isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)',
|
||||
background: isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.1)",
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(stepIndex / totalSteps) * 100}%`,
|
||||
height: '100%',
|
||||
height: "100%",
|
||||
background: primaryColor,
|
||||
transition: 'width 0.3s'
|
||||
transition: "width 0.3s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span>{t('oobe.step', { current: stepIndex, total: totalSteps })}</span>
|
||||
<span>{t("oobe.step", { current: stepIndex, total: totalSteps })}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{currentStep !== 'start' ? (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{currentStep !== "start" ? (
|
||||
<Button type="primary" onClick={handleNext}>
|
||||
{t('common.next')}
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -803,9 +803,9 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
loading={loading}
|
||||
onClick={handleFinish}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -814,10 +814,10 @@ export const OOBE: React.FC<oobeProps> = ({ visible, onComplete }) => {
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
filter: isDark ? 'brightness(0) invert(1)' : 'none'
|
||||
filter: isDark ? "brightness(0) invert(1)" : "none",
|
||||
}}
|
||||
/>
|
||||
{t('oobe.steps.start.startButton')}
|
||||
{t("oobe.steps.start.startButton")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from "react"
|
||||
|
||||
interface OOBEBackgroundProps {
|
||||
primaryColor: string
|
||||
mode: 'light' | 'dark'
|
||||
mode: "light" | "dark"
|
||||
}
|
||||
|
||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
const h = hex.replace('#', '')
|
||||
const h = hex.replace("#", "")
|
||||
return {
|
||||
r: parseInt(h.substring(0, 2), 16),
|
||||
g: parseInt(h.substring(2, 4), 16),
|
||||
b: parseInt(h.substring(4, 6), 16)
|
||||
b: parseInt(h.substring(4, 6), 16),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const resize = () => {
|
||||
@@ -30,14 +30,14 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const rgb = hexToRgb(primaryColor)
|
||||
|
||||
const isDark = mode === 'dark'
|
||||
const isDark = mode === "dark"
|
||||
|
||||
const bgColor = isDark ? '#0a1628' : '#e8f4fc'
|
||||
const transparentColor = isDark ? 'rgba(10, 22, 40, 0)' : 'rgba(232, 244, 252, 0)'
|
||||
const bgColor = isDark ? "#0a1628" : "#e8f4fc"
|
||||
const transparentColor = isDark ? "rgba(10, 22, 40, 0)" : "rgba(232, 244, 252, 0)"
|
||||
|
||||
const blobs: {
|
||||
x: number
|
||||
@@ -55,14 +55,14 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
`rgba(${Math.min(255, rgb.r + 20)}, ${Math.min(255, rgb.g + 30)}, ${Math.min(255, rgb.b + 40)}, 0.30)`,
|
||||
`rgba(${Math.max(0, rgb.r - 10)}, ${Math.max(0, rgb.g - 20)}, ${Math.max(0, rgb.b - 30)}, 0.30)`,
|
||||
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.25)`,
|
||||
`rgba(${Math.min(255, rgb.r + 40)}, ${Math.min(255, rgb.g + 50)}, ${rgb.b}, 0.28)`
|
||||
`rgba(${Math.min(255, rgb.r + 40)}, ${Math.min(255, rgb.g + 50)}, ${rgb.b}, 0.28)`,
|
||||
]
|
||||
: [
|
||||
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)`,
|
||||
`rgba(${Math.min(255, rgb.r + 30)}, ${Math.min(255, rgb.g + 40)}, ${Math.min(255, rgb.b + 50)}, 0.15)`,
|
||||
`rgba(${Math.max(0, rgb.r - 20)}, ${Math.max(0, rgb.g - 30)}, ${Math.max(0, rgb.b - 40)}, 0.12)`,
|
||||
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`,
|
||||
`rgba(${Math.min(255, rgb.r + 50)}, ${Math.min(255, rgb.g + 60)}, ${rgb.b}, 0.14)`
|
||||
`rgba(${Math.min(255, rgb.r + 50)}, ${Math.min(255, rgb.g + 60)}, ${rgb.b}, 0.14)`,
|
||||
]
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@@ -73,7 +73,7 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
color: baseColors[i % baseColors.length],
|
||||
phase: Math.random() * Math.PI * 2
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener("resize", resize)
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}, [primaryColor, mode])
|
||||
@@ -129,13 +129,13 @@ export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mo
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 0,
|
||||
filter: 'blur(40px)'
|
||||
filter: "blur(40px)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface reason {
|
||||
id: number
|
||||
@@ -19,8 +19,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [form] = Form.useForm()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const emitDataUpdated = (category: 'reasons' | 'all') => {
|
||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||
const emitDataUpdated = (category: "reasons" | "all") => {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||
}
|
||||
|
||||
const fetchReasons = useCallback(async () => {
|
||||
@@ -32,7 +32,7 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setData(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch reasons:', e)
|
||||
console.error("Failed to fetch reasons:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -42,24 +42,24 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fetchReasons()
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (category === 'reasons' || category === 'all') fetchReasons()
|
||||
if (category === "reasons" || category === "all") fetchReasons()
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchReasons])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
const values = await form.validateFields()
|
||||
const content = values.content?.trim()
|
||||
const category = values.category?.trim() || t('reasons.others')
|
||||
const category = values.category?.trim() || t("reasons.others")
|
||||
|
||||
if (data.some((r) => r.content === content && r.category === category)) {
|
||||
messageApi.warning(t('reasons.reasonExists'))
|
||||
messageApi.warning(t("reasons.reasonExists"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,85 +67,85 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
...values,
|
||||
content,
|
||||
category,
|
||||
delta: Number(values.delta)
|
||||
delta: Number(values.delta),
|
||||
})
|
||||
if (res.success) {
|
||||
messageApi.success(t('reasons.addSuccess'))
|
||||
messageApi.success(t("reasons.addSuccess"))
|
||||
setVisible(false)
|
||||
form.resetFields()
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
emitDataUpdated("reasons")
|
||||
} else {
|
||||
messageApi.error(res.message || t('reasons.addFailed'))
|
||||
messageApi.error(res.message || t("reasons.addFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteReason(id)
|
||||
if (res.success) {
|
||||
messageApi.success(t('reasons.deleteSuccess'))
|
||||
messageApi.success(t("reasons.deleteSuccess"))
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
emitDataUpdated("reasons")
|
||||
} else {
|
||||
messageApi.error(res.message || t('reasons.deleteFailed'))
|
||||
messageApi.error(res.message || t("reasons.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<reason> = [
|
||||
{
|
||||
title: t('reasons.category'),
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
title: t("reasons.category"),
|
||||
dataIndex: "category",
|
||||
key: "category",
|
||||
width: 120,
|
||||
render: (category: string) => <Tag>{category}</Tag>
|
||||
render: (category: string) => <Tag>{category}</Tag>,
|
||||
},
|
||||
{ title: t('reasons.content'), dataIndex: 'content', key: 'content', width: 250 },
|
||||
{ title: t("reasons.content"), dataIndex: "content", key: "content", width: 250 },
|
||||
{
|
||||
title: t('reasons.presetPoints'),
|
||||
dataIndex: 'delta',
|
||||
key: 'delta',
|
||||
title: t("reasons.presetPoints"),
|
||||
dataIndex: "delta",
|
||||
key: "delta",
|
||||
width: 100,
|
||||
render: (delta: number) => (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
delta > 0 ? 'var(--ant-color-success, #52c41a)' : 'var(--ant-color-error, #ff4d4f)'
|
||||
delta > 0 ? "var(--ant-color-success, #52c41a)" : "var(--ant-color-error, #ff4d4f)",
|
||||
}}
|
||||
>
|
||||
{delta > 0 ? `+${delta}` : delta}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title={t('reasons.deleteConfirm')}
|
||||
title={t("reasons.deleteConfirm")}
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<Button type="link" danger disabled={!canEdit}>
|
||||
{t('common.delete')}
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('reasons.title')}</h2>
|
||||
<div style={{ marginBottom: "16px", display: "flex", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("reasons.title")}</h2>
|
||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||
{t('reasons.addReason')}
|
||||
{t("reasons.addReason")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -156,39 +156,39 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
loading={loading}
|
||||
bordered
|
||||
pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('reasons.addTitle')}
|
||||
title={t("reasons.addTitle")}
|
||||
open={visible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setVisible(false)}
|
||||
okText={t('reasons.addConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("reasons.addConfirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
|
||||
<Form.Item
|
||||
label={t('reasons.category')}
|
||||
label={t("reasons.category")}
|
||||
name="category"
|
||||
initialValue={t('reasons.others')}
|
||||
initialValue={t("reasons.others")}
|
||||
>
|
||||
<Input placeholder={t('reasons.categoryPlaceholder')} />
|
||||
<Input placeholder={t("reasons.categoryPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('reasons.content')}
|
||||
label={t("reasons.content")}
|
||||
name="content"
|
||||
rules={[{ required: true, message: t('reasons.contentRequired') }]}
|
||||
rules={[{ required: true, message: t("reasons.contentRequired") }]}
|
||||
>
|
||||
<Input placeholder={t('reasons.contentPlaceholder')} />
|
||||
<Input placeholder={t("reasons.contentPlaceholder")} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('reasons.presetPoints')}
|
||||
label={t("reasons.presetPoints")}
|
||||
name="delta"
|
||||
rules={[{ required: true, message: t('reasons.pointsRequired') }]}
|
||||
rules={[{ required: true, message: t("reasons.pointsRequired") }]}
|
||||
>
|
||||
<InputNumber placeholder={t('reasons.pointsPlaceholder')} style={{ width: '100%' }} />
|
||||
<InputNumber placeholder={t("reasons.pointsPlaceholder")} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Form,
|
||||
Select,
|
||||
@@ -11,24 +11,24 @@ import {
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Popconfirm
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UndoOutlined } from '@ant-design/icons'
|
||||
import { match } from 'pinyin-pro'
|
||||
Popconfirm,
|
||||
} from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { UndoOutlined } from "@ant-design/icons"
|
||||
import { match } from "pinyin-pro"
|
||||
|
||||
const normalizeSearch = (input: unknown) =>
|
||||
String(input ?? '')
|
||||
String(input ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const getOptionLabel = (option: unknown) => {
|
||||
if (option && typeof option === 'object') {
|
||||
if (option && typeof option === "object") {
|
||||
const anyOption = option as any
|
||||
return String(anyOption.label ?? anyOption.text ?? anyOption.value ?? '')
|
||||
return String(anyOption.label ?? anyOption.text ?? anyOption.value ?? "")
|
||||
}
|
||||
return String(option ?? '')
|
||||
return String(option ?? "")
|
||||
}
|
||||
|
||||
const matchStudentName = (name: string, keyword: string) => {
|
||||
@@ -38,8 +38,8 @@ const matchStudentName = (name: string, keyword: string) => {
|
||||
const nameLower = String(name).toLowerCase()
|
||||
if (nameLower.includes(q0)) return true
|
||||
|
||||
const q1 = q0.replace(/\s+/g, '')
|
||||
if (q1 && nameLower.replace(/\s+/g, '').includes(q1)) return true
|
||||
const q1 = q0.replace(/\s+/g, "")
|
||||
if (q1 && nameLower.replace(/\s+/g, "").includes(q1)) return true
|
||||
|
||||
try {
|
||||
const m0 = match(name, q0)
|
||||
@@ -89,8 +89,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [form] = Form.useForm()
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
|
||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||
}
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -101,14 +101,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [stuRes, reaRes, eveRes] = await Promise.all([
|
||||
(window as any).api.queryStudents({}),
|
||||
(window as any).api.queryReasons(),
|
||||
(window as any).api.queryEvents({ limit: 10 })
|
||||
(window as any).api.queryEvents({ limit: 10 }),
|
||||
])
|
||||
|
||||
if (stuRes.success) setStudents(stuRes.data)
|
||||
if (reaRes.success) setReasons(reaRes.data)
|
||||
if (eveRes.success) setEvents(eveRes.data)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch data:', e)
|
||||
console.error("Failed to fetch data:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -120,22 +120,22 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (
|
||||
category === 'events' ||
|
||||
category === 'students' ||
|
||||
category === 'reasons' ||
|
||||
category === 'all'
|
||||
category === "events" ||
|
||||
category === "students" ||
|
||||
category === "reasons" ||
|
||||
category === "all"
|
||||
) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchData])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
const values = form.getFieldsValue(true) as any
|
||||
@@ -144,7 +144,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
? values.student_name
|
||||
: [values.student_name]
|
||||
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
|
||||
messageApi.warning(t('score.pleaseEnterInfo'))
|
||||
messageApi.warning(t("score.pleaseEnterInfo"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,13 +155,13 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
|
||||
|
||||
if (!hasDeltaInput && !selectedReason) {
|
||||
messageApi.warning(t('score.pleaseEnterPoints'))
|
||||
messageApi.warning(t("score.pleaseEnterPoints"))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitLoading(true)
|
||||
const delta = hasDeltaInput
|
||||
? values.type === 'subtract'
|
||||
? values.type === "subtract"
|
||||
? -Math.abs(deltaInput)
|
||||
: Math.abs(deltaInput)
|
||||
: Number(selectedReason?.delta ?? 0)
|
||||
@@ -172,7 +172,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const res = await (window as any).api.createEvent({
|
||||
student_name: studentName,
|
||||
reason_content: values.reason_content,
|
||||
delta: delta
|
||||
delta: delta,
|
||||
})
|
||||
if (res.success) {
|
||||
successCount++
|
||||
@@ -180,22 +180,22 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
if (successCount === studentNames.length) {
|
||||
messageApi.success(t('score.batchSuccess', { count: successCount }))
|
||||
messageApi.success(t("score.batchSuccess", { count: successCount }))
|
||||
form.setFieldsValue({
|
||||
student_name: [],
|
||||
delta: undefined,
|
||||
reason_content: '',
|
||||
reason_content: "",
|
||||
reason_id: undefined,
|
||||
type: 'add'
|
||||
type: "add",
|
||||
})
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
emitDataUpdated("events")
|
||||
} else {
|
||||
messageApi.warning(
|
||||
t('score.batchPartial', { success: successCount, total: studentNames.length })
|
||||
t("score.batchPartial", { success: successCount, total: studentNames.length })
|
||||
)
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
emitDataUpdated("events")
|
||||
}
|
||||
} finally {
|
||||
setSubmitLoading(false)
|
||||
@@ -205,105 +205,105 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleUndo = async (uuid: string) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteEvent(uuid)
|
||||
if (res.success) {
|
||||
messageApi.success(t('score.undoSuccess'))
|
||||
messageApi.success(t("score.undoSuccess"))
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
emitDataUpdated("events")
|
||||
} else {
|
||||
messageApi.error(res.message || t('score.undoFailed'))
|
||||
messageApi.error(res.message || t("score.undoFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<scoreEvent> = [
|
||||
{ title: t('score.student'), dataIndex: 'student_name', key: 'student_name', width: 100 },
|
||||
{ title: t("score.student"), dataIndex: "student_name", key: "student_name", width: 100 },
|
||||
{
|
||||
title: t('score.change'),
|
||||
dataIndex: 'delta',
|
||||
key: 'delta',
|
||||
title: t("score.change"),
|
||||
dataIndex: "delta",
|
||||
key: "delta",
|
||||
width: 80,
|
||||
render: (delta: number) => (
|
||||
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||
)
|
||||
<Tag color={delta > 0 ? "success" : "error"}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('score.reason'),
|
||||
dataIndex: 'reason_content',
|
||||
key: 'reason_content',
|
||||
ellipsis: true
|
||||
title: t("score.reason"),
|
||||
dataIndex: "reason_content",
|
||||
key: "reason_content",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('score.time'),
|
||||
dataIndex: 'event_time',
|
||||
key: 'event_time',
|
||||
title: t("score.time"),
|
||||
dataIndex: "event_time",
|
||||
key: "event_time",
|
||||
width: 160,
|
||||
render: (time: string) => new Date(time).toLocaleString()
|
||||
render: (time: string) => new Date(time).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Popconfirm title={t('score.undoConfirm')} onConfirm={() => handleUndo(row.uuid)}>
|
||||
<Popconfirm title={t("score.undoConfirm")} onConfirm={() => handleUndo(row.uuid)}>
|
||||
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
||||
{t('score.undo')}
|
||||
{t("score.undo")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}>{t('score.title')}</h2>
|
||||
<h2 style={{ marginBottom: "24px", color: "var(--ss-text-main)" }}>{t("score.title")}</h2>
|
||||
|
||||
<Card style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Form.Item label={t('score.student')} name="student_name">
|
||||
<Card style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: "add" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "24px" }}>
|
||||
<Form.Item label={t("score.student")} name="student_name">
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder={t('score.pleaseSelectStudent')}
|
||||
placeholder={t("score.pleaseSelectStudent")}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('score.points')}>
|
||||
<Form.Item label={t("score.points")}>
|
||||
<Space>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Radio.Group optionType="button" buttonStyle="solid">
|
||||
<Radio.Button value="add">{t('score.addPoints')}</Radio.Button>
|
||||
<Radio.Button value="subtract">{t('score.deductPoints')}</Radio.Button>
|
||||
<Radio.Button value="add">{t("score.addPoints")}</Radio.Button>
|
||||
<Radio.Button value="subtract">{t("score.deductPoints")}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="delta" noStyle>
|
||||
<InputNumber min={1} placeholder={t('score.points')} style={{ width: '120px' }} />
|
||||
<InputNumber min={1} placeholder={t("score.points")} style={{ width: "120px" }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('score.quickReason')} name="reason_id">
|
||||
<Form.Item label={t("score.quickReason")} name="reason_id">
|
||||
<Select
|
||||
placeholder={t('score.selectReason')}
|
||||
placeholder={t("score.selectReason")}
|
||||
onChange={(v) => {
|
||||
const id = Number(v)
|
||||
if (!Number.isFinite(id)) return
|
||||
const reason = reasons.find((r) => r.id === id)
|
||||
if (!reason) return
|
||||
|
||||
const currentDelta = Number(form.getFieldValue('delta'))
|
||||
const currentDelta = Number(form.getFieldValue("delta"))
|
||||
const hasCurrentDelta = Number.isFinite(currentDelta) && currentDelta > 0
|
||||
|
||||
if (hasCurrentDelta) {
|
||||
form.setFieldsValue({
|
||||
reason_content: reason.content,
|
||||
type: reason.delta > 0 ? 'add' : 'subtract'
|
||||
type: reason.delta > 0 ? "add" : "subtract",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -311,38 +311,38 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
form.setFieldsValue({
|
||||
reason_content: reason.content,
|
||||
delta: Math.abs(reason.delta),
|
||||
type: reason.delta > 0 ? 'add' : 'subtract'
|
||||
type: reason.delta > 0 ? "add" : "subtract",
|
||||
})
|
||||
}}
|
||||
options={reasons.map((r) => ({
|
||||
label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
|
||||
value: r.id
|
||||
value: r.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('score.reasonContent')} name="reason_content">
|
||||
<Input placeholder={t('score.reasonPlaceholder')} />
|
||||
<Form.Item label={t("score.reasonContent")} name="reason_content">
|
||||
<Input placeholder={t("score.reasonPlaceholder")} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ marginTop: "24px", display: "flex", justifyContent: "center" }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={!canEdit}
|
||||
onClick={handleSubmit}
|
||||
loading={submitLoading}
|
||||
style={{ width: '200px' }}
|
||||
style={{ width: "200px" }}
|
||||
>
|
||||
{t('score.submit')}
|
||||
{t("score.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 16 }}>{t('score.recentRecords')}</div>
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 16 }}>{t("score.recentRecords")}</div>
|
||||
<Table
|
||||
dataSource={events}
|
||||
columns={columns}
|
||||
@@ -350,7 +350,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
style={{ color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, message, Space, Table } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Button, Card, message, Space, Table } from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface settlementSummary {
|
||||
id: number
|
||||
@@ -42,12 +42,12 @@ export const SettlementHistory: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.querySettlements()
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||
messageApi.error(res.message || t("settlements.queryFailed"))
|
||||
return
|
||||
}
|
||||
setSettlements(res.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settlements:', e)
|
||||
console.error("Failed to fetch settlements:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -60,10 +60,10 @@ export const SettlementHistory: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (category === 'events' || category === 'all') fetchSettlements()
|
||||
if (category === "events" || category === "all") fetchSettlements()
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchSettlements])
|
||||
|
||||
const openSettlement = async (id: number) => {
|
||||
@@ -73,13 +73,13 @@ export const SettlementHistory: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
||||
if (!res.success || !res.data) {
|
||||
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||
messageApi.error(res.message || t("settlements.queryFailed"))
|
||||
return
|
||||
}
|
||||
setSelectedSettlement(res.data.settlement)
|
||||
setRows(res.data.rows || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settlement leaderboard:', e)
|
||||
console.error("Failed to fetch settlement leaderboard:", e)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
@@ -88,29 +88,29 @@ export const SettlementHistory: React.FC = () => {
|
||||
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('settlements.rank'),
|
||||
key: 'rank',
|
||||
title: t("settlements.rank"),
|
||||
key: "rank",
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (_, __, index) => <span style={{ fontWeight: 'bold' }}>{index + 1}</span>
|
||||
align: "center",
|
||||
render: (_, __, index) => <span style={{ fontWeight: "bold" }}>{index + 1}</span>,
|
||||
},
|
||||
{ title: t('settlements.name'), dataIndex: 'name', key: 'name', width: 160 },
|
||||
{ title: t("settlements.name"), dataIndex: "name", key: "name", width: 160 },
|
||||
{
|
||||
title: t('settlements.phaseScore'),
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
title: t("settlements.phaseScore"),
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
width: 120,
|
||||
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
|
||||
}
|
||||
render: (score: number) => <span style={{ fontWeight: "bold" }}>{score}</span>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
if (selectedId !== null && selectedSettlement) {
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<Space style={{ marginBottom: '16px' }}>
|
||||
<Space style={{ marginBottom: "16px" }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedId(null)
|
||||
@@ -118,17 +118,17 @@ export const SettlementHistory: React.FC = () => {
|
||||
setRows([])
|
||||
}}
|
||||
>
|
||||
{t('settlements.back')}
|
||||
{t("settlements.back")}
|
||||
</Button>
|
||||
<div style={{ color: 'var(--ss-text-main)', fontWeight: 700 }}>
|
||||
{t('settlements.leaderboardTitle')}
|
||||
<div style={{ color: "var(--ss-text-main)", fontWeight: 700 }}>
|
||||
{t("settlements.leaderboardTitle")}
|
||||
</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
<div style={{ color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
||||
{formatRange(selectedSettlement)}
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||
<Card style={{ backgroundColor: "var(--ss-card-bg)" }}>
|
||||
<Table
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
@@ -136,7 +136,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
loading={detailLoading}
|
||||
bordered
|
||||
pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
|
||||
style={{ color: 'var(--ss-text-main)' }}
|
||||
style={{ color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -144,45 +144,45 @@ export const SettlementHistory: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}>
|
||||
{t('settlements.title')}
|
||||
<h2 style={{ marginBottom: "16px", color: "var(--ss-text-main)" }}>
|
||||
{t("settlements.title")}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '16px'
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{settlements.map((s) => (
|
||||
<Card
|
||||
key={s.id}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px' }}>
|
||||
{t('settlements.phase', { id: s.id })}
|
||||
<div style={{ fontWeight: 700, marginBottom: "8px" }}>
|
||||
{t("settlements.phase", { id: s.id })}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}
|
||||
style={{ fontSize: "12px", color: "var(--ss-text-secondary)", marginBottom: "12px" }}
|
||||
>
|
||||
{formatRange(s)}
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => openSettlement(s.id)}>
|
||||
{t('settlements.viewLeaderboard')}
|
||||
{t("settlements.viewLeaderboard")}
|
||||
</Button>
|
||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
{t('settlements.recordCount', { count: s.event_count })}
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settlements.recordCount", { count: s.event_count })}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{!loading && settlements.length === 0 && (
|
||||
<div style={{ marginTop: '16px', color: 'var(--ss-text-secondary)' }}>
|
||||
{t('settlements.noSettlements')}
|
||||
<div style={{ marginTop: "16px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("settlements.noSettlements")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Layout, Menu, Card, Tag, Button, Space, message } from 'antd'
|
||||
import { Layout, Menu, Card, Tag, Button, Space, message } from "antd"
|
||||
import {
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
@@ -8,29 +8,29 @@ import {
|
||||
SyncOutlined,
|
||||
FileTextOutlined,
|
||||
CloudOutlined,
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import appLogo from '../assets/logoHD.svg'
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import appLogo from "../assets/logoHD.svg"
|
||||
|
||||
const { Sider } = Layout
|
||||
|
||||
interface SidebarProps {
|
||||
activeMenu: string
|
||||
permission: 'admin' | 'points' | 'view'
|
||||
permission: "admin" | "points" | "view"
|
||||
onMenuChange: (value: string) => void
|
||||
}
|
||||
|
||||
interface DbStatus {
|
||||
type: 'sqlite' | 'postgresql'
|
||||
type: "sqlite" | "postgresql"
|
||||
connected: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: 'sqlite', connected: true })
|
||||
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: "sqlite", connected: true })
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
|
||||
@@ -40,11 +40,13 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
loadDbStatus()
|
||||
}
|
||||
if ((window as any).api) {
|
||||
const unsubscribe = (window as any).api.onSettingChanged((change) => {
|
||||
if (change.key === 'pg_connection_status') {
|
||||
handleStatusChange()
|
||||
const unsubscribe = (window as any).api.onSettingChanged(
|
||||
(change: { key: string; value: any }) => {
|
||||
if (change.key === "pg_connection_status") {
|
||||
handleStatusChange()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return unsubscribe
|
||||
}
|
||||
}, [])
|
||||
@@ -57,7 +59,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
setDbStatus(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load database status:', e)
|
||||
console.error("Failed to load database status:", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
try {
|
||||
await loadDbStatus()
|
||||
} catch (e) {
|
||||
console.error('Failed to sync database status:', e)
|
||||
console.error("Failed to sync database status:", e)
|
||||
} finally {
|
||||
setSyncLoading(false)
|
||||
}
|
||||
@@ -80,17 +82,17 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
|
||||
const statusRes = await (window as any).api.dbGetStatus()
|
||||
if (!statusRes.success || !statusRes.data) {
|
||||
messageApi.error(t('sidebar.getDbStatusFailed'))
|
||||
messageApi.error(t("sidebar.getDbStatusFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (statusRes.data.type !== 'postgresql') {
|
||||
messageApi.error(t('sidebar.notRemoteMode'))
|
||||
if (statusRes.data.type !== "postgresql") {
|
||||
messageApi.error(t("sidebar.notRemoteMode"))
|
||||
return
|
||||
}
|
||||
|
||||
if (!statusRes.data.connected) {
|
||||
messageApi.error(t('sidebar.dbNotConnected'))
|
||||
messageApi.error(t("sidebar.dbNotConnected"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,12 +100,12 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
try {
|
||||
const res = await (window as any).api.dbSync()
|
||||
if (res.success && res.data?.success) {
|
||||
messageApi.success(t('sidebar.syncSuccess'))
|
||||
messageApi.success(t("sidebar.syncSuccess"))
|
||||
} else {
|
||||
messageApi.error(res.data?.message || res.message || t('sidebar.syncFailed'))
|
||||
messageApi.error(res.data?.message || res.message || t("sidebar.syncFailed"))
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || t('sidebar.syncFailed'))
|
||||
messageApi.error(e?.message || t("sidebar.syncFailed"))
|
||||
} finally {
|
||||
setForceSyncLoading(false)
|
||||
}
|
||||
@@ -111,48 +113,48 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'home',
|
||||
key: "home",
|
||||
icon: <HomeOutlined />,
|
||||
label: t('sidebar.home')
|
||||
label: t("sidebar.home"),
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
key: "students",
|
||||
icon: <UserOutlined />,
|
||||
label: t('sidebar.students'),
|
||||
disabled: permission !== 'admin'
|
||||
label: t("sidebar.students"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
key: "score",
|
||||
icon: <HistoryOutlined />,
|
||||
label: t('sidebar.score')
|
||||
label: t("sidebar.score"),
|
||||
},
|
||||
{
|
||||
key: 'auto-score',
|
||||
key: "auto-score",
|
||||
icon: <SyncOutlined />,
|
||||
label: t('sidebar.autoScore')
|
||||
label: t("sidebar.autoScore"),
|
||||
},
|
||||
{
|
||||
key: 'leaderboard',
|
||||
key: "leaderboard",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: t('sidebar.leaderboard')
|
||||
label: t("sidebar.leaderboard"),
|
||||
},
|
||||
{
|
||||
key: 'settlements',
|
||||
key: "settlements",
|
||||
icon: <FileTextOutlined />,
|
||||
label: t('sidebar.settlements')
|
||||
label: t("sidebar.settlements"),
|
||||
},
|
||||
{
|
||||
key: 'reasons',
|
||||
key: "reasons",
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: t('sidebar.reasons'),
|
||||
disabled: permission !== 'admin'
|
||||
label: t("sidebar.reasons"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: t('sidebar.settings'),
|
||||
disabled: permission !== 'admin'
|
||||
}
|
||||
label: t("sidebar.settings"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -160,88 +162,88 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
className="ss-sidebar"
|
||||
width={200}
|
||||
style={{
|
||||
background: 'var(--ss-sidebar-bg)',
|
||||
borderRight: '1px solid var(--ss-border-color)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
background: "var(--ss-sidebar-bg)",
|
||||
borderRight: "1px solid var(--ss-border-color)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
theme="light"
|
||||
>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
padding: '32px 24px 16px',
|
||||
textAlign: 'center',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0
|
||||
padding: "32px 24px 16px",
|
||||
textAlign: "center",
|
||||
WebkitAppRegion: "drag",
|
||||
userSelect: "none",
|
||||
flexShrink: 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={appLogo}
|
||||
style={{ width: '48px', height: '48px', marginBottom: '12px' }}
|
||||
style={{ width: "48px", height: "48px", marginBottom: "12px" }}
|
||||
alt="logo"
|
||||
/>
|
||||
<h2
|
||||
style={{
|
||||
color: 'var(--ss-sidebar-text, var(--ss-text-main))',
|
||||
color: "var(--ss-sidebar-text, var(--ss-text-main))",
|
||||
margin: 0,
|
||||
fontSize: '20px'
|
||||
fontSize: "20px",
|
||||
}}
|
||||
>
|
||||
SecScore
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--ss-sidebar-text, var(--ss-text-main))',
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-sidebar-text, var(--ss-text-main))",
|
||||
opacity: 0.8,
|
||||
marginTop: '4px'
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{t('settings.about.appName')}
|
||||
{t("settings.about.appName")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeMenu]}
|
||||
onClick={({ key }) => onMenuChange(key)}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent'
|
||||
width: "100%",
|
||||
border: "none",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
items={menuItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dbStatus.type === 'postgresql' && (
|
||||
{dbStatus.type === "postgresql" && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
margin: '8px',
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
border: '1px solid var(--ss-border-color)'
|
||||
margin: "8px",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
}}
|
||||
styles={{ body: { padding: '12px' } }}
|
||||
styles={{ body: { padding: "12px" } }}
|
||||
>
|
||||
{contextHolder}
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space orientation="vertical" size={4} style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Space size={4}>
|
||||
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
|
||||
<span style={{ fontSize: '12px', fontWeight: 500 }}>{t('sidebar.remoteDb')}</span>
|
||||
<CloudOutlined style={{ fontSize: "12px", color: "#1890ff" }} />
|
||||
<span style={{ fontSize: "12px", fontWeight: 500 }}>{t("sidebar.remoteDb")}</span>
|
||||
</Space>
|
||||
<Tag
|
||||
color={dbStatus.connected ? 'success' : 'error'}
|
||||
style={{ margin: 0, fontSize: '10px' }}
|
||||
color={dbStatus.connected ? "success" : "error"}
|
||||
style={{ margin: 0, fontSize: "10px" }}
|
||||
>
|
||||
{dbStatus.connected
|
||||
? t('settings.database.connected')
|
||||
: t('settings.database.disconnected')}
|
||||
? t("settings.database.connected")
|
||||
: t("settings.database.disconnected")}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
@@ -251,14 +253,14 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
onClick={handleSync}
|
||||
loading={syncLoading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
fontSize: '12px',
|
||||
padding: '0 8px',
|
||||
color: 'var(--ss-text-secondary)'
|
||||
width: "100%",
|
||||
height: "24px",
|
||||
fontSize: "12px",
|
||||
padding: "0 8px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t('sidebar.refreshStatus')}
|
||||
{t("sidebar.refreshStatus")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -268,13 +270,13 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
loading={forceSyncLoading}
|
||||
disabled={!dbStatus.connected}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
fontSize: '12px',
|
||||
padding: '0 8px'
|
||||
width: "100%",
|
||||
height: "24px",
|
||||
fontSize: "12px",
|
||||
padding: "0 8px",
|
||||
}}
|
||||
>
|
||||
{t('sidebar.syncNow')}
|
||||
{t("sidebar.syncNow")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TagEditorDialog } from './TagEditorDialog'
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
||||
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { TagEditorDialog } from "./TagEditorDialog"
|
||||
|
||||
const createXlsxWorker = () => {
|
||||
return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
return new Worker(new URL("../workers/xlsxWorker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const [tagEditVisible, setTagEditVisible] = useState(false)
|
||||
const [editingStudent, setEditingStudent] = useState<student | null>(null)
|
||||
const [xlsxLoading, setXlsxLoading] = useState(false)
|
||||
const [xlsxFileName, setXlsxFileName] = useState('')
|
||||
const [xlsxFileName, setXlsxFileName] = useState("")
|
||||
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
|
||||
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
||||
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -45,8 +45,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const emitDataUpdated = (category: 'students' | 'all') => {
|
||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||
const emitDataUpdated = (category: "students" | "all") => {
|
||||
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
|
||||
}
|
||||
|
||||
const fetchStudents = useCallback(async () => {
|
||||
@@ -68,7 +68,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
tags = tagsRes.data.map((t: any) => t.name)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch tags for student:', s.id, e)
|
||||
console.warn("Failed to fetch tags for student:", s.id, e)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -76,19 +76,19 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
name: s.name,
|
||||
score: s.score,
|
||||
tags,
|
||||
tagIds
|
||||
tagIds,
|
||||
}
|
||||
})
|
||||
)
|
||||
console.debug('Fetched students:', students)
|
||||
console.debug("Fetched students:", students)
|
||||
setData(students)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse students response, falling back:', e)
|
||||
console.warn("Failed to parse students response, falling back:", e)
|
||||
setData(res.data)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch students:', e)
|
||||
console.error("Failed to fetch students:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -98,49 +98,51 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fetchStudents()
|
||||
const onDataUpdated = (e: any) => {
|
||||
const category = e?.detail?.category
|
||||
if (category === 'students' || category === 'all') fetchStudents()
|
||||
if (category === "students" || category === "all") fetchStudents()
|
||||
}
|
||||
window.addEventListener('ss:data-updated', onDataUpdated as any)
|
||||
return () => window.removeEventListener('ss:data-updated', onDataUpdated as any)
|
||||
window.addEventListener("ss:data-updated", onDataUpdated as any)
|
||||
return () => window.removeEventListener("ss:data-updated", onDataUpdated as any)
|
||||
}, [fetchStudents])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (!values.name) {
|
||||
messageApi.warning(t('students.nameRequired'))
|
||||
messageApi.warning(t("students.nameRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
const name = values.name.trim()
|
||||
if (data.some((s) => s.name === name)) {
|
||||
messageApi.warning(t('students.nameExists'))
|
||||
messageApi.warning(t("students.nameExists"))
|
||||
return
|
||||
}
|
||||
|
||||
const res = await (window as any).api.createStudent({ ...values, name })
|
||||
if (res.success) {
|
||||
messageApi.success(t('students.addSuccess'))
|
||||
messageApi.success(t("students.addSuccess"))
|
||||
setVisible(false)
|
||||
form.resetFields()
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
messageApi.error(res.message || t('students.addFailed'))
|
||||
messageApi.error(res.message || t("students.addFailed"))
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
const api = (window as any).api
|
||||
api?.writeLog?.({
|
||||
level: 'error',
|
||||
message: 'renderer:validate error',
|
||||
level: "error",
|
||||
message: "renderer:validate error",
|
||||
meta:
|
||||
err instanceof Error ? { message: err.message, stack: err.stack } : { err: String(err) }
|
||||
err instanceof Error
|
||||
? { message: err.message, stack: err.stack }
|
||||
: { err: String(err) },
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
@@ -151,22 +153,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteStudent(id)
|
||||
if (res.success) {
|
||||
messageApi.success(t('students.deleteSuccess'))
|
||||
messageApi.success(t("students.deleteSuccess"))
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
messageApi.error(res.message || t('students.deleteFailed'))
|
||||
messageApi.error(res.message || t("students.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenTagEditor = (student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
setEditingStudent(student)
|
||||
@@ -179,24 +181,24 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
try {
|
||||
const res = await (window as any).api.tagsUpdateStudentTags(editingStudent.id, tagIds)
|
||||
if (res && res.success) {
|
||||
messageApi.success('标签保存成功')
|
||||
messageApi.success("标签保存成功")
|
||||
setTagEditVisible(false)
|
||||
setEditingStudent(null)
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
emitDataUpdated("students")
|
||||
} else {
|
||||
const errorMsg = res?.message || t('students.tagSaveFailed')
|
||||
const errorMsg = res?.message || t("students.tagSaveFailed")
|
||||
messageApi.error(errorMsg)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
messageApi.error(`${t('students.tagSaveFailed')}: ${errorMsg}`)
|
||||
messageApi.error(`${t("students.tagSaveFailed")}: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const excelColName = (idx: number) => {
|
||||
let n = idx + 1
|
||||
let s = ''
|
||||
let s = ""
|
||||
while (n > 0) {
|
||||
const mod = (n - 1) % 26
|
||||
s = String.fromCharCode(65 + mod) + s
|
||||
@@ -207,7 +209,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const parseXlsxFile = async (file: File) => {
|
||||
if (!xlsxWorkerRef.current) {
|
||||
messageApi.error(t('students.workerNotReady'))
|
||||
messageApi.error(t("students.workerNotReady"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,28 +218,28 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const buf = await file.arrayBuffer()
|
||||
|
||||
xlsxWorkerRef.current.postMessage({
|
||||
type: 'parseXlsx',
|
||||
data: { buffer: buf }
|
||||
type: "parseXlsx",
|
||||
data: { buffer: buf },
|
||||
})
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'success') {
|
||||
if (event.data.type === "success") {
|
||||
setXlsxFileName(file.name)
|
||||
setXlsxAoa(event.data.data)
|
||||
setXlsxSelectedCol(null)
|
||||
setXlsxVisible(true)
|
||||
setImportVisible(false)
|
||||
setXlsxLoading(false)
|
||||
} else if (event.data.type === 'error') {
|
||||
messageApi.error(event.data.error || t('students.parseXlsxFailed'))
|
||||
} else if (event.data.type === "error") {
|
||||
messageApi.error(event.data.error || t("students.parseXlsxFailed"))
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
|
||||
xlsxWorkerRef.current?.removeEventListener("message", handleMessage)
|
||||
}
|
||||
|
||||
xlsxWorkerRef.current.addEventListener('message', handleMessage)
|
||||
xlsxWorkerRef.current.addEventListener("message", handleMessage)
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || t('students.parseXlsxFailed'))
|
||||
messageApi.error(e?.message || t("students.parseXlsxFailed"))
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -256,7 +258,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return rows.map((row, idx) => {
|
||||
const record: any = { __row: idx + 1 }
|
||||
for (let c = 0; c < xlsxMaxCols; c++) {
|
||||
record[`c${c}`] = row?.[c] ?? ''
|
||||
record[`c${c}`] = row?.[c] ?? ""
|
||||
}
|
||||
return record
|
||||
})
|
||||
@@ -265,13 +267,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const xlsxPreviewColumns = useMemo(() => {
|
||||
const cols: any[] = [
|
||||
{
|
||||
title: '#',
|
||||
dataIndex: '__row',
|
||||
key: '__row',
|
||||
title: "#",
|
||||
dataIndex: "__row",
|
||||
key: "__row",
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
fixed: 'left' as const
|
||||
}
|
||||
align: "center" as const,
|
||||
fixed: "left" as const,
|
||||
},
|
||||
]
|
||||
for (let c = 0; c < xlsxMaxCols; c++) {
|
||||
const selected = xlsxSelectedCol === c
|
||||
@@ -279,9 +281,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
title: (
|
||||
<span
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
cursor: "pointer",
|
||||
fontWeight: selected ? 700 : 500,
|
||||
color: selected ? 'var(--ant-color-primary, #1890ff)' : undefined
|
||||
color: selected ? "var(--ant-color-primary, #1890ff)" : undefined,
|
||||
}}
|
||||
onClick={() => setXlsxSelectedCol(c)}
|
||||
>
|
||||
@@ -290,7 +292,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
),
|
||||
dataIndex: `c${c}`,
|
||||
key: `c${c}`,
|
||||
width: 120
|
||||
width: 120,
|
||||
})
|
||||
}
|
||||
return cols
|
||||
@@ -299,10 +301,10 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const extractNamesFromAoa = (aoa: any[][], colIdx: number) => {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const banned = new Set([t('students.name').toLowerCase(), 'name', t('students.name')])
|
||||
const banned = new Set([t("students.name").toLowerCase(), "name", t("students.name")])
|
||||
for (const row of aoa) {
|
||||
const raw = row?.[colIdx]
|
||||
const name = String(raw ?? '').trim()
|
||||
const name = String(raw ?? "").trim()
|
||||
if (!name) continue
|
||||
if (banned.has(name.toLowerCase()) || banned.has(name)) continue
|
||||
if (seen.has(name)) continue
|
||||
@@ -315,17 +317,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleConfirmXlsxImport = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t('common.readOnly'))
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
if (xlsxSelectedCol == null) {
|
||||
messageApi.warning(t('students.selectNameColFirst'))
|
||||
messageApi.warning(t("students.selectNameColFirst"))
|
||||
return
|
||||
}
|
||||
|
||||
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
|
||||
if (!names.length) {
|
||||
messageApi.error(t('students.noNamesFound'))
|
||||
messageApi.error(t("students.noNamesFound"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -333,56 +335,56 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
try {
|
||||
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
||||
if (!res?.success) {
|
||||
messageApi.error(res?.message || t('students.importFailed'))
|
||||
messageApi.error(res?.message || t("students.importFailed"))
|
||||
return
|
||||
}
|
||||
const inserted = Number(res?.data?.inserted ?? 0)
|
||||
const skipped = Number(res?.data?.skipped ?? 0)
|
||||
messageApi.success(t('students.importComplete', { inserted, skipped }))
|
||||
messageApi.success(t("students.importComplete", { inserted, skipped }))
|
||||
setXlsxVisible(false)
|
||||
setXlsxAoa([])
|
||||
setXlsxFileName('')
|
||||
setXlsxFileName("")
|
||||
setXlsxSelectedCol(null)
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
emitDataUpdated("students")
|
||||
} finally {
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<student> = [
|
||||
{ title: t('students.name'), dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: t("students.name"), dataIndex: "name", key: "name", width: 100 },
|
||||
{
|
||||
title: t('students.currentScore'),
|
||||
dataIndex: 'score',
|
||||
key: 'score',
|
||||
title: t("students.currentScore"),
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
width: 160,
|
||||
align: 'center',
|
||||
align: "center",
|
||||
render: (score: number) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontWeight: "bold",
|
||||
color:
|
||||
score > 0
|
||||
? 'var(--ant-color-success, #52c41a)'
|
||||
? "var(--ant-color-success, #52c41a)"
|
||||
: score < 0
|
||||
? 'var(--ant-color-error, #ff4d4f)'
|
||||
: 'inherit'
|
||||
? "var(--ant-color-error, #ff4d4f)"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{score > 0 ? `+${score}` : score}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('students.tags'),
|
||||
dataIndex: 'tags',
|
||||
key: 'tags',
|
||||
title: t("students.tags"),
|
||||
dataIndex: "tags",
|
||||
key: "tags",
|
||||
width: 200,
|
||||
render: (tags: string[] = []) => (
|
||||
<Space>
|
||||
{tags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('students.noTags')}</span>
|
||||
<span style={{ color: "var(--ss-text-secondary)" }}>{t("students.noTags")}</span>
|
||||
) : (
|
||||
tags.slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} color="blue">
|
||||
@@ -392,38 +394,38 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)}
|
||||
{tags.length > 3 && <Tag>+{tags.length - 3}</Tag>}
|
||||
</Space>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
|
||||
{t('students.editTags')}
|
||||
{t("students.editTags")}
|
||||
</Button>
|
||||
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
|
||||
{t('common.delete')}
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const paginatedData = data.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('students.title')}</h2>
|
||||
<div style={{ marginBottom: "16px", display: "flex", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("students.title")}</h2>
|
||||
<Space>
|
||||
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
||||
{t('students.importList')}
|
||||
{t("students.importList")}
|
||||
</Button>
|
||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||
{t('students.addStudent')}
|
||||
{t("students.addStudent")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -434,9 +436,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<div style={{ marginTop: 16, textAlign: "right" }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
@@ -446,38 +448,38 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setPageSize(size)
|
||||
}}
|
||||
showSizeChanger
|
||||
showTotal={(total) => t('common.total', { count: total })}
|
||||
showTotal={(total) => t("common.total", { count: total })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('students.addTitle')}
|
||||
title={t("students.addTitle")}
|
||||
open={visible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setVisible(false)}
|
||||
okText={t('students.addConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("students.addConfirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label={t('students.name')}
|
||||
label={t("students.name")}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('students.nameRequired') }]}
|
||||
rules={[{ required: true, message: t("students.nameRequired") }]}
|
||||
>
|
||||
<Input placeholder={t('students.namePlaceholder')} />
|
||||
<Input placeholder={t("students.namePlaceholder")} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('students.importTitle')}
|
||||
title={t("students.importTitle")}
|
||||
open={importVisible}
|
||||
onCancel={() => setImportVisible(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" style={{ width: "100%" }}>
|
||||
<Button
|
||||
loading={xlsxLoading}
|
||||
disabled={!canEdit}
|
||||
@@ -485,42 +487,42 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
xlsxInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
{t('students.importByXlsx')}
|
||||
{t("students.importByXlsx")}
|
||||
</Button>
|
||||
<input
|
||||
ref={xlsxInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) parseXlsxFile(file)
|
||||
if (xlsxInputRef.current) xlsxInputRef.current.value = ''
|
||||
if (xlsxInputRef.current) xlsxInputRef.current.value = ""
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('students.xlsxPreview')}
|
||||
title={t("students.xlsxPreview")}
|
||||
open={xlsxVisible}
|
||||
onCancel={() => setXlsxVisible(false)}
|
||||
onOk={handleConfirmXlsxImport}
|
||||
okText={t('students.importConfirm')}
|
||||
okText={t("students.importConfirm")}
|
||||
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
|
||||
width="80%"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
<div style={{ marginBottom: "12px", color: "var(--ss-text-secondary)", fontSize: "12px" }}>
|
||||
<div>
|
||||
{t('students.file')}
|
||||
{xlsxFileName || '-'}
|
||||
{t("students.file")}
|
||||
{xlsxFileName || "-"}
|
||||
</div>
|
||||
<div>
|
||||
{t('students.selectNameCol')}
|
||||
{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
|
||||
{t("students.selectNameCol")}
|
||||
{xlsxSelectedCol == null ? "-" : excelColName(xlsxSelectedCol)}
|
||||
</div>
|
||||
<div>{t('students.previewRows')}</div>
|
||||
<div>{t("students.previewRows")}</div>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={xlsxPreviewRows}
|
||||
@@ -528,7 +530,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
rowKey="__row"
|
||||
bordered
|
||||
scroll={{ y: 420 }}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
style={{ backgroundColor: "var(--ss-card-bg)", color: "var(--ss-text-main)" }}
|
||||
pagination={false}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -541,7 +543,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}}
|
||||
onConfirm={handleSaveTags}
|
||||
initialTagIds={editingStudent?.tagIds || []}
|
||||
title={t('students.editTagTitle', { name: editingStudent?.name || '' })}
|
||||
title={t("students.editTagTitle", { name: editingStudent?.name || "" })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Input, Button, Space, Tag, message } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { Modal, Input, Button, Space, Tag, message } from "antd"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface TagItem {
|
||||
id: number
|
||||
@@ -24,7 +24,7 @@ export async function fetchAllTags() {
|
||||
}
|
||||
return []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch tags:', e)
|
||||
console.error("Failed to fetch tags:", e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
initialTagIds = [],
|
||||
title
|
||||
title,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [allTags, setAllTags] = useState<TagItem[]>([])
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
@@ -45,7 +45,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTagIds(new Set(initialTagIds))
|
||||
setInputValue('')
|
||||
setInputValue("")
|
||||
fetchAllTags().then((tags) => setAllTags(tags))
|
||||
}
|
||||
}, [visible, initialTagIds])
|
||||
@@ -55,7 +55,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
if (!trimmed) return
|
||||
|
||||
if (trimmed.length > 30) {
|
||||
messageApi.error(t('tags.nameTooLong'))
|
||||
messageApi.error(t("tags.nameTooLong"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
if (existingTag) {
|
||||
setSelectedTagIds((prev) => new Set([...prev, existingTag.id]))
|
||||
}
|
||||
setInputValue('')
|
||||
setInputValue("")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
if (res.success && res.data) {
|
||||
setAllTags((prev) => [...prev, res.data])
|
||||
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
|
||||
setInputValue('')
|
||||
setInputValue("")
|
||||
} else {
|
||||
messageApi.error(res.message || t('tags.addFailed'))
|
||||
messageApi.error(res.message || t("tags.addFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create tag:', e)
|
||||
messageApi.error(t('tags.addFailed'))
|
||||
console.error("Failed to create tag:", e)
|
||||
messageApi.error(t("tags.addFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
newSet.delete(tagId)
|
||||
return newSet
|
||||
})
|
||||
messageApi.success(t('tags.deleteSuccess'))
|
||||
messageApi.success(t("tags.deleteSuccess"))
|
||||
} else {
|
||||
messageApi.error(res.message || t('tags.deleteFailed'))
|
||||
messageApi.error(res.message || t("tags.deleteFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete tag:', e)
|
||||
messageApi.error(t('tags.deleteFailed'))
|
||||
console.error("Failed to delete tag:", e)
|
||||
messageApi.error(t("tags.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,44 +125,44 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title || t('tags.editTitle')}
|
||||
title={title || t("tags.editTitle")}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleConfirm}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okText={t("common.save")}
|
||||
cancelText={t("common.cancel")}
|
||||
destroyOnHidden
|
||||
width={500}
|
||||
>
|
||||
{contextHolder}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<Input
|
||||
placeholder={t('tags.inputPlaceholder')}
|
||||
placeholder={t("tags.inputPlaceholder")}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={handleAddTag}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
|
||||
{t('common.add')}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
{t('tags.selectedTags')}
|
||||
<div style={{ fontSize: "14px", color: "var(--ss-text-secondary)", marginBottom: "8px" }}>
|
||||
{t("tags.selectedTags")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '50px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
borderRadius: '4px'
|
||||
minHeight: "50px",
|
||||
padding: "12px",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{selectedTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noTagsSelected')}</span>
|
||||
<span style={{ color: "var(--ss-text-secondary)" }}>{t("tags.noTagsSelected")}</span>
|
||||
) : (
|
||||
<Space wrap>
|
||||
{selectedTags.map((tag) => (
|
||||
@@ -171,7 +171,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
color="blue"
|
||||
closable
|
||||
onClose={() => handleToggleTag(tag.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
@@ -182,19 +182,19 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
{t('tags.availableTags')}
|
||||
<div style={{ fontSize: "14px", color: "var(--ss-text-secondary)", marginBottom: "8px" }}>
|
||||
{t("tags.availableTags")}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '50px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
borderRadius: '4px'
|
||||
minHeight: "50px",
|
||||
padding: "12px",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{availableTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noAvailableTags')}</span>
|
||||
<span style={{ color: "var(--ss-text-secondary)" }}>{t("tags.noAvailableTags")}</span>
|
||||
) : (
|
||||
<Space wrap>
|
||||
{availableTags.map((tag) => (
|
||||
@@ -205,7 +205,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
e.preventDefault()
|
||||
handleDeleteTag(tag.id)
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleToggleTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
@@ -1,40 +1,40 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { Drawer, Form, Input, Select, Button, Space, Divider, Row, Col, ColorPicker } from 'antd'
|
||||
import type { Color } from 'antd/es/color-picker'
|
||||
import { useThemeEditor } from '../contexts/ThemeEditorContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { generateColorMap } from '../utils/color'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
import React, { useEffect } from "react"
|
||||
import { Drawer, Form, Input, Select, Button, Space, Divider, Row, Col, ColorPicker } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import { useThemeEditor } from "../contexts/ThemeEditorContext"
|
||||
import { useTheme } from "../contexts/ThemeContext"
|
||||
import { generateColorMap } from "../utils/color"
|
||||
import { themeConfig } from "../preload/types"
|
||||
|
||||
const variableGroups = {
|
||||
background: {
|
||||
title: '背景颜色',
|
||||
title: "背景颜色",
|
||||
items: [
|
||||
{ key: '--ss-bg-color', label: '全局背景' },
|
||||
{ key: '--ss-card-bg', label: '卡片背景' },
|
||||
{ key: '--ss-header-bg', label: '顶部栏背景' },
|
||||
{ key: '--ss-sidebar-bg', label: '侧边栏背景' }
|
||||
]
|
||||
{ key: "--ss-bg-color", label: "全局背景" },
|
||||
{ key: "--ss-card-bg", label: "卡片背景" },
|
||||
{ key: "--ss-header-bg", label: "顶部栏背景" },
|
||||
{ key: "--ss-sidebar-bg", label: "侧边栏背景" },
|
||||
],
|
||||
},
|
||||
text: {
|
||||
title: '文字颜色',
|
||||
title: "文字颜色",
|
||||
items: [
|
||||
{ key: '--ss-text-main', label: '主要文字' },
|
||||
{ key: '--ss-text-secondary', label: '次要文字' },
|
||||
{ key: '--ss-sidebar-active-text', label: '侧边栏选中文字' }
|
||||
]
|
||||
{ key: "--ss-text-main", label: "主要文字" },
|
||||
{ key: "--ss-text-secondary", label: "次要文字" },
|
||||
{ key: "--ss-sidebar-active-text", label: "侧边栏选中文字" },
|
||||
],
|
||||
},
|
||||
border: {
|
||||
title: '边框与分割线',
|
||||
items: [{ key: '--ss-border-color', label: '通用边框' }]
|
||||
title: "边框与分割线",
|
||||
items: [{ key: "--ss-border-color", label: "通用边框" }],
|
||||
},
|
||||
interaction: {
|
||||
title: '交互状态',
|
||||
title: "交互状态",
|
||||
items: [
|
||||
{ key: '--ss-item-hover', label: '列表悬浮' },
|
||||
{ key: '--ss-sidebar-active-bg', label: '侧边栏选中背景' }
|
||||
]
|
||||
}
|
||||
{ key: "--ss-item-hover", label: "列表悬浮" },
|
||||
{ key: "--ss-sidebar-active-bg", label: "侧边栏选中背景" },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const ThemeEditor: React.FC = () => {
|
||||
@@ -44,7 +44,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
updateEditingTheme,
|
||||
updateConfig,
|
||||
saveEditingTheme,
|
||||
cancelEditing
|
||||
cancelEditing,
|
||||
} = useThemeEditor()
|
||||
|
||||
const { currentTheme } = useTheme()
|
||||
@@ -56,7 +56,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
const { tdesign, custom } = theme.config
|
||||
const root = document.documentElement
|
||||
|
||||
root.setAttribute('theme-mode', theme.mode)
|
||||
root.setAttribute("theme-mode", theme.mode)
|
||||
|
||||
if (tdesign.brandColor) {
|
||||
const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
|
||||
@@ -66,7 +66,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
}
|
||||
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
root.style.setProperty(key, String(value))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
const { tdesign, custom } = currentTheme.config
|
||||
const root = document.documentElement
|
||||
|
||||
root.setAttribute('theme-mode', currentTheme.mode)
|
||||
root.setAttribute("theme-mode", currentTheme.mode)
|
||||
|
||||
if (tdesign.brandColor) {
|
||||
const colorMap = generateColorMap(tdesign.brandColor, currentTheme.mode)
|
||||
@@ -88,7 +88,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
}
|
||||
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
root.style.setProperty(key, String(value))
|
||||
})
|
||||
}
|
||||
}, [isEditing, currentTheme])
|
||||
@@ -112,7 +112,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="large">
|
||||
<Space orientation="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Divider>基本信息</Divider>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -129,10 +129,10 @@ export const ThemeEditor: React.FC = () => {
|
||||
<Form.Item label="色彩模式">
|
||||
<Select
|
||||
value={editingTheme.mode}
|
||||
onChange={(v) => updateEditingTheme({ mode: v as 'light' | 'dark' })}
|
||||
onChange={(v) => updateEditingTheme({ mode: v as "light" | "dark" })}
|
||||
options={[
|
||||
{ label: '浅色 (Light)', value: 'light' },
|
||||
{ label: '深色 (Dark)', value: 'dark' }
|
||||
{ label: "浅色 (Light)", value: "light" },
|
||||
{ label: "深色 (Dark)", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -155,7 +155,7 @@ export const ThemeEditor: React.FC = () => {
|
||||
<ColorPicker
|
||||
value={editingTheme.config.tdesign.brandColor}
|
||||
onChange={(color: Color) =>
|
||||
updateConfig('tdesign', 'brandColor', color.toHexString())
|
||||
updateConfig("tdesign", "brandColor", color.toHexString())
|
||||
}
|
||||
showText
|
||||
/>
|
||||
@@ -168,53 +168,53 @@ export const ThemeEditor: React.FC = () => {
|
||||
<div key={groupKey} style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
color: 'var(--ss-text-main)'
|
||||
marginBottom: "12px",
|
||||
color: "var(--ss-text-main)",
|
||||
}}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{group.items.map((item) => (
|
||||
<Col span={groupKey === 'background' ? 12 : 6} key={item.key}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Col span={groupKey === "background" ? 12 : 6} key={item.key}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--ss-text-secondary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
fontSize: "12px",
|
||||
color: "var(--ss-text-secondary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{groupKey === 'background' ? (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{groupKey === "background" ? (
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
background: editingTheme.config.custom[item.key] || '#ffffff',
|
||||
flexShrink: 0
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
background: editingTheme.config.custom[item.key] || "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={editingTheme.config.custom[item.key] || ''}
|
||||
onChange={(e) => updateConfig('custom', item.key, e.target.value)}
|
||||
value={editingTheme.config.custom[item.key] || ""}
|
||||
onChange={(e) => updateConfig("custom", item.key, e.target.value)}
|
||||
placeholder="例如 #ffffff 或 linear-gradient(...)"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ColorPicker
|
||||
value={editingTheme.config.custom[item.key] || '#ffffff'}
|
||||
value={editingTheme.config.custom[item.key] || "#ffffff"}
|
||||
onChange={(color: Color) =>
|
||||
updateConfig('custom', item.key, color.toHexString())
|
||||
updateConfig("custom", item.key, color.toHexString())
|
||||
}
|
||||
showText
|
||||
/>
|
||||
@@ -1,24 +1,24 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd'
|
||||
import type { Color } from 'antd/es/color-picker'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import type { themeConfig } from '../../../preload/types'
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from "antd"
|
||||
import type { Color } from "antd/es/color-picker"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useTheme } from "../contexts/ThemeContext"
|
||||
import type { themeConfig } from "../preload/types"
|
||||
|
||||
type props = {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const presetPrimaryColors = [
|
||||
'#1677FF',
|
||||
'#2F54EB',
|
||||
'#722ED1',
|
||||
'#EB2F96',
|
||||
'#F5222D',
|
||||
'#FA8C16',
|
||||
'#FADB14',
|
||||
'#52C41A',
|
||||
'#13C2C2'
|
||||
"#1677FF",
|
||||
"#2F54EB",
|
||||
"#722ED1",
|
||||
"#EB2F96",
|
||||
"#F5222D",
|
||||
"#FA8C16",
|
||||
"#FADB14",
|
||||
"#52C41A",
|
||||
"#13C2C2",
|
||||
]
|
||||
|
||||
const presetGradients: {
|
||||
@@ -28,43 +28,43 @@ const presetGradients: {
|
||||
dark: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'blue',
|
||||
labelKey: 'blue',
|
||||
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
||||
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
|
||||
id: "blue",
|
||||
labelKey: "blue",
|
||||
light: "linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)",
|
||||
dark: "linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)",
|
||||
},
|
||||
{
|
||||
id: 'pink',
|
||||
labelKey: 'pink',
|
||||
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
||||
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
|
||||
id: "pink",
|
||||
labelKey: "pink",
|
||||
light: "linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)",
|
||||
dark: "linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)",
|
||||
},
|
||||
{
|
||||
id: 'cyan',
|
||||
labelKey: 'cyan',
|
||||
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
|
||||
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
|
||||
id: "cyan",
|
||||
labelKey: "cyan",
|
||||
light: "linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)",
|
||||
dark: "linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)",
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
labelKey: 'purple',
|
||||
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)',
|
||||
dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)'
|
||||
}
|
||||
id: "purple",
|
||||
labelKey: "purple",
|
||||
light: "linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)",
|
||||
dark: "linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)",
|
||||
},
|
||||
]
|
||||
|
||||
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
|
||||
|
||||
const normalizeHex = (value: string): string | null => {
|
||||
const s = String(value || '').trim()
|
||||
const s = String(value || "").trim()
|
||||
if (!s) return null
|
||||
const m = s.match(/^#?([0-9a-fA-F]{6})$/)
|
||||
if (!m) return null
|
||||
return `#${m[1].toUpperCase()}`
|
||||
}
|
||||
|
||||
const buildGradient = (a: string, b: string, dir: 'v' | 'h' | 'd'): string => {
|
||||
const angle = dir === 'h' ? '90deg' : dir === 'd' ? '135deg' : '180deg'
|
||||
const buildGradient = (a: string, b: string, dir: "v" | "h" | "d"): string => {
|
||||
const angle = dir === "h" ? "90deg" : dir === "d" ? "135deg" : "180deg"
|
||||
return `linear-gradient(${angle}, ${a} 0%, ${b} 100%)`
|
||||
}
|
||||
|
||||
@@ -76,27 +76,27 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
const [workingTheme, setWorkingTheme] = useState<themeConfig | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [primaryInput, setPrimaryInput] = useState('')
|
||||
const [primaryInput, setPrimaryInput] = useState("")
|
||||
const primaryColor = useMemo(() => {
|
||||
const fromTheme = workingTheme?.config?.tdesign?.brandColor
|
||||
return normalizeHex(primaryInput) || normalizeHex(fromTheme || '') || '#1677FF'
|
||||
return normalizeHex(primaryInput) || normalizeHex(fromTheme || "") || "#1677FF"
|
||||
}, [primaryInput, workingTheme])
|
||||
|
||||
const [gradientDir, setGradientDir] = useState<'v' | 'h' | 'd'>('v')
|
||||
const [g1, setG1] = useState<string>('#f7fbff')
|
||||
const [g2, setG2] = useState<string>('#f8f9fc')
|
||||
const [gradientDir, setGradientDir] = useState<"v" | "h" | "d">("v")
|
||||
const [g1, setG1] = useState<string>("#f7fbff")
|
||||
const [g2, setG2] = useState<string>("#f8f9fc")
|
||||
|
||||
const currentBg = workingTheme?.config?.custom?.['--ss-bg-color'] || ''
|
||||
const currentBg = workingTheme?.config?.custom?.["--ss-bg-color"] || ""
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTheme) return
|
||||
const base = deepClone(currentTheme)
|
||||
const editable =
|
||||
base.id === 'custom-default' || base.id.startsWith('custom-')
|
||||
base.id === "custom-default" || base.id.startsWith("custom-")
|
||||
? base
|
||||
: { ...base, id: 'custom-default', name: t('theme.myTheme') }
|
||||
: { ...base, id: "custom-default", name: t("theme.myTheme") }
|
||||
setWorkingTheme(editable)
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || "")
|
||||
}, [currentTheme])
|
||||
|
||||
// Real-time theme preview
|
||||
@@ -142,12 +142,12 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const ok = await ensureCustomThemeSelected(workingTheme)
|
||||
if (!ok) throw new Error('保存失败')
|
||||
if (!ok) throw new Error("保存失败")
|
||||
const res = await (window as any).api.saveTheme(workingTheme)
|
||||
if (!res?.success) throw new Error(res?.message || '保存失败')
|
||||
messageApi.success(t('theme.saved'))
|
||||
if (!res?.success) throw new Error(res?.message || "保存失败")
|
||||
messageApi.success(t("theme.saved"))
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || t('theme.saveFailed'))
|
||||
messageApi.error(e?.message || t("theme.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
|
||||
const setPrimary = async (hex: string) => {
|
||||
setPrimaryInput(hex)
|
||||
setWorkingTheme((prev) => {
|
||||
setWorkingTheme((prev: themeConfig | null) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
@@ -170,7 +170,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': value }
|
||||
next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": value }
|
||||
saveThemeToDb(next)
|
||||
return next
|
||||
})
|
||||
@@ -178,51 +178,51 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
|
||||
const setGradientFromPickers = async () => {
|
||||
const g = buildGradient(g1, g2, gradientDir)
|
||||
setWorkingTheme((prev) => {
|
||||
setWorkingTheme((prev: themeConfig | null) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': g }
|
||||
next.config.custom = { ...(next.config.custom || {}), "--ss-bg-color": g }
|
||||
saveThemeToDb(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setMode = async (mode: 'light' | 'dark') => {
|
||||
const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
|
||||
const setMode = async (mode: "light" | "dark") => {
|
||||
const targetThemeId = mode === "light" ? "light-default" : "dark-default"
|
||||
await setTheme(targetThemeId)
|
||||
}
|
||||
|
||||
if (!workingTheme) return null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: compact ? 12 : 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: compact ? 12 : 16 }}>
|
||||
{holder}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong>{t('theme.colorAndBackground')}</Typography.Text>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography.Text strong>{t("theme.colorAndBackground")}</Typography.Text>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={save}>
|
||||
{t('common.save')}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>{t('theme.mode')}</Typography.Text>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text>{t("theme.mode")}</Typography.Text>
|
||||
<Segmented
|
||||
value={workingTheme.mode}
|
||||
onChange={(v) => setMode(v as any)}
|
||||
options={[
|
||||
{ label: t('settings.lightMode'), value: 'light' },
|
||||
{ label: t('settings.darkMode'), value: 'dark' }
|
||||
{ label: t("settings.lightMode"), value: "light" },
|
||||
{ label: t("settings.darkMode"), value: "dark" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>{t('settings.primaryColor')}</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text>{t("settings.primaryColor")}</Typography.Text>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Input
|
||||
value={primaryInput}
|
||||
onChange={(e) => setPrimaryInput(e.target.value)}
|
||||
@@ -243,10 +243,10 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
border:
|
||||
c === primaryColor ? '2px solid rgba(0,0,0,0.35)' : '1px solid rgba(0,0,0,0.18)',
|
||||
c === primaryColor ? "2px solid rgba(0,0,0,0.35)" : "1px solid rgba(0,0,0,0.18)",
|
||||
background: c,
|
||||
cursor: 'pointer',
|
||||
padding: 0
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -257,13 +257,13 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>{t('settings.backgroundGradient')}</Typography.Text>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Typography.Text>{t("settings.backgroundGradient")}</Typography.Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 10
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{presetGradients.map((g) => {
|
||||
@@ -277,23 +277,23 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: active
|
||||
? '2px solid var(--ant-color-primary)'
|
||||
: '1px solid var(--ss-border-color)',
|
||||
? "2px solid var(--ant-color-primary)"
|
||||
: "1px solid var(--ss-border-color)",
|
||||
padding: 10,
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left'
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 56,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
background: gradientValue
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
background: gradientValue,
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ss-text-secondary)' }}>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "var(--ss-text-secondary)" }}>
|
||||
{t(`theme.gradientLabels.${g.labelKey}`)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -301,28 +301,28 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Segmented
|
||||
value={gradientDir}
|
||||
onChange={(v) => setGradientDir(v as any)}
|
||||
options={[
|
||||
{ label: t('theme.gradientDirections.vertical'), value: 'v' },
|
||||
{ label: t('theme.gradientDirections.horizontal'), value: 'h' },
|
||||
{ label: t('theme.gradientDirections.diagonal'), value: 'd' }
|
||||
{ label: t("theme.gradientDirections.vertical"), value: "v" },
|
||||
{ label: t("theme.gradientDirections.horizontal"), value: "h" },
|
||||
{ label: t("theme.gradientDirections.diagonal"), value: "d" },
|
||||
]}
|
||||
/>
|
||||
<Space size="small">
|
||||
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
|
||||
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
|
||||
<Button onClick={setGradientFromPickers}>{t('theme.generate')}</Button>
|
||||
<Button onClick={setGradientFromPickers}>{t("theme.generate")}</Button>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
width: 160,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--ss-border-color)',
|
||||
background: buildGradient(g1, g2, gradientDir)
|
||||
border: "1px solid var(--ss-border-color)",
|
||||
background: buildGradient(g1, g2, gradientDir),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from 'antd'
|
||||
import { Button } from "antd"
|
||||
import {
|
||||
MinusOutlined,
|
||||
BorderOutlined,
|
||||
CloseOutlined,
|
||||
FullscreenExitOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
import electronLogo from '../assets/electron.svg'
|
||||
FullscreenExitOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useEffect, useState } from "react"
|
||||
import electronLogo from "../assets/electron.svg"
|
||||
|
||||
interface TitleBarProps {
|
||||
children?: React.ReactNode
|
||||
@@ -43,32 +43,32 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
|
||||
<div
|
||||
style={
|
||||
{
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 0 0 16px',
|
||||
backgroundColor: 'var(--ss-header-bg, #ffffff)',
|
||||
borderBottom: '1px solid var(--ss-border-color, #e7e7e7)',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
height: "32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0 0 0 16px",
|
||||
backgroundColor: "var(--ss-header-bg, #ffffff)",
|
||||
borderBottom: "1px solid var(--ss-border-color, #e7e7e7)",
|
||||
WebkitAppRegion: "drag",
|
||||
userSelect: "none",
|
||||
zIndex: 1000,
|
||||
transition: 'background-color 0.3s, border-color 0.3s'
|
||||
transition: "background-color 0.3s, border-color 0.3s",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
color: 'var(--ss-text-main, #000000)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0
|
||||
color: "var(--ss-text-main, #000000)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<img src={electronLogo} alt="logo" style={{ width: '16px', height: '16px' }} />
|
||||
<img src={electronLogo} alt="logo" style={{ width: "16px", height: "16px" }} />
|
||||
<span>SecScore</span>
|
||||
</div>
|
||||
|
||||
@@ -77,11 +77,11 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
|
||||
<div
|
||||
style={
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
paddingRight: '12px'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
WebkitAppRegion: "no-drag",
|
||||
paddingRight: "12px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -91,37 +91,37 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
|
||||
<div
|
||||
style={
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
height: '100%',
|
||||
flexShrink: 0
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
WebkitAppRegion: "no-drag",
|
||||
height: "100%",
|
||||
flexShrink: 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={minimize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={maximize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<FullscreenExitOutlined style={{ transform: 'scale(0.8)' }} />
|
||||
<FullscreenExitOutlined style={{ transform: "scale(0.8)" }} />
|
||||
) : (
|
||||
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
|
||||
<BorderOutlined style={{ transform: "scale(0.8)" }} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={close}
|
||||
className="titlebar-close-btn"
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useState } from "react"
|
||||
|
||||
function Versions(): React.JSX.Element {
|
||||
const [versions] = useState({
|
||||
tauri: "2.x",
|
||||
webview: navigator.userAgent.match(/Chrome\/(\d+)/)?.[1] || "unknown",
|
||||
})
|
||||
|
||||
return (
|
||||
<ul className="versions">
|
||||
<li className="tauri-version">Tauri v{versions.tauri}</li>
|
||||
<li className="webview-version">WebView Chrome v{versions.webview}</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default Versions
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button } from 'antd'
|
||||
import { Button } from "antd"
|
||||
import {
|
||||
MinusOutlined,
|
||||
BorderOutlined,
|
||||
CloseOutlined,
|
||||
FullscreenExitOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
FullscreenExitOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function WindowControls(): React.JSX.Element {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
@@ -38,37 +38,37 @@ export function WindowControls(): React.JSX.Element {
|
||||
<div
|
||||
style={
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
height: '100%',
|
||||
flexShrink: 0
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
WebkitAppRegion: "no-drag",
|
||||
height: "100%",
|
||||
flexShrink: 0,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={minimize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={maximize}
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<FullscreenExitOutlined />
|
||||
) : (
|
||||
<BorderOutlined style={{ transform: 'scale(0.8)' }} />
|
||||
<BorderOutlined style={{ transform: "scale(0.8)" }} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={close}
|
||||
className="titlebar-close-btn"
|
||||
style={{ width: '46px', height: '32px', borderRadius: 0 }}
|
||||
style={{ width: "46px", height: "32px", borderRadius: 0 }}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Modal, message, Typography } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
||||
import React, { useState } from "react"
|
||||
import { Modal, message, Typography } from "antd"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ThemeQuickSettings } from "./ThemeQuickSettings"
|
||||
|
||||
interface wizardProps {
|
||||
visible: boolean
|
||||
@@ -16,14 +16,14 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
const handleFinish = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!(window as any).api) throw new Error('api not ready')
|
||||
const res = await (window as any).api.setSetting('is_wizard_completed', true)
|
||||
if (!res?.success) throw new Error(res?.message || 'failed')
|
||||
if (!(window as any).api) throw new Error("api not ready")
|
||||
const res = await (window as any).api.setSetting("is_wizard_completed", true)
|
||||
if (!res?.success) throw new Error(res?.message || "failed")
|
||||
|
||||
messageApi.success(t('wizard.configComplete'))
|
||||
messageApi.success(t("wizard.configComplete"))
|
||||
onComplete()
|
||||
} catch {
|
||||
messageApi.error(t('wizard.configFailed'))
|
||||
messageApi.error(t("wizard.configFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -31,21 +31,21 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('wizard.welcomeTitle')}
|
||||
title={t("wizard.welcomeTitle")}
|
||||
open={visible}
|
||||
onOk={handleFinish}
|
||||
onCancel={() => {}}
|
||||
confirmLoading={loading}
|
||||
okText={t('wizard.startJourney')}
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
okText={t("wizard.startJourney")}
|
||||
cancelButtonProps={{ style: { display: "none" } }}
|
||||
closable={false}
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
width={500}
|
||||
>
|
||||
{contextHolder}
|
||||
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
|
||||
{t('wizard.welcomeDesc')}
|
||||
<Typography.Paragraph style={{ marginBottom: "24px", color: "var(--ss-text-secondary)" }}>
|
||||
{t("wizard.welcomeDesc")}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<ThemeQuickSettings compact />
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Space, Input, InputNumber, Select, Button } from 'antd'
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Space, Input, InputNumber, Select, Button } from "antd"
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const SCORE_RANGE = { MIN: -999, MAX: 999 }
|
||||
|
||||
const ACTION_DEFINITIONS = [
|
||||
{ eventName: 'add_score', labelKey: 'autoScore.actionAddScore' },
|
||||
{ eventName: 'add_tag', labelKey: 'autoScore.actionAddTag' }
|
||||
{ eventName: "add_score", labelKey: "autoScore.actionAddScore" },
|
||||
{ eventName: "add_tag", labelKey: "autoScore.actionAddTag" },
|
||||
]
|
||||
|
||||
interface ActionItem {
|
||||
@@ -27,7 +27,7 @@ export const ActionComponent: React.FC<ActionComponentProps> = ({
|
||||
actions,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onChange
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -37,52 +37,52 @@ export const ActionComponent: React.FC<ActionComponentProps> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'var(--ss-card-bg)'
|
||||
padding: "12px",
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "var(--ss-card-bg)",
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%' }} wrap>
|
||||
<Space style={{ width: "100%" }} wrap>
|
||||
<Select
|
||||
value={action.eventName}
|
||||
onChange={(value) => handleActionChange(action.id, 'eventName', value)}
|
||||
onChange={(value) => handleActionChange(action.id, "eventName", value)}
|
||||
style={{ width: 150 }}
|
||||
options={ACTION_DEFINITIONS.map((d) => ({
|
||||
label: t(d.labelKey),
|
||||
value: d.eventName
|
||||
value: d.eventName,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{action.eventName === 'add_score' && (
|
||||
{action.eventName === "add_score" && (
|
||||
<InputNumber
|
||||
value={action.value ? parseInt(action.value) : undefined}
|
||||
onChange={(value) => handleActionChange(action.id, 'value', String(value || 0))}
|
||||
placeholder={t('autoScore.scorePlaceholder')}
|
||||
onChange={(value) => handleActionChange(action.id, "value", String(value || 0))}
|
||||
placeholder={t("autoScore.scorePlaceholder")}
|
||||
min={SCORE_RANGE.MIN}
|
||||
max={SCORE_RANGE.MAX}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{action.eventName === 'add_tag' && (
|
||||
{action.eventName === "add_tag" && (
|
||||
<Input
|
||||
value={action.value}
|
||||
onChange={(e) => handleActionChange(action.id, 'value', e.target.value)}
|
||||
placeholder={t('autoScore.tagNamePlaceholder')}
|
||||
onChange={(e) => handleActionChange(action.id, "value", e.target.value)}
|
||||
placeholder={t("autoScore.tagNamePlaceholder")}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
value={action.reason}
|
||||
onChange={(e) => handleActionChange(action.id, 'reason', e.target.value)}
|
||||
placeholder={t('autoScore.reasonPlaceholder')}
|
||||
onChange={(e) => handleActionChange(action.id, "reason", e.target.value)}
|
||||
placeholder={t("autoScore.reasonPlaceholder")}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ActionComponent: React.FC<ActionComponentProps> = ({
|
||||
))}
|
||||
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={onAdd} block>
|
||||
{t('autoScore.addAction')}
|
||||
{t("autoScore.addAction")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
.betweenRules::before,
|
||||
.betweenRules::after {
|
||||
content: '';
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--ss-border-color, #d9d9d9);
|
||||
@@ -185,64 +185,64 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .ruleGroup {
|
||||
[theme-mode="dark"] .ruleGroup {
|
||||
background-color: var(--ss-card-bg, #1f1f1f);
|
||||
border-color: var(--ss-border-color, #424242);
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .rule {
|
||||
[theme-mode="dark"] .rule {
|
||||
background-color: var(--ss-item-bg, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .rule:hover {
|
||||
[theme-mode="dark"] .rule:hover {
|
||||
background-color: var(--ss-item-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .rule-fields .ant-select-selector,
|
||||
[theme-mode='dark'] .rule-operators .ant-select-selector,
|
||||
[theme-mode='dark'] .rule-subfields .ant-select-selector,
|
||||
[theme-mode='dark'] .rule-value .ant-input,
|
||||
[theme-mode='dark'] .rule-value .ant-input-number,
|
||||
[theme-mode='dark'] .rule-value .ant-select-selector {
|
||||
[theme-mode="dark"] .rule-fields .ant-select-selector,
|
||||
[theme-mode="dark"] .rule-operators .ant-select-selector,
|
||||
[theme-mode="dark"] .rule-subfields .ant-select-selector,
|
||||
[theme-mode="dark"] .rule-value .ant-input,
|
||||
[theme-mode="dark"] .rule-value .ant-input-number,
|
||||
[theme-mode="dark"] .rule-value .ant-select-selector {
|
||||
background-color: var(--ss-input-bg, #141414) !important;
|
||||
border-color: var(--ss-border-color, #424242) !important;
|
||||
color: var(--ss-text-main, rgba(255, 255, 255, 0.85)) !important;
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .rule-value .ant-input-number-input {
|
||||
[theme-mode="dark"] .rule-value .ant-input-number-input {
|
||||
color: var(--ss-text-main, rgba(255, 255, 255, 0.85)) !important;
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .betweenRules {
|
||||
[theme-mode="dark"] .betweenRules {
|
||||
color: var(--ss-text-secondary, rgba(255, 255, 255, 0.45));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .betweenRules::before,
|
||||
[theme-mode='dark'] .betweenRules::after {
|
||||
[theme-mode="dark"] .betweenRules::before,
|
||||
[theme-mode="dark"] .betweenRules::after {
|
||||
background-color: var(--ss-border-color, #424242);
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .dragHandle {
|
||||
[theme-mode="dark"] .dragHandle {
|
||||
color: var(--ss-text-secondary, rgba(255, 255, 255, 0.45));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .dragHandle:hover {
|
||||
[theme-mode="dark"] .dragHandle:hover {
|
||||
color: var(--ss-text-main, rgba(255, 255, 255, 0.85));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .queryBuilder .ant-select-dropdown {
|
||||
[theme-mode="dark"] .queryBuilder .ant-select-dropdown {
|
||||
background-color: var(--ss-card-bg, #1f1f1f);
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .queryBuilder .ant-select-item {
|
||||
[theme-mode="dark"] .queryBuilder .ant-select-item {
|
||||
color: var(--ss-text-main, rgba(255, 255, 255, 0.85));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .queryBuilder .ant-select-item-option-active {
|
||||
[theme-mode="dark"] .queryBuilder .ant-select-item-option-active {
|
||||
background-color: var(--ss-item-hover, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
[theme-mode='dark'] .queryBuilder .ant-select-item-option-selected {
|
||||
[theme-mode="dark"] .queryBuilder .ant-select-item-option-selected {
|
||||
background-color: rgba(var(--ss-primary-rgb, 22, 119, 255), 0.2);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { RuleGroupType, Field, RuleType } from 'react-querybuilder'
|
||||
import { defaultOperators } from 'react-querybuilder'
|
||||
import { fetchAllTags } from '../TagEditorDialog'
|
||||
import type { RuleGroupType, Field, RuleType } from "react-querybuilder"
|
||||
import { defaultOperators } from "react-querybuilder"
|
||||
import { fetchAllTags } from "../TagEditorDialog"
|
||||
|
||||
const tags = await fetchAllTags()
|
||||
|
||||
export interface AutoScoreTrigger {
|
||||
event: string
|
||||
value?: string
|
||||
relation?: 'AND' | 'OR'
|
||||
relation?: "AND" | "OR"
|
||||
}
|
||||
|
||||
export interface AutoScoreAction {
|
||||
@@ -25,41 +25,41 @@ export const validator = (r: RuleType) => !!r.value
|
||||
|
||||
export const getFields = (t: (key: string) => string): Field[] => [
|
||||
{
|
||||
name: 'student_has_tag',
|
||||
label: t('triggers.studentTag.label'),
|
||||
placeholder: t('triggers.studentTag.placeholder'),
|
||||
valueEditorType: 'multiselect',
|
||||
values: tags.map((tag) => tag.name),
|
||||
name: "student_has_tag",
|
||||
label: t("triggers.studentTag.label"),
|
||||
placeholder: t("triggers.studentTag.placeholder"),
|
||||
valueEditorType: "multiselect",
|
||||
values: tags.map((tag: { id: number; name: string }) => tag.name),
|
||||
defaultValue: tags.length > 0 ? [tags[0].name] : [],
|
||||
operators: defaultOperators.filter((op) => op.name === 'in')
|
||||
operators: defaultOperators.filter((op) => op.name === "in"),
|
||||
},
|
||||
{
|
||||
name: 'interval_time_passed',
|
||||
label: t('triggers.intervalTime.label'),
|
||||
matchModes: ['all'],
|
||||
name: "interval_time_passed",
|
||||
label: t("triggers.intervalTime.label"),
|
||||
matchModes: ["all"],
|
||||
subproperties: [
|
||||
{
|
||||
name: 'month',
|
||||
label: t('triggers.intervalTime.monthLabel'),
|
||||
inputType: 'number',
|
||||
datatype: 'month',
|
||||
operators: ['=']
|
||||
name: "month",
|
||||
label: t("triggers.intervalTime.monthLabel"),
|
||||
inputType: "number",
|
||||
datatype: "month",
|
||||
operators: ["="],
|
||||
},
|
||||
/* { name: 'week', label: t('triggers.intervalTime.weekLabel'), inputType: 'week', datatype: 'week', operators: ['='] },
|
||||
*/ {
|
||||
name: 'time',
|
||||
label: t('triggers.intervalTime.timeLabel'),
|
||||
inputType: 'time',
|
||||
datatype: 'time',
|
||||
operators: ['=']
|
||||
}
|
||||
]
|
||||
}
|
||||
name: "time",
|
||||
label: t("triggers.intervalTime.timeLabel"),
|
||||
inputType: "time",
|
||||
datatype: "time",
|
||||
operators: ["="],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const defaultQuery: RuleGroupType = {
|
||||
combinator: 'and',
|
||||
rules: [{ field: 'interval_time_passed', operator: '=', value: '1440' }]
|
||||
combinator: "and",
|
||||
rules: [{ field: "interval_time_passed", operator: "=", value: "1440" }],
|
||||
}
|
||||
|
||||
export function queryToAutoScoreRule(_query: RuleGroupType): AutoScoreRuleData {
|
||||
@@ -88,30 +88,30 @@ export function queryToAutoScoreRule(_query: RuleGroupType): AutoScoreRuleData {
|
||||
|
||||
return {
|
||||
triggers,
|
||||
actions: []
|
||||
actions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function autoScoreRuleToQuery(ruleData: AutoScoreRuleData): RuleGroupType {
|
||||
const rules: any[] = []
|
||||
let currentCombinator: 'and' | 'or' = 'and'
|
||||
let currentCombinator: "and" | "or" = "and"
|
||||
|
||||
ruleData.triggers.forEach((trigger, index) => {
|
||||
if (index === 0) {
|
||||
currentCombinator = 'and'
|
||||
} else if (trigger.relation === 'OR') {
|
||||
currentCombinator = 'or'
|
||||
currentCombinator = "and"
|
||||
} else if (trigger.relation === "OR") {
|
||||
currentCombinator = "or"
|
||||
}
|
||||
|
||||
rules.push({
|
||||
field: trigger.event,
|
||||
operator: '=',
|
||||
value: trigger.value || ''
|
||||
operator: "=",
|
||||
value: trigger.value || "",
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
combinator: currentCombinator,
|
||||
rules: rules.length > 0 ? rules : defaultQuery.rules
|
||||
rules: rules.length > 0 ? rules : defaultQuery.rules,
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { formatQuery, QueryBuilder, RuleGroupType } from 'react-querybuilder'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QueryBuilderDnD } from '@react-querybuilder/dnd'
|
||||
import * as ReactDnD from 'react-dnd'
|
||||
import { QueryBuilderAntD } from '@react-querybuilder/antd'
|
||||
import * as ReactDndHtml5Backend from 'react-dnd-html5-backend'
|
||||
import * as ReactDndTouchBackend from 'react-dnd-touch-backend'
|
||||
import { formatQuery, QueryBuilder, RuleGroupType } from "react-querybuilder"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { QueryBuilderDnD } from "@react-querybuilder/dnd"
|
||||
import * as ReactDnD from "react-dnd"
|
||||
import { QueryBuilderAntD } from "@react-querybuilder/antd"
|
||||
import * as ReactDndHtml5Backend from "react-dnd-html5-backend"
|
||||
import * as ReactDndTouchBackend from "react-dnd-touch-backend"
|
||||
import {
|
||||
getFields,
|
||||
defaultQuery,
|
||||
autoScoreRuleToQuery,
|
||||
queryToAutoScoreRule,
|
||||
type AutoScoreRuleData
|
||||
} from './ruleBuilderUtils'
|
||||
import { Card } from 'antd'
|
||||
type AutoScoreRuleData,
|
||||
} from "./ruleBuilderUtils"
|
||||
import { Card } from "antd"
|
||||
|
||||
import './ruleBuilderOverride.css'
|
||||
import "./ruleBuilderOverride.css"
|
||||
|
||||
interface RuleComponentProps {
|
||||
initialData?: AutoScoreRuleData
|
||||
@@ -45,16 +45,16 @@ export const RuleComponent: React.FC<RuleComponentProps> = ({ initialData, onCha
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={t('autoScore.triggerCondition')}
|
||||
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
||||
title={t("autoScore.triggerCondition")}
|
||||
style={{ marginBottom: "24px", backgroundColor: "var(--ss-card-bg)" }}
|
||||
>
|
||||
<QueryBuilderDnD dnd={{ ...ReactDnD, ...ReactDndHtml5Backend, ...ReactDndTouchBackend }}>
|
||||
<QueryBuilderAntD>
|
||||
<QueryBuilder fields={getFields(t)} query={query} onQueryChange={handleQueryChange} />
|
||||
</QueryBuilderAntD>
|
||||
</QueryBuilderDnD>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<pre style={{ fontSize: '12px', color: '#999' }}>{formatQuery(query, 'json')}</pre>
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
<pre style={{ fontSize: "12px", color: "#999" }}>{formatQuery(query, "json")}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { ClientContext } from '../ClientContext'
|
||||
import { createContext, useContext } from "react"
|
||||
import { ClientContext } from "../ClientContext"
|
||||
|
||||
const ServiceContext = createContext<ClientContext | null>(null)
|
||||
|
||||
@@ -7,6 +7,6 @@ export const ServiceProvider = ServiceContext.Provider
|
||||
|
||||
export const useService = () => {
|
||||
const ctx = useContext(ServiceContext)
|
||||
if (!ctx) throw new Error('No ServiceProvider')
|
||||
if (!ctx) throw new Error("No ServiceProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
import { generateColorMap } from '../utils/color'
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"
|
||||
import { themeConfig } from "../preload/types"
|
||||
import { generateColorMap } from "../utils/color"
|
||||
|
||||
interface themeContextType {
|
||||
currentTheme: themeConfig | null
|
||||
@@ -24,18 +24,18 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
for (const k of prevKeys) root.style.removeProperty(k)
|
||||
const nextKeys: string[] = []
|
||||
|
||||
root.setAttribute('theme-mode', theme.mode)
|
||||
root.setAttribute("theme-mode", theme.mode)
|
||||
|
||||
const brandColor = tdesign.brandColor || '#1677FF'
|
||||
const hex = brandColor.replace('#', '')
|
||||
const brandColor = tdesign.brandColor || "#1677FF"
|
||||
const hex = brandColor.replace("#", "")
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
|
||||
root.style.setProperty('--ss-primary-color', brandColor)
|
||||
root.style.setProperty('--ss-primary-rgb', `${r}, ${g}, ${b}`)
|
||||
nextKeys.push('--ss-primary-color')
|
||||
nextKeys.push('--ss-primary-rgb')
|
||||
root.style.setProperty("--ss-primary-color", brandColor)
|
||||
root.style.setProperty("--ss-primary-rgb", `${r}, ${g}, ${b}`)
|
||||
nextKeys.push("--ss-primary-color")
|
||||
nextKeys.push("--ss-primary-rgb")
|
||||
|
||||
if (brandColor) {
|
||||
const colorMap = generateColorMap(brandColor, theme.mode)
|
||||
@@ -44,58 +44,58 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
nextKeys.push(key)
|
||||
})
|
||||
|
||||
root.style.setProperty('--ant-color-primary', brandColor)
|
||||
nextKeys.push('--ant-color-primary')
|
||||
root.style.setProperty("--ant-color-primary", brandColor)
|
||||
nextKeys.push("--ant-color-primary")
|
||||
|
||||
root.style.setProperty('--ant-color-primary-hover', `rgba(${r}, ${g}, ${b}, 0.85)`)
|
||||
root.style.setProperty('--ant-color-primary-active', `rgba(${r}, ${g}, ${b}, 0.7)`)
|
||||
root.style.setProperty('--ant-color-primary-bg', `rgba(${r}, ${g}, ${b}, 0.1)`)
|
||||
root.style.setProperty('--ant-color-primary-bg-hover', `rgba(${r}, ${g}, ${b}, 0.2)`)
|
||||
root.style.setProperty('--ant-color-primary-border', `rgba(${r}, ${g}, ${b}, 0.3)`)
|
||||
root.style.setProperty("--ant-color-primary-hover", `rgba(${r}, ${g}, ${b}, 0.85)`)
|
||||
root.style.setProperty("--ant-color-primary-active", `rgba(${r}, ${g}, ${b}, 0.7)`)
|
||||
root.style.setProperty("--ant-color-primary-bg", `rgba(${r}, ${g}, ${b}, 0.1)`)
|
||||
root.style.setProperty("--ant-color-primary-bg-hover", `rgba(${r}, ${g}, ${b}, 0.2)`)
|
||||
root.style.setProperty("--ant-color-primary-border", `rgba(${r}, ${g}, ${b}, 0.3)`)
|
||||
|
||||
nextKeys.push('--ant-color-primary-hover')
|
||||
nextKeys.push('--ant-color-primary-active')
|
||||
nextKeys.push('--ant-color-primary-bg')
|
||||
nextKeys.push('--ant-color-primary-bg-hover')
|
||||
nextKeys.push('--ant-color-primary-border')
|
||||
nextKeys.push("--ant-color-primary-hover")
|
||||
nextKeys.push("--ant-color-primary-active")
|
||||
nextKeys.push("--ant-color-primary-bg")
|
||||
nextKeys.push("--ant-color-primary-bg-hover")
|
||||
nextKeys.push("--ant-color-primary-border")
|
||||
}
|
||||
|
||||
if (tdesign.warningColor) {
|
||||
root.style.setProperty('--ant-color-warning', tdesign.warningColor)
|
||||
nextKeys.push('--ant-color-warning')
|
||||
root.style.setProperty("--ant-color-warning", tdesign.warningColor)
|
||||
nextKeys.push("--ant-color-warning")
|
||||
}
|
||||
if (tdesign.errorColor) {
|
||||
root.style.setProperty('--ant-color-error', tdesign.errorColor)
|
||||
nextKeys.push('--ant-color-error')
|
||||
root.style.setProperty("--ant-color-error", tdesign.errorColor)
|
||||
nextKeys.push("--ant-color-error")
|
||||
}
|
||||
if (tdesign.successColor) {
|
||||
root.style.setProperty('--ant-color-success', tdesign.successColor)
|
||||
nextKeys.push('--ant-color-success')
|
||||
root.style.setProperty("--ant-color-success", tdesign.successColor)
|
||||
nextKeys.push("--ant-color-success")
|
||||
}
|
||||
|
||||
Object.entries(custom).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value)
|
||||
root.style.setProperty(key, String(value))
|
||||
nextKeys.push(key)
|
||||
})
|
||||
|
||||
const bgLight = custom['--ss-bg-color-light']
|
||||
const bgDark = custom['--ss-bg-color-dark']
|
||||
const bgLight = custom["--ss-bg-color-light"]
|
||||
const bgDark = custom["--ss-bg-color-dark"]
|
||||
if (bgLight || bgDark) {
|
||||
const resolved = theme.mode === 'dark' ? bgDark || bgLight : bgLight || bgDark
|
||||
const resolved = theme.mode === "dark" ? bgDark || bgLight : bgLight || bgDark
|
||||
if (resolved) {
|
||||
root.style.setProperty('--ss-bg-color', resolved)
|
||||
nextKeys.push('--ss-bg-color')
|
||||
root.style.setProperty("--ss-bg-color", resolved)
|
||||
nextKeys.push("--ss-bg-color")
|
||||
}
|
||||
}
|
||||
|
||||
if (!custom['--ss-sidebar-active-bg']) {
|
||||
const alpha = theme.mode === 'dark' ? 0.22 : 0.12
|
||||
root.style.setProperty('--ss-sidebar-active-bg', `rgba(${r}, ${g}, ${b}, ${alpha})`)
|
||||
nextKeys.push('--ss-sidebar-active-bg')
|
||||
if (!custom["--ss-sidebar-active-bg"]) {
|
||||
const alpha = theme.mode === "dark" ? 0.22 : 0.12
|
||||
root.style.setProperty("--ss-sidebar-active-bg", `rgba(${r}, ${g}, ${b}, ${alpha})`)
|
||||
nextKeys.push("--ss-sidebar-active-bg")
|
||||
}
|
||||
if (!custom['--ss-sidebar-active-text']) {
|
||||
root.style.setProperty('--ss-sidebar-active-text', brandColor)
|
||||
nextKeys.push('--ss-sidebar-active-text')
|
||||
if (!custom["--ss-sidebar-active-text"]) {
|
||||
root.style.setProperty("--ss-sidebar-active-text", brandColor)
|
||||
nextKeys.push("--ss-sidebar-active-text")
|
||||
}
|
||||
|
||||
appliedStyleKeysRef.current = nextKeys
|
||||
@@ -126,7 +126,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
await loadCurrentTheme()
|
||||
})()
|
||||
|
||||
const unsubscribe = (window as any).api.onThemeChanged((theme) => {
|
||||
const unsubscribe = (window as any).api.onThemeChanged((theme: themeConfig) => {
|
||||
setCurrentTheme(theme)
|
||||
currentThemeRef.current = theme
|
||||
applyThemeConfig(theme)
|
||||
@@ -134,7 +134,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,6 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider')
|
||||
if (!context) throw new Error("useTheme must be used within ThemeProvider")
|
||||
return context
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react'
|
||||
import { themeConfig } from '../../../preload/types'
|
||||
import { useTheme } from './ThemeContext'
|
||||
import React, { createContext, useContext, useState, useCallback } from "react"
|
||||
import { themeConfig } from "../preload/types"
|
||||
import { useTheme } from "./ThemeContext"
|
||||
|
||||
interface ThemeEditorContextType {
|
||||
isEditing: boolean
|
||||
editingTheme: themeConfig | null
|
||||
startEditing: (theme?: themeConfig) => void
|
||||
updateEditingTheme: (updates: Partial<themeConfig>) => void
|
||||
updateConfig: (type: 'tdesign' | 'custom', key: string, value: string) => void
|
||||
updateConfig: (type: "tdesign" | "custom", key: string, value: string) => void
|
||||
saveEditingTheme: () => Promise<void>
|
||||
cancelEditing: () => void
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
} else if (currentTheme) {
|
||||
const newTheme: themeConfig = JSON.parse(JSON.stringify(currentTheme))
|
||||
newTheme.id = `custom-${Date.now()}`
|
||||
newTheme.name = '新自定义主题'
|
||||
newTheme.name = "新自定义主题"
|
||||
setEditingTheme(newTheme)
|
||||
}
|
||||
setIsEditing(true)
|
||||
@@ -35,11 +35,11 @@ export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
)
|
||||
|
||||
const updateEditingTheme = useCallback((updates: Partial<themeConfig>) => {
|
||||
setEditingTheme((prev) => (prev ? { ...prev, ...updates } : null))
|
||||
setEditingTheme((prev: themeConfig | null) => (prev ? { ...prev, ...updates } : null))
|
||||
}, [])
|
||||
|
||||
const updateConfig = useCallback((type: 'tdesign' | 'custom', key: string, value: string) => {
|
||||
setEditingTheme((prev) => {
|
||||
const updateConfig = useCallback((type: "tdesign" | "custom", key: string, value: string) => {
|
||||
setEditingTheme((prev: themeConfig | null) => {
|
||||
if (!prev) return null
|
||||
const next = { ...prev }
|
||||
next.config = { ...next.config }
|
||||
@@ -56,7 +56,7 @@ export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
setIsEditing(false)
|
||||
setEditingTheme(null)
|
||||
} else {
|
||||
console.error('Failed to save theme:', res.message)
|
||||
console.error("Failed to save theme:", res.message)
|
||||
}
|
||||
}, [editingTheme])
|
||||
|
||||
@@ -74,7 +74,7 @@ export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
updateEditingTheme,
|
||||
updateConfig,
|
||||
saveEditingTheme,
|
||||
cancelEditing
|
||||
cancelEditing,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -84,6 +84,6 @@ export const ThemeEditorProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
|
||||
export const useThemeEditor = () => {
|
||||
const context = useContext(ThemeEditorContext)
|
||||
if (!context) throw new Error('useThemeEditor must be used within ThemeEditorProvider')
|
||||
if (!context) throw new Error("useThemeEditor must be used within ThemeEditorProvider")
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import i18n from "i18next"
|
||||
import { initReactI18next } from "react-i18next"
|
||||
import zhCN from "./locales/zh-CN.json"
|
||||
import enUS from "./locales/en-US.json"
|
||||
|
||||
export const defaultNS = "translation"
|
||||
export const resources = {
|
||||
"zh-CN": { translation: zhCN },
|
||||
"en-US": { translation: enUS },
|
||||
} as const
|
||||
|
||||
export type AppLanguage = "zh-CN" | "en-US"
|
||||
|
||||
export const languageNames: Record<AppLanguage, string> = {
|
||||
"zh-CN": "简体中文",
|
||||
"en-US": "English",
|
||||
}
|
||||
|
||||
export const languageOptions: { value: AppLanguage; label: string }[] = [
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "en-US", label: "English" },
|
||||
]
|
||||
|
||||
const savedLanguage = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("secscore_language")
|
||||
if (stored && (stored === "zh-CN" || stored === "en-US")) {
|
||||
return stored
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
const browserLang = navigator.language || (navigator as any).userLanguage
|
||||
if (browserLang?.startsWith("zh")) return "zh-CN"
|
||||
return "en-US"
|
||||
})()
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: savedLanguage,
|
||||
fallbackLng: "zh-CN",
|
||||
defaultNS,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
})
|
||||
|
||||
export const changeLanguage = async (lang: AppLanguage): Promise<void> => {
|
||||
await i18n.changeLanguage(lang)
|
||||
try {
|
||||
localStorage.setItem("secscore_language", lang)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentLanguage = (): AppLanguage => {
|
||||
return (i18n.language as AppLanguage) || "zh-CN"
|
||||
}
|
||||
|
||||
export { i18n }
|
||||
export default i18n
|
||||
@@ -0,0 +1,105 @@
|
||||
import "./assets/main.css"
|
||||
import "./i18n"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "./App"
|
||||
import { ClientContext } from "./ClientContext"
|
||||
import { StudentService } from "./services/StudentService"
|
||||
import { ServiceProvider } from "./contexts/ServiceContext"
|
||||
|
||||
const ctx = new ClientContext()
|
||||
new StudentService(ctx)
|
||||
|
||||
const safeWriteLog = (payload: {
|
||||
level: "debug" | "info" | "warn" | "error"
|
||||
message: string
|
||||
meta?: any
|
||||
}) => {
|
||||
try {
|
||||
const api = (window as any).api
|
||||
if (!api?.writeLog) return
|
||||
api.writeLog(payload)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const patchConsole = () => {
|
||||
const c = window.console as any
|
||||
const set = (name: string, fn: (...args: any[]) => void) => {
|
||||
try {
|
||||
c[name] = fn
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
set("log", (...args: any[]) =>
|
||||
safeWriteLog({ level: "info", message: String(args[0] ?? ""), meta: args.slice(1) })
|
||||
)
|
||||
set("info", (...args: any[]) =>
|
||||
safeWriteLog({ level: "info", message: String(args[0] ?? ""), meta: args.slice(1) })
|
||||
)
|
||||
set("warn", (...args: any[]) =>
|
||||
safeWriteLog({ level: "warn", message: String(args[0] ?? ""), meta: args.slice(1) })
|
||||
)
|
||||
set("debug", (...args: any[]) =>
|
||||
safeWriteLog({ level: "debug", message: String(args[0] ?? ""), meta: args.slice(1) })
|
||||
)
|
||||
set("error", (...args: any[]) => {
|
||||
const first = args[0]
|
||||
if (first instanceof Error) {
|
||||
safeWriteLog({
|
||||
level: "error",
|
||||
message: first.message,
|
||||
meta: { stack: first.stack, args: args.slice(1) },
|
||||
})
|
||||
return
|
||||
}
|
||||
safeWriteLog({ level: "error", message: String(first ?? ""), meta: args.slice(1) })
|
||||
})
|
||||
set("trace", (...args: any[]) =>
|
||||
safeWriteLog({
|
||||
level: "debug",
|
||||
message: "console.trace",
|
||||
meta: { args, stack: new Error("console.trace").stack },
|
||||
})
|
||||
)
|
||||
set("table", (...args: any[]) =>
|
||||
safeWriteLog({ level: "info", message: "console.table", meta: args })
|
||||
)
|
||||
}
|
||||
patchConsole()
|
||||
|
||||
window.addEventListener("error", (e: any) => {
|
||||
const error = e?.error
|
||||
safeWriteLog({
|
||||
level: "error",
|
||||
message: "renderer:error",
|
||||
meta: {
|
||||
message: error?.message || e?.message,
|
||||
stack: error?.stack,
|
||||
filename: e?.filename,
|
||||
lineno: e?.lineno,
|
||||
colno: e?.colno,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener("unhandledrejection", (e: any) => {
|
||||
const reason = e?.reason
|
||||
safeWriteLog({
|
||||
level: "error",
|
||||
message: "renderer:unhandledrejection",
|
||||
meta: reason instanceof Error ? { message: reason.message, stack: reason.stack } : { reason },
|
||||
})
|
||||
})
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ServiceProvider value={ctx}>
|
||||
<App />
|
||||
</ServiceProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Context as BaseContext } from '../shared/kernel'
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
export class MainContext extends BaseContext {
|
||||
public isQuitting = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
handle(
|
||||
channel: string,
|
||||
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => Promise<any> | any
|
||||
) {
|
||||
ipcMain.handle(channel, listener)
|
||||
this.effect(() => ipcMain.removeHandler(channel))
|
||||
}
|
||||
|
||||
ipcOn(channel: string, listener: (event: Electron.IpcMainEvent, ...args: any[]) => void) {
|
||||
ipcMain.on(channel, listener)
|
||||
this.effect(() => ipcMain.removeListener(channel, listener))
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { Context, Service } from '../../shared/kernel'
|
||||
import { DataSource } from 'typeorm'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { StudentEntity } from './entities/StudentEntity'
|
||||
import { ReasonEntity } from './entities/ReasonEntity'
|
||||
import { ScoreEventEntity } from './entities/ScoreEventEntity'
|
||||
import { SettlementEntity } from './entities/SettlementEntity'
|
||||
import { SettingEntity } from './entities/SettingEntity'
|
||||
import { TagEntity } from './entities/TagEntity'
|
||||
import { StudentTagEntity } from './entities/StudentTagEntity'
|
||||
import { migrations } from './migrations'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
db: DbManager
|
||||
}
|
||||
}
|
||||
|
||||
interface PostgreSQLConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
ssl?: boolean
|
||||
sslmode?: string
|
||||
channelBinding?: string
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
id: string
|
||||
operation: () => Promise<any>
|
||||
resolve: (value: any) => void
|
||||
reject: (error: Error) => void
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function parsePostgreSQLConnectionString(connectionString: string): PostgreSQLConfig | null {
|
||||
if (!connectionString.startsWith('postgresql://')) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(connectionString)
|
||||
const config: PostgreSQLConfig = {
|
||||
host: url.hostname,
|
||||
port: url.port ? parseInt(url.port, 10) : 5432,
|
||||
username: url.username,
|
||||
password: decodeURIComponent(url.password || ''),
|
||||
database: url.pathname.slice(1),
|
||||
ssl: false
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(url.search)
|
||||
if (params.get('sslmode')) {
|
||||
config.ssl = true
|
||||
config.sslmode = params.get('sslmode') || 'require'
|
||||
}
|
||||
if (params.get('channel_binding')) {
|
||||
config.channelBinding = params.get('channel_binding') || 'require'
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e) {
|
||||
console.error('Failed to parse PostgreSQL connection string:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class DbManager extends Service {
|
||||
private _dataSource: DataSource | null = null
|
||||
private _isPostgreSQL: boolean = false
|
||||
private dbPath: string
|
||||
private operationQueue: QueueItem[] = []
|
||||
private isProcessingQueue: boolean = false
|
||||
private queueIdCounter: number = 0
|
||||
|
||||
constructor(ctx: Context, dbPath: string, pgConnectionString?: string) {
|
||||
super(ctx, 'db')
|
||||
this.dbPath = dbPath
|
||||
this._isPostgreSQL = !!pgConnectionString
|
||||
}
|
||||
|
||||
get dataSource(): DataSource {
|
||||
if (!this._dataSource) {
|
||||
throw new Error('Database not initialized. Call initialize() first.')
|
||||
}
|
||||
return this._dataSource
|
||||
}
|
||||
|
||||
get isPostgreSQL(): boolean {
|
||||
return this._isPostgreSQL
|
||||
}
|
||||
|
||||
private createDataSource(pgConnectionString?: string): DataSource {
|
||||
const pgConfig = pgConnectionString ? parsePostgreSQLConnectionString(pgConnectionString) : null
|
||||
|
||||
if (pgConfig) {
|
||||
this._isPostgreSQL = true
|
||||
return new DataSource({
|
||||
type: 'postgres',
|
||||
host: pgConfig.host,
|
||||
port: pgConfig.port,
|
||||
username: pgConfig.username,
|
||||
password: pgConfig.password,
|
||||
database: pgConfig.database,
|
||||
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : false,
|
||||
extra: {
|
||||
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : undefined,
|
||||
sslmode: pgConfig.sslmode,
|
||||
channelBinding: pgConfig.channelBinding
|
||||
},
|
||||
entities: [
|
||||
StudentEntity,
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
TagEntity,
|
||||
StudentTagEntity
|
||||
],
|
||||
migrations,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
poolSize: 10,
|
||||
connectTimeoutMS: 30000
|
||||
})
|
||||
} else {
|
||||
this._isPostgreSQL = false
|
||||
const dbDir = path.dirname(this.dbPath)
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
return new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: this.dbPath,
|
||||
entities: [
|
||||
StudentEntity,
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
TagEntity,
|
||||
StudentTagEntity
|
||||
],
|
||||
migrations,
|
||||
synchronize: false,
|
||||
logging: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(pgConnectionString?: string) {
|
||||
if (this._dataSource?.isInitialized) {
|
||||
await this.dispose()
|
||||
}
|
||||
|
||||
this._dataSource = this.createDataSource(pgConnectionString)
|
||||
await this._dataSource.initialize()
|
||||
|
||||
if (!this._isPostgreSQL) {
|
||||
await this._dataSource.query('PRAGMA foreign_keys = ON')
|
||||
}
|
||||
|
||||
await this._dataSource.runMigrations()
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
if (!this._dataSource?.isInitialized) return
|
||||
await this._dataSource.destroy()
|
||||
this._dataSource = null
|
||||
}
|
||||
|
||||
async switchConnection(pgConnectionString?: string): Promise<{ type: 'sqlite' | 'postgresql' }> {
|
||||
await this.dispose()
|
||||
await this.initialize(pgConnectionString)
|
||||
return { type: this._isPostgreSQL ? 'postgresql' : 'sqlite' }
|
||||
}
|
||||
|
||||
getDatabaseType(): 'sqlite' | 'postgresql' {
|
||||
return this._isPostgreSQL ? 'postgresql' : 'sqlite'
|
||||
}
|
||||
|
||||
async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const item: QueueItem = {
|
||||
id: `op-${++this.queueIdCounter}`,
|
||||
operation,
|
||||
resolve: resolve as (value: any) => void,
|
||||
reject,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
this.operationQueue.push(item)
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.isProcessingQueue || this.operationQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true
|
||||
|
||||
while (this.operationQueue.length > 0) {
|
||||
const item = this.operationQueue.shift()!
|
||||
try {
|
||||
const result = await item.operation()
|
||||
item.resolve(result)
|
||||
} catch (error) {
|
||||
item.reject(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false
|
||||
}
|
||||
|
||||
async withQueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (this._isPostgreSQL) {
|
||||
return this.enqueueOperation(operation)
|
||||
}
|
||||
return operation()
|
||||
}
|
||||
|
||||
async syncToRemote(): Promise<{ success: boolean; message?: string }> {
|
||||
if (!this._isPostgreSQL) {
|
||||
return { success: false, message: '当前不是远程数据库模式' }
|
||||
}
|
||||
|
||||
if (!this._dataSource?.isInitialized) {
|
||||
return { success: false, message: '数据库未初始化' }
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.enqueueOperation(async () => {
|
||||
await this._dataSource!.query('SELECT 1')
|
||||
return { success: true, message: '同步成功' }
|
||||
})
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error?.message || '同步失败' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import type { DataSource } from 'typeorm'
|
||||
import {
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
StudentEntity
|
||||
} from '../entities'
|
||||
|
||||
type exportBundle = {
|
||||
students: StudentEntity[]
|
||||
reasons: ReasonEntity[]
|
||||
events: ScoreEventEntity[]
|
||||
settlements: SettlementEntity[]
|
||||
settings: SettingEntity[]
|
||||
}
|
||||
|
||||
type importResult = { success: true } | { success: false; message: string }
|
||||
|
||||
export class DataBackupRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async exportJson(): Promise<string> {
|
||||
const bundle = await this.exportBundle()
|
||||
return JSON.stringify(bundle, null, 2)
|
||||
}
|
||||
|
||||
private async exportBundle(): Promise<exportBundle> {
|
||||
const students = await this.dataSource.getRepository(StudentEntity).find()
|
||||
const reasons = await this.dataSource.getRepository(ReasonEntity).find()
|
||||
const events = await this.dataSource.getRepository(ScoreEventEntity).find()
|
||||
const settlements = await this.dataSource
|
||||
.getRepository(SettlementEntity)
|
||||
.find({ order: { id: 'ASC' } })
|
||||
const settings = await this.dataSource
|
||||
.getRepository(SettingEntity)
|
||||
.createQueryBuilder('s')
|
||||
.where("s.key NOT LIKE 'security_%'")
|
||||
.getMany()
|
||||
return { students, reasons, events, settlements, settings }
|
||||
}
|
||||
|
||||
async importJson(jsonText: string): Promise<importResult> {
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(String(jsonText ?? ''))
|
||||
} catch {
|
||||
return { success: false, message: 'Invalid JSON' }
|
||||
}
|
||||
|
||||
const students = Array.isArray(parsed?.students) ? parsed.students : []
|
||||
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
|
||||
const events = Array.isArray(parsed?.events) ? parsed.events : []
|
||||
const settlements = Array.isArray(parsed?.settlements) ? parsed.settlements : []
|
||||
const settings = Array.isArray(parsed?.settings) ? parsed.settings : []
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.clear(ScoreEventEntity)
|
||||
await manager.clear(SettlementEntity)
|
||||
await manager.clear(StudentEntity)
|
||||
await manager.clear(ReasonEntity)
|
||||
await manager
|
||||
.getRepository(SettingEntity)
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where("key NOT LIKE 'security_%'")
|
||||
.execute()
|
||||
|
||||
const insertStudents: Partial<StudentEntity>[] = []
|
||||
for (const s of students) {
|
||||
const name = String(s?.name ?? '').trim()
|
||||
if (!name) continue
|
||||
const score = Number(s?.score ?? 0)
|
||||
const extraJson = s?.extra_json != null ? String(s.extra_json) : null
|
||||
const createdAt = s?.created_at != null ? String(s.created_at) : new Date().toISOString()
|
||||
const updatedAt = s?.updated_at != null ? String(s.updated_at) : new Date().toISOString()
|
||||
insertStudents.push({
|
||||
name,
|
||||
score: Number.isFinite(score) ? score : 0,
|
||||
extra_json: extraJson,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt
|
||||
})
|
||||
}
|
||||
if (insertStudents.length) {
|
||||
await manager.getRepository(StudentEntity).insert(insertStudents)
|
||||
}
|
||||
|
||||
const insertReasons: Partial<ReasonEntity>[] = []
|
||||
for (const r of reasons) {
|
||||
const content = String(r?.content ?? '').trim()
|
||||
if (!content) continue
|
||||
const category = String(r?.category ?? '其他')
|
||||
const delta = Number(r?.delta ?? 0)
|
||||
const isSystem = Number(r?.is_system ?? 0) ? 1 : 0
|
||||
const updatedAt = r?.updated_at != null ? String(r.updated_at) : new Date().toISOString()
|
||||
insertReasons.push({
|
||||
content,
|
||||
category,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
is_system: isSystem,
|
||||
updated_at: updatedAt
|
||||
})
|
||||
}
|
||||
if (insertReasons.length) {
|
||||
await manager.getRepository(ReasonEntity).insert(insertReasons)
|
||||
}
|
||||
|
||||
const insertSettlements: Partial<SettlementEntity>[] = []
|
||||
for (const s of settlements) {
|
||||
const id = Number(s?.id)
|
||||
const startTime = String(s?.start_time ?? '').trim()
|
||||
const endTime = String(s?.end_time ?? '').trim()
|
||||
const createdAt = String(s?.created_at ?? new Date().toISOString())
|
||||
if (!Number.isFinite(id) || !startTime || !endTime) continue
|
||||
insertSettlements.push({
|
||||
id,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
created_at: createdAt
|
||||
})
|
||||
}
|
||||
if (insertSettlements.length) {
|
||||
await manager.getRepository(SettlementEntity).insert(insertSettlements)
|
||||
}
|
||||
|
||||
const insertEvents: Partial<ScoreEventEntity>[] = []
|
||||
for (const e of events) {
|
||||
const uuid = String(e?.uuid ?? '').trim()
|
||||
const studentName = String(e?.student_name ?? '').trim()
|
||||
const reasonContent = String(e?.reason_content ?? '').trim()
|
||||
if (!uuid || !studentName || !reasonContent) continue
|
||||
const delta = Number(e?.delta ?? 0)
|
||||
const valPrev = Number(e?.val_prev ?? 0)
|
||||
const valCurr = Number(e?.val_curr ?? 0)
|
||||
const eventTime = String(e?.event_time ?? new Date().toISOString())
|
||||
const settlementIdRaw = e?.settlement_id
|
||||
const settlementId =
|
||||
settlementIdRaw === null || settlementIdRaw === undefined
|
||||
? null
|
||||
: Number(settlementIdRaw)
|
||||
insertEvents.push({
|
||||
uuid,
|
||||
student_name: studentName,
|
||||
reason_content: reasonContent,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
val_prev: Number.isFinite(valPrev) ? valPrev : 0,
|
||||
val_curr: Number.isFinite(valCurr) ? valCurr : 0,
|
||||
event_time: eventTime,
|
||||
settlement_id: Number.isFinite(settlementId as any) ? (settlementId as any) : null
|
||||
})
|
||||
}
|
||||
if (insertEvents.length) {
|
||||
await manager.getRepository(ScoreEventEntity).insert(insertEvents)
|
||||
}
|
||||
|
||||
for (const it of settings) {
|
||||
const key = String(it?.key ?? '').trim()
|
||||
if (!key || key.startsWith('security_')) continue
|
||||
await manager.getRepository(SettingEntity).save({ key, value: String(it?.value ?? '') })
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e?.message || 'Import failed' }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { StudentEntity } from './entities/StudentEntity'
|
||||
export { ReasonEntity } from './entities/ReasonEntity'
|
||||
export { ScoreEventEntity } from './entities/ScoreEventEntity'
|
||||
export { SettlementEntity } from './entities/SettlementEntity'
|
||||
export { SettingEntity } from './entities/SettingEntity'
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'reasons' })
|
||||
export class ReasonEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
content!: string
|
||||
|
||||
@Column({ type: 'text', default: '其他' })
|
||||
category!: string
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
delta!: number
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
is_system!: number
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'score_events' })
|
||||
export class ScoreEventEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
uuid!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
student_name!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
reason_content!: string
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
delta!: number
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
val_prev!: number
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
val_curr!: number
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
event_time!: string
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
settlement_id!: number | null
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'settings' })
|
||||
export class SettingEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
key!: string
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
value!: string | null
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'settlements' })
|
||||
export class SettlementEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text' })
|
||||
start_time!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
end_time!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm'
|
||||
import { TagEntity } from './TagEntity'
|
||||
|
||||
@Entity({ name: 'students' })
|
||||
export class StudentEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text', default: '[]' })
|
||||
tags!: string
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
score!: number
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
extra_json!: string | null
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
|
||||
@ManyToMany(() => TagEntity, { cascade: true })
|
||||
@JoinTable({
|
||||
name: 'student_tags',
|
||||
joinColumn: { name: 'student_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' }
|
||||
})
|
||||
tagEntities!: TagEntity[]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'
|
||||
import { StudentEntity } from './StudentEntity'
|
||||
import { TagEntity } from './TagEntity'
|
||||
|
||||
@Entity({ name: 'student_tags' })
|
||||
@Index(['student_id', 'tag_id'], { unique: true })
|
||||
export class StudentTagEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
student_id!: number
|
||||
|
||||
@Column({ name: 'tag_id', type: 'integer' })
|
||||
tag_id!: number
|
||||
|
||||
@ManyToOne(() => StudentEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student!: StudentEntity
|
||||
|
||||
@ManyToOne(() => TagEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'tag_id' })
|
||||
tag!: TagEntity
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'tags' })
|
||||
export class TagEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number
|
||||
|
||||
@Column({ type: 'text', unique: true })
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
created_at!: string
|
||||
|
||||
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updated_at!: string
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { StudentEntity } from './StudentEntity'
|
||||
export { ReasonEntity } from './ReasonEntity'
|
||||
export { ScoreEventEntity } from './ScoreEventEntity'
|
||||
export { SettlementEntity } from './SettlementEntity'
|
||||
export { SettingEntity } from './SettingEntity'
|
||||
export { TagEntity } from './TagEntity'
|
||||
export { StudentTagEntity } from './StudentTagEntity'
|
||||
@@ -1,155 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm'
|
||||
|
||||
export class InitSchema2026011800000 implements MigrationInterface {
|
||||
name = 'InitSchema2026011800000'
|
||||
|
||||
private isPostgres(queryRunner: QueryRunner): boolean {
|
||||
return queryRunner.connection.options.type === 'postgres'
|
||||
}
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const isPg = this.isPostgres(queryRunner)
|
||||
const pkSerial = isPg ? 'SERIAL PRIMARY KEY' : 'integer PRIMARY KEY AUTOINCREMENT'
|
||||
const now = isPg ? 'CURRENT_TIMESTAMP' : '(CURRENT_TIMESTAMP)'
|
||||
const defaultZero = isPg ? 'DEFAULT 0' : 'DEFAULT (0)'
|
||||
const defaultEmptyArr = isPg ? "DEFAULT '[]'" : "DEFAULT '[]'"
|
||||
|
||||
if (!(await queryRunner.hasTable('students'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "students" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"score" integer NOT NULL ${defaultZero},
|
||||
"extra_json" text,
|
||||
"tags" text NOT NULL ${defaultEmptyArr},
|
||||
"created_at" text NOT NULL DEFAULT ${now},
|
||||
"updated_at" text NOT NULL DEFAULT ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('reasons'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "reasons" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"category" text NOT NULL DEFAULT '其他',
|
||||
"delta" integer NOT NULL,
|
||||
"is_system" integer NOT NULL ${defaultZero},
|
||||
"updated_at" text NOT NULL DEFAULT ${now},
|
||||
CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('settlements'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "settlements" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"start_time" text NOT NULL,
|
||||
"end_time" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('score_events'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "score_events" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"uuid" text NOT NULL,
|
||||
"student_name" text NOT NULL,
|
||||
"reason_content" text NOT NULL,
|
||||
"delta" integer NOT NULL,
|
||||
"val_prev" integer NOT NULL,
|
||||
"val_curr" integer NOT NULL,
|
||||
"event_time" text NOT NULL DEFAULT ${now},
|
||||
"settlement_id" integer,
|
||||
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
|
||||
)
|
||||
`)
|
||||
}
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_score_events_settlement_id" ON "score_events" ("settlement_id")`
|
||||
)
|
||||
|
||||
if (!(await queryRunner.hasTable('settings'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "settings" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" text
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
const reasonValues = `('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP),('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP),('纪律良好','纪律',1,1,CURRENT_TIMESTAMP),('帮助同学','品德',2,1,CURRENT_TIMESTAMP),('迟到','纪律',-1,1,CURRENT_TIMESTAMP),('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP)`
|
||||
|
||||
if (isPg) {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
|
||||
${reasonValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`)
|
||||
} else {
|
||||
await queryRunner.query(`
|
||||
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
|
||||
${reasonValues}
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('tags'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "tags" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"name" text NOT NULL UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT ${now},
|
||||
"updated_at" text NOT NULL DEFAULT ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasTable('student_tags'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "student_tags" (
|
||||
"id" ${pkSerial} NOT NULL,
|
||||
"student_id" integer NOT NULL,
|
||||
"tag_id" integer NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT ${now},
|
||||
FOREIGN KEY ("student_id") REFERENCES "students"("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE,
|
||||
UNIQUE("student_id", "tag_id")
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_student_id" ON "student_tags" ("student_id")`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
|
||||
)
|
||||
|
||||
const tagValues = `('优秀生'),('班干部'),('勤奋'),('活跃'),('进步快')`
|
||||
|
||||
if (isPg) {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "tags" ("name") VALUES ${tagValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`)
|
||||
} else {
|
||||
await queryRunner.query(`
|
||||
INSERT OR IGNORE INTO "tags" ("name") VALUES ${tagValues}
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "score_events"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "settlements"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "students"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "reasons"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "settings"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "student_tags"`)
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "tags"`)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { InitSchema2026011800000 } from './InitSchema2026011800000'
|
||||
|
||||
export const migrations = [InitSchema2026011800000]
|
||||
@@ -1,92 +0,0 @@
|
||||
import type {
|
||||
configureHostDelegate,
|
||||
hostApplicationContext,
|
||||
hostBuilderContext,
|
||||
hostedService,
|
||||
middleware,
|
||||
serviceToken
|
||||
} from './types'
|
||||
import { ServiceProvider } from './serviceCollection'
|
||||
|
||||
export class HostApplication {
|
||||
private hostedInstances: hostedService[] = []
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
private readonly context: hostBuilderContext,
|
||||
private readonly provider: ServiceProvider,
|
||||
private readonly configureDelegates: configureHostDelegate[],
|
||||
private readonly middleware: middleware[],
|
||||
private readonly hostedTokens: serviceToken<hostedService>[]
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return
|
||||
|
||||
const appCtx = this.createApplicationContext()
|
||||
for (const configure of this.configureDelegates) {
|
||||
await configure(this.context, appCtx)
|
||||
}
|
||||
|
||||
await this.dispatch(0, appCtx, async () => {
|
||||
await this.bootstrapHostedServices()
|
||||
})
|
||||
|
||||
this.started = true
|
||||
await this.context.lifetime.notifyStarted()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return
|
||||
await this.context.lifetime.notifyStopping()
|
||||
await this.disposeHostedServices()
|
||||
await this.context.lifetime.notifyStopped()
|
||||
this.started = false
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.stop()
|
||||
await this.provider.dispose()
|
||||
}
|
||||
|
||||
get services(): ServiceProvider {
|
||||
return this.provider
|
||||
}
|
||||
|
||||
get hostContext(): hostBuilderContext {
|
||||
return this.context
|
||||
}
|
||||
|
||||
private createApplicationContext(): hostApplicationContext {
|
||||
return { services: this.provider, host: this.context }
|
||||
}
|
||||
|
||||
private async bootstrapHostedServices() {
|
||||
for (const token of this.hostedTokens) {
|
||||
const service = this.provider.get(token)
|
||||
this.hostedInstances.push(service)
|
||||
await service.start()
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeHostedServices() {
|
||||
while (this.hostedInstances.length) {
|
||||
const service = this.hostedInstances.pop()
|
||||
if (!service) continue
|
||||
await service.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(
|
||||
index: number,
|
||||
appCtx: hostApplicationContext,
|
||||
terminal: () => Promise<void>
|
||||
) {
|
||||
const middleware = this.middleware[index]
|
||||
if (!middleware) {
|
||||
await terminal()
|
||||
return
|
||||
}
|
||||
await middleware(appCtx, () => this.dispatch(index + 1, appCtx, terminal))
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ServiceCollection } from './serviceCollection'
|
||||
import { HostApplication } from './hostApplication'
|
||||
import {
|
||||
type appRuntimeContext,
|
||||
type configureHostDelegate,
|
||||
type configureServicesDelegate,
|
||||
type hostBuilderContext,
|
||||
type hostBuilderSettings,
|
||||
type hostedService,
|
||||
type middleware,
|
||||
type serviceToken
|
||||
} from './types'
|
||||
|
||||
export class SecScoreHostBuilder {
|
||||
private readonly serviceCollection: ServiceCollection
|
||||
private readonly configureServicesDelegates: configureServicesDelegate[] = []
|
||||
private readonly configureDelegates: configureHostDelegate[] = []
|
||||
private readonly middleware: middleware[] = []
|
||||
private readonly hostedServices: serviceToken<hostedService>[] = []
|
||||
private readonly builderContext: hostBuilderContext
|
||||
|
||||
constructor(runtimeCtx: appRuntimeContext, settings: hostBuilderSettings = {}) {
|
||||
this.serviceCollection = new ServiceCollection(runtimeCtx)
|
||||
this.builderContext = {
|
||||
ctx: runtimeCtx,
|
||||
environmentName: settings.environment ?? process.env.SECSCORE_ENV ?? 'Production',
|
||||
properties: new Map(Object.entries(settings.properties ?? {})),
|
||||
lifetime: this.serviceCollection.getLifetime()
|
||||
}
|
||||
}
|
||||
|
||||
get context(): hostBuilderContext {
|
||||
return this.builderContext
|
||||
}
|
||||
|
||||
configureServices(callback: configureServicesDelegate): this {
|
||||
this.configureServicesDelegates.push(callback)
|
||||
return this
|
||||
}
|
||||
|
||||
configure(callback: configureHostDelegate): this {
|
||||
this.configureDelegates.push(callback)
|
||||
return this
|
||||
}
|
||||
|
||||
use(middleware: middleware): this {
|
||||
this.middleware.push(middleware)
|
||||
return this
|
||||
}
|
||||
|
||||
addHostedService(token: serviceToken<hostedService>): this {
|
||||
this.hostedServices.push(token)
|
||||
return this
|
||||
}
|
||||
|
||||
async build(): Promise<SecScoreHost> {
|
||||
for (const configure of this.configureServicesDelegates) {
|
||||
await configure(this.builderContext, this.serviceCollection)
|
||||
}
|
||||
|
||||
const provider = this.serviceCollection.buildServiceProvider()
|
||||
const application = new HostApplication(
|
||||
this.builderContext,
|
||||
provider,
|
||||
this.configureDelegates,
|
||||
this.middleware,
|
||||
this.hostedServices
|
||||
)
|
||||
return new SecScoreHost(application)
|
||||
}
|
||||
}
|
||||
|
||||
export class SecScoreHost {
|
||||
constructor(private readonly app: HostApplication) {}
|
||||
|
||||
get services() {
|
||||
return this.app.services
|
||||
}
|
||||
|
||||
get hostContext() {
|
||||
return this.app.hostContext
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.app.start()
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.app.stop()
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.app.dispose()
|
||||
}
|
||||
|
||||
async run() {
|
||||
await this.start()
|
||||
return async () => {
|
||||
await this.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostBuilder(ctx: appRuntimeContext, settings?: hostBuilderSettings) {
|
||||
return new SecScoreHostBuilder(ctx, settings)
|
||||
}
|
||||
|
||||
export const Host = {
|
||||
createApplicationBuilder: createHostBuilder
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export { SecScoreHostBuilder, SecScoreHost, createHostBuilder, Host } from './hostBuilder'
|
||||
export { ServiceCollection, ServiceProvider } from './serviceCollection'
|
||||
export {
|
||||
HostApplicationLifetimeToken,
|
||||
AppConfigToken,
|
||||
LoggerToken,
|
||||
DbManagerToken,
|
||||
SettingsStoreToken,
|
||||
SecurityServiceToken,
|
||||
PermissionServiceToken,
|
||||
StudentRepositoryToken,
|
||||
ReasonRepositoryToken,
|
||||
EventRepositoryToken,
|
||||
SettlementRepositoryToken,
|
||||
TagRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
AutoScoreServiceToken
|
||||
} from './tokens'
|
||||
export type {
|
||||
appRuntimeContext,
|
||||
hostedService,
|
||||
middleware,
|
||||
hostBuilderSettings,
|
||||
hostBuilderContext,
|
||||
configureServicesDelegate,
|
||||
configureHostDelegate,
|
||||
hostApplicationLifetime,
|
||||
hostApplicationContext,
|
||||
serviceToken
|
||||
} from './types'
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { awaitable, disposer, hostApplicationLifetime } from './types'
|
||||
|
||||
type loggerLike = { error: (...args: any[]) => void }
|
||||
|
||||
// 默认的应用生命周期实现,管理启动/停止事件
|
||||
export class DefaultHostApplicationLifetime implements hostApplicationLifetime {
|
||||
constructor(private readonly logger: loggerLike = console) {}
|
||||
private readonly started = new Set<() => awaitable<void>>() // 启动事件处理器
|
||||
private readonly stopping = new Set<() => awaitable<void>>() // 停止事件处理器
|
||||
private readonly stopped = new Set<() => awaitable<void>>() // 已停止事件处理器
|
||||
|
||||
// 注册启动事件处理器
|
||||
onStarted(handler: () => awaitable<void>): disposer {
|
||||
this.started.add(handler)
|
||||
return () => {
|
||||
this.started.delete(handler)
|
||||
} // 返回清理函数
|
||||
}
|
||||
|
||||
// 注册停止事件处理器
|
||||
onStopping(handler: () => awaitable<void>): disposer {
|
||||
this.stopping.add(handler)
|
||||
return () => {
|
||||
this.stopping.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册已停止事件处理器
|
||||
onStopped(handler: () => awaitable<void>): disposer {
|
||||
this.stopped.add(handler)
|
||||
return () => {
|
||||
this.stopped.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知所有启动事件处理器
|
||||
async notifyStarted() {
|
||||
await this.dispatch(this.started)
|
||||
}
|
||||
|
||||
// 通知所有停止事件处理器
|
||||
async notifyStopping() {
|
||||
await this.dispatch(this.stopping)
|
||||
}
|
||||
|
||||
// 通知所有已停止事件处理器
|
||||
async notifyStopped() {
|
||||
await this.dispatch(this.stopped)
|
||||
}
|
||||
|
||||
// 执行事件处理器列表
|
||||
private async dispatch(targets: Set<() => awaitable<void>>) {
|
||||
for (const handler of Array.from(targets)) {
|
||||
try {
|
||||
await handler()
|
||||
} catch (error) {
|
||||
this.logger.error('[HostLifetime] handler failed', error as Error) // 记录错误但不中断
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { HostApplicationLifetimeToken } from './tokens'
|
||||
import { DefaultHostApplicationLifetime } from './lifetime'
|
||||
import type {
|
||||
appRuntimeContext,
|
||||
disposer,
|
||||
injectableClass,
|
||||
serviceDescriptor,
|
||||
serviceFactory,
|
||||
serviceFactoryOrValue,
|
||||
serviceLifetime,
|
||||
serviceToken
|
||||
} from './types'
|
||||
|
||||
// 检查值是否为构造函数
|
||||
function isConstructor<T>(value: serviceFactoryOrValue<T>): value is injectableClass<T> {
|
||||
return (
|
||||
typeof value === 'function' &&
|
||||
!!(value as any).prototype &&
|
||||
(value as any).prototype.constructor === value
|
||||
)
|
||||
}
|
||||
|
||||
// 服务集合类,管理所有注册的服务描述符
|
||||
export class ServiceCollection {
|
||||
private readonly descriptors = new Map<serviceToken, serviceDescriptor>() // 服务描述符映射
|
||||
private readonly hostLifetime: DefaultHostApplicationLifetime // 应用生命周期管理器
|
||||
|
||||
constructor(private readonly ctx: appRuntimeContext) {
|
||||
this.hostLifetime = new DefaultHostApplicationLifetime(this.ctx.logger ?? console)
|
||||
this.addSingleton(HostApplicationLifetimeToken, () => this.hostLifetime)
|
||||
}
|
||||
|
||||
// 获取生命周期管理器
|
||||
getLifetime() {
|
||||
return this.hostLifetime
|
||||
}
|
||||
|
||||
// 添加单例服务
|
||||
addSingleton<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'singleton', impl)
|
||||
}
|
||||
|
||||
// 添加作用域服务
|
||||
addScoped<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'scoped', impl)
|
||||
}
|
||||
|
||||
// 添加瞬时服务
|
||||
addTransient<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
return this.register(token, 'transient', impl)
|
||||
}
|
||||
|
||||
// 尝试添加单例服务(如果不存在)
|
||||
tryAddSingleton<T>(token: serviceToken<T>, impl: serviceFactoryOrValue<T>): this {
|
||||
if (!this.descriptors.has(token)) {
|
||||
this.addSingleton(token, impl)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
// 检查服务是否已注册
|
||||
has(token: serviceToken): boolean {
|
||||
return this.descriptors.has(token)
|
||||
}
|
||||
|
||||
// 清空所有服务
|
||||
clear(): void {
|
||||
this.descriptors.clear()
|
||||
}
|
||||
|
||||
// 构建服务提供者
|
||||
buildServiceProvider(): ServiceProvider {
|
||||
return new ServiceProvider(this.ctx, new Map(this.descriptors))
|
||||
}
|
||||
|
||||
// 注册服务
|
||||
private register<T>(
|
||||
token: serviceToken<T>,
|
||||
lifetime: serviceLifetime,
|
||||
impl: serviceFactoryOrValue<T>
|
||||
): this {
|
||||
const descriptor: serviceDescriptor = {
|
||||
token,
|
||||
lifetime,
|
||||
factory: this.normalizeFactory(impl) // 标准化工厂函数
|
||||
}
|
||||
this.descriptors.set(token, descriptor)
|
||||
return this
|
||||
}
|
||||
|
||||
// 标准化工厂函数
|
||||
private normalizeFactory<T>(impl: serviceFactoryOrValue<T>): serviceFactory<T> {
|
||||
if (isConstructor(impl)) {
|
||||
return (provider) => this.instantiateClass(impl, provider) // 构造函数,实例化类
|
||||
}
|
||||
|
||||
if (typeof impl === 'function') {
|
||||
return impl as serviceFactory<T> // 已经是工厂函数
|
||||
}
|
||||
|
||||
return () => impl // 直接值,返回常量
|
||||
}
|
||||
|
||||
// 实例化类,注入依赖
|
||||
private instantiateClass<T>(Ctor: injectableClass<T>, provider: ServiceProvider): T {
|
||||
const deps = [...(Ctor.inject ?? [])].map((token) => provider.get(token)) // 获取依赖
|
||||
return new Ctor(...deps) // 构造实例
|
||||
}
|
||||
}
|
||||
|
||||
// 服务提供者类,负责解析和提供服务实例
|
||||
export class ServiceProvider {
|
||||
private readonly singletonCache: Map<serviceToken, unknown> // 单例缓存
|
||||
private readonly scopedCache = new Map<serviceToken, unknown>() // 作用域缓存
|
||||
private readonly singletonCleanup: disposer[] // 单例清理函数
|
||||
private readonly scopedCleanup: disposer[] = [] // 作用域清理函数
|
||||
private readonly root: ServiceProvider | null // 根提供者
|
||||
|
||||
constructor(
|
||||
private readonly ctx: appRuntimeContext,
|
||||
private readonly descriptors: Map<serviceToken, serviceDescriptor>,
|
||||
root: ServiceProvider | null = null,
|
||||
private readonly isScope = false // 是否为作用域提供者
|
||||
) {
|
||||
this.root = root
|
||||
this.singletonCache = root ? root.singletonCache : new Map() // 共享单例缓存
|
||||
this.singletonCleanup = root ? root.singletonCleanup : []
|
||||
}
|
||||
|
||||
// 获取服务实例
|
||||
get<T>(token: serviceToken<T>): T {
|
||||
const descriptor = this.descriptors.get(token) as serviceDescriptor<T> | undefined
|
||||
if (!descriptor) {
|
||||
throw new Error(`Service not registered for token: ${token.toString?.() ?? String(token)}`)
|
||||
}
|
||||
|
||||
switch (descriptor.lifetime) {
|
||||
case 'singleton':
|
||||
return this.resolveSingleton(descriptor)
|
||||
case 'scoped':
|
||||
return this.resolveScoped(descriptor)
|
||||
case 'transient':
|
||||
default:
|
||||
return this.instantiate(descriptor) // 每次都新实例
|
||||
}
|
||||
}
|
||||
|
||||
// 创建作用域提供者
|
||||
createScope(): ServiceProvider {
|
||||
return new ServiceProvider(this.ctx, this.descriptors, this.root ?? this, true)
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
async dispose(): Promise<void> {
|
||||
await this.flushCleanup(this.scopedCleanup) // 先清理作用域
|
||||
if (!this.isScope) {
|
||||
await this.flushCleanup(this.singletonCleanup) // 再清理单例
|
||||
this.singletonCache.clear()
|
||||
}
|
||||
this.scopedCache.clear()
|
||||
}
|
||||
|
||||
// 解析单例服务
|
||||
private resolveSingleton<T>(descriptor: serviceDescriptor<T>): T {
|
||||
const cacheOwner = this.root ?? this
|
||||
if (cacheOwner.singletonCache.has(descriptor.token)) {
|
||||
return cacheOwner.singletonCache.get(descriptor.token) as T
|
||||
}
|
||||
const instance = this.instantiate(descriptor)
|
||||
cacheOwner.singletonCache.set(descriptor.token, instance)
|
||||
const disposer = this.extractDisposer(instance) // 提取清理函数
|
||||
if (disposer) cacheOwner.singletonCleanup.push(disposer)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 解析作用域服务
|
||||
private resolveScoped<T>(descriptor: serviceDescriptor<T>): T {
|
||||
if (this.scopedCache.has(descriptor.token)) {
|
||||
return this.scopedCache.get(descriptor.token) as T
|
||||
}
|
||||
const instance = this.instantiate(descriptor)
|
||||
this.scopedCache.set(descriptor.token, instance)
|
||||
const disposer = this.extractDisposer(instance)
|
||||
if (disposer) this.scopedCleanup.push(disposer)
|
||||
return instance
|
||||
}
|
||||
|
||||
// 实例化服务
|
||||
private instantiate<T>(descriptor: serviceDescriptor<T>): T {
|
||||
return descriptor.factory(this)
|
||||
}
|
||||
|
||||
// 提取实例的清理函数
|
||||
private extractDisposer(instance: unknown): disposer | null {
|
||||
if (!instance) return null
|
||||
if (typeof (instance as any).dispose === 'function') {
|
||||
return () => (instance as any).dispose()
|
||||
}
|
||||
if (typeof (instance as any).destroy === 'function') {
|
||||
return () => (instance as any).destroy()
|
||||
}
|
||||
if (typeof (instance as any).stop === 'function') {
|
||||
return () => (instance as any).stop()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 执行清理函数列表
|
||||
private async flushCleanup(cleanup: disposer[]) {
|
||||
while (cleanup.length) {
|
||||
const disposer = cleanup.pop()
|
||||
if (!disposer) continue
|
||||
await disposer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export const HostApplicationLifetimeToken = Symbol.for('secscore.hosting.lifetime')
|
||||
|
||||
export const AppConfigToken = Symbol.for('secscore.app.config')
|
||||
export const LoggerToken = Symbol.for('secscore.logger')
|
||||
export const DbManagerToken = Symbol.for('secscore.dbManager')
|
||||
export const SettingsStoreToken = Symbol.for('secscore.settingsStore')
|
||||
export const SecurityServiceToken = Symbol.for('secscore.securityService')
|
||||
export const PermissionServiceToken = Symbol.for('secscore.permissionService')
|
||||
export const StudentRepositoryToken = Symbol.for('secscore.studentRepository')
|
||||
export const ReasonRepositoryToken = Symbol.for('secscore.reasonRepository')
|
||||
export const EventRepositoryToken = Symbol.for('secscore.eventRepository')
|
||||
export const SettlementRepositoryToken = Symbol.for('secscore.settlementRepository')
|
||||
export const TagRepositoryToken = Symbol.for('secscore.tagRepository')
|
||||
export const ThemeServiceToken = Symbol.for('secscore.themeService')
|
||||
export const WindowManagerToken = Symbol.for('secscore.windowManager')
|
||||
export const TrayServiceToken = Symbol.for('secscore.trayService')
|
||||
export const AutoScoreServiceToken = Symbol.for('secscore.autoScoreService')
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ServiceCollection, ServiceProvider } from './serviceCollection'
|
||||
|
||||
export type awaitable<T> = T | Promise<T>
|
||||
export type disposer = () => awaitable<void>
|
||||
|
||||
export type serviceToken<T = unknown> = string | symbol | (new (...args: any[]) => T)
|
||||
|
||||
export interface injectableClass<T = unknown> {
|
||||
new (...args: any[]): T
|
||||
inject?: readonly serviceToken[]
|
||||
}
|
||||
|
||||
export type serviceFactory<T> = (provider: ServiceProvider) => T
|
||||
export type serviceFactoryOrValue<T> = serviceFactory<T> | injectableClass<T> | T
|
||||
|
||||
export type serviceLifetime = 'singleton' | 'scoped' | 'transient'
|
||||
|
||||
export interface serviceDescriptor<T = unknown> {
|
||||
token: serviceToken<T>
|
||||
lifetime: serviceLifetime
|
||||
factory: serviceFactory<T>
|
||||
}
|
||||
|
||||
export interface hostedService {
|
||||
start(): awaitable<void>
|
||||
stop(): awaitable<void>
|
||||
}
|
||||
|
||||
export interface hostBuilderSettings {
|
||||
environment?: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface hostApplicationLifetime {
|
||||
onStarted(handler: () => awaitable<void>): disposer
|
||||
onStopping(handler: () => awaitable<void>): disposer
|
||||
onStopped(handler: () => awaitable<void>): disposer
|
||||
notifyStarted(): Promise<void>
|
||||
notifyStopping(): Promise<void>
|
||||
notifyStopped(): Promise<void>
|
||||
}
|
||||
|
||||
export interface appRuntimeContext {
|
||||
logger?: { error: (...args: any[]) => void }
|
||||
}
|
||||
|
||||
export interface hostBuilderContext {
|
||||
ctx: appRuntimeContext
|
||||
environmentName: string
|
||||
properties: Map<string | symbol, unknown>
|
||||
lifetime: hostApplicationLifetime
|
||||
}
|
||||
|
||||
export interface hostApplicationContext {
|
||||
services: ServiceProvider
|
||||
host: hostBuilderContext
|
||||
}
|
||||
|
||||
export type configureServicesDelegate = (
|
||||
context: hostBuilderContext,
|
||||
services: ServiceCollection
|
||||
) => awaitable<void>
|
||||
|
||||
export type configureHostDelegate = (
|
||||
context: hostBuilderContext,
|
||||
app: hostApplicationContext
|
||||
) => awaitable<void>
|
||||
|
||||
export type middleware = (app: hostApplicationContext, next: () => Promise<void>) => awaitable<void>
|
||||
|
||||
export type { ServiceCollection, ServiceProvider }
|
||||
@@ -1,367 +0,0 @@
|
||||
import 'reflect-metadata'
|
||||
import { app } from 'electron'
|
||||
import { join, dirname } from 'path'
|
||||
import fs from 'fs'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/SecScore_logo.ico?asset'
|
||||
import { MainContext } from './context'
|
||||
import { DbManager } from './db/DbManager'
|
||||
import { LoggerService } from './services/LoggerService'
|
||||
import { SettingsService } from './services/SettingsService'
|
||||
import { SecurityService } from './services/SecurityService'
|
||||
import { PermissionService } from './services/PermissionService'
|
||||
import { AuthService } from './services/AuthService'
|
||||
import { DataService } from './services/DataService'
|
||||
import { ThemeService } from './services/ThemeService'
|
||||
import { WindowManager, type windowManagerOptions } from './services/WindowManager'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { AutoScoreService } from './services/AutoScoreService'
|
||||
import { DbConnectionService } from './services/DbConnectionService'
|
||||
import { StudentRepository } from './repos/StudentRepository'
|
||||
import { ReasonRepository } from './repos/ReasonRepository'
|
||||
import { EventRepository } from './repos/EventRepository'
|
||||
import { SettlementRepository } from './repos/SettlementRepository'
|
||||
import { TagRepository } from './repos/TagRepository'
|
||||
import {
|
||||
AppConfigToken,
|
||||
createHostBuilder,
|
||||
DbManagerToken,
|
||||
EventRepositoryToken,
|
||||
LoggerToken,
|
||||
PermissionServiceToken,
|
||||
ReasonRepositoryToken,
|
||||
SecurityServiceToken,
|
||||
SettlementRepositoryToken,
|
||||
SettingsStoreToken,
|
||||
StudentRepositoryToken,
|
||||
TagRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken,
|
||||
TrayServiceToken,
|
||||
AutoScoreServiceToken
|
||||
} from './hosting'
|
||||
|
||||
type mainAppConfig = {
|
||||
isDev: boolean
|
||||
appRoot: string
|
||||
dataRoot: string
|
||||
configDir: string
|
||||
logDir: string
|
||||
dbPath: string
|
||||
pgConnectionString?: string
|
||||
window: windowManagerOptions
|
||||
}
|
||||
|
||||
const PROTOCOL_SCHEME = 'secscore'
|
||||
|
||||
let mainCtxRef: MainContext | null = null
|
||||
let pendingProtocolUrl: string | null = null
|
||||
|
||||
const extractProtocolUrl = (argv: string[]): string | null => {
|
||||
const prefix = `${PROTOCOL_SCHEME}://`
|
||||
const lowerPrefix = prefix.toLowerCase()
|
||||
for (const arg of argv) {
|
||||
if (typeof arg !== 'string') continue
|
||||
const v = arg.trim()
|
||||
if (!v) continue
|
||||
const lower = v.toLowerCase()
|
||||
if (lower.startsWith(lowerPrefix)) return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const openMainRoute = (ctx: MainContext, route: string) => {
|
||||
ctx.windows.open({
|
||||
key: 'main',
|
||||
title: 'SecScore',
|
||||
route
|
||||
})
|
||||
}
|
||||
|
||||
const handleProtocolUrl = (rawUrl: string, ctx: MainContext) => {
|
||||
if (!rawUrl) return
|
||||
let s = rawUrl.trim()
|
||||
if (!s) return
|
||||
const prefix = `${PROTOCOL_SCHEME}://`
|
||||
if (s.toLowerCase().startsWith(prefix)) {
|
||||
s = s.slice(prefix.length)
|
||||
}
|
||||
s = s.replace(/^\/+/, '')
|
||||
if (!s) {
|
||||
openMainRoute(ctx, '/')
|
||||
return
|
||||
}
|
||||
const parts = s.split('/')
|
||||
const head = parts[0]?.toLowerCase() ?? ''
|
||||
if (!head) {
|
||||
openMainRoute(ctx, '/')
|
||||
return
|
||||
}
|
||||
if (head === 'home') {
|
||||
openMainRoute(ctx, '/')
|
||||
return
|
||||
}
|
||||
if (head === 'students') {
|
||||
openMainRoute(ctx, '/students')
|
||||
return
|
||||
}
|
||||
if (head === 'score') {
|
||||
openMainRoute(ctx, '/score')
|
||||
return
|
||||
}
|
||||
if (head === 'leaderboard') {
|
||||
openMainRoute(ctx, '/leaderboard')
|
||||
return
|
||||
}
|
||||
if (head === 'settlements') {
|
||||
openMainRoute(ctx, '/settlements')
|
||||
return
|
||||
}
|
||||
if (head === 'reasons') {
|
||||
openMainRoute(ctx, '/reasons')
|
||||
return
|
||||
}
|
||||
if (head === 'settings') {
|
||||
openMainRoute(ctx, '/settings')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
const initialUrl = extractProtocolUrl(process.argv)
|
||||
if (initialUrl) {
|
||||
pendingProtocolUrl = initialUrl
|
||||
}
|
||||
app.on('second-instance', (event, argv) => {
|
||||
event.preventDefault()
|
||||
const url = extractProtocolUrl(argv)
|
||||
if (!url) return
|
||||
if (mainCtxRef) {
|
||||
handleProtocolUrl(url, mainCtxRef)
|
||||
} else {
|
||||
pendingProtocolUrl = url
|
||||
}
|
||||
})
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
if (mainCtxRef) {
|
||||
handleProtocolUrl(url, mainCtxRef)
|
||||
} else {
|
||||
pendingProtocolUrl = url
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
if (!is.dev) {
|
||||
app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
|
||||
}
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
const appRoot = is.dev ? process.cwd() : dirname(process.execPath)
|
||||
|
||||
const ensureWritableDir = (preferred: string, fallback: string) => {
|
||||
try {
|
||||
if (!fs.existsSync(preferred)) fs.mkdirSync(preferred, { recursive: true })
|
||||
return preferred
|
||||
} catch {
|
||||
if (!fs.existsSync(fallback)) fs.mkdirSync(fallback, { recursive: true })
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const dataRoot = is.dev
|
||||
? process.cwd()
|
||||
: ensureWritableDir(join(appRoot, 'data'), join(app.getPath('userData'), 'secscore-data'))
|
||||
|
||||
const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs')
|
||||
const configDir = is.dev ? join(process.cwd(), 'configs') : join(dataRoot, 'configs')
|
||||
const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite')
|
||||
|
||||
const pgConnectionString = process.env['PG_CONNECTION_STRING'] || undefined
|
||||
|
||||
const config: mainAppConfig = {
|
||||
isDev: is.dev,
|
||||
appRoot,
|
||||
dataRoot,
|
||||
logDir,
|
||||
configDir,
|
||||
dbPath,
|
||||
pgConnectionString,
|
||||
window: {
|
||||
icon,
|
||||
preloadPath: join(__dirname, '../preload/index.js'),
|
||||
rendererHtmlPath: join(__dirname, '../renderer/index.html'),
|
||||
getRendererUrl: () => (is.dev ? process.env['ELECTRON_RENDERER_URL'] : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const builder = createHostBuilder({
|
||||
logger: {
|
||||
error: (...args: any[]) => {
|
||||
try {
|
||||
process.stderr.write(`${args.map((a) => String(a)).join(' ')}\n`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.configureServices(async (_builderContext, services) => {
|
||||
services.addSingleton(AppConfigToken, config)
|
||||
|
||||
services.addSingleton(MainContext, () => new MainContext())
|
||||
|
||||
services.addSingleton(
|
||||
LoggerToken,
|
||||
(p) => new LoggerService(p.get(MainContext), config.logDir)
|
||||
)
|
||||
services.addSingleton(
|
||||
DbManagerToken,
|
||||
(p) => new DbManager(p.get(MainContext), config.dbPath, config.pgConnectionString)
|
||||
)
|
||||
services.addSingleton(SettingsStoreToken, (p) => new SettingsService(p.get(MainContext)))
|
||||
services.addSingleton(SecurityServiceToken, (p) => new SecurityService(p.get(MainContext)))
|
||||
services.addSingleton(
|
||||
PermissionServiceToken,
|
||||
(p) => new PermissionService(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(AuthService, (p) => new AuthService(p.get(MainContext)))
|
||||
services.addSingleton(
|
||||
DataService,
|
||||
(p) => new DataService(p.get(MainContext), p.get(TagRepositoryToken))
|
||||
)
|
||||
|
||||
services.addSingleton(
|
||||
StudentRepositoryToken,
|
||||
(p) => new StudentRepository(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(ReasonRepositoryToken, (p) => new ReasonRepository(p.get(MainContext)))
|
||||
services.addSingleton(EventRepositoryToken, (p) => new EventRepository(p.get(MainContext)))
|
||||
services.addSingleton(
|
||||
SettlementRepositoryToken,
|
||||
(p) => new SettlementRepository(p.get(MainContext))
|
||||
)
|
||||
services.addSingleton(
|
||||
TagRepositoryToken,
|
||||
(p) => new TagRepository((p.get(DbManagerToken) as DbManager).dataSource)
|
||||
)
|
||||
|
||||
services.addSingleton(ThemeServiceToken, (p) => new ThemeService(p.get(MainContext)))
|
||||
services.addSingleton(
|
||||
WindowManagerToken,
|
||||
(p) => new WindowManager(p.get(MainContext), config.window)
|
||||
)
|
||||
services.addSingleton(
|
||||
TrayServiceToken,
|
||||
(p) => new TrayService(p.get(MainContext), config.window)
|
||||
)
|
||||
services.addSingleton(AutoScoreServiceToken, (p) => new AutoScoreService(p.get(MainContext)))
|
||||
services.addSingleton(DbConnectionService, (p) => new DbConnectionService(p.get(MainContext)))
|
||||
})
|
||||
.configure(async (_builderContext, appCtx) => {
|
||||
const services = appCtx.services
|
||||
services.get(LoggerToken)
|
||||
// 先初始化 db(使用默认 SQLite)
|
||||
const db = services.get(DbManagerToken) as DbManager
|
||||
await db.initialize()
|
||||
// 然后初始化 settings
|
||||
const settings = services.get(SettingsStoreToken) as SettingsService
|
||||
await settings.initialize()
|
||||
// 检查是否需要切换到 PostgreSQL
|
||||
const pgConnectionString = settings.getValue('pg_connection_string')
|
||||
if (pgConnectionString) {
|
||||
try {
|
||||
await db.switchConnection(pgConnectionString)
|
||||
await settings.setValue('pg_connection_status', {
|
||||
connected: true,
|
||||
type: 'postgresql'
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('Failed to connect to PostgreSQL:', e)
|
||||
await settings.setValue('pg_connection_status', {
|
||||
connected: false,
|
||||
type: 'postgresql',
|
||||
error: e?.message || '连接失败'
|
||||
})
|
||||
// 切换回 SQLite
|
||||
await db.switchConnection(undefined)
|
||||
}
|
||||
}
|
||||
services.get(SecurityServiceToken)
|
||||
services.get(PermissionServiceToken)
|
||||
services.get(AuthService)
|
||||
services.get(DataService)
|
||||
services.get(StudentRepositoryToken)
|
||||
services.get(ReasonRepositoryToken)
|
||||
services.get(EventRepositoryToken)
|
||||
services.get(SettlementRepositoryToken)
|
||||
const theme = services.get(
|
||||
ThemeServiceToken
|
||||
) as import('./services/ThemeService').ThemeService
|
||||
await theme.init()
|
||||
if (!process.env.HEADLESS) {
|
||||
services.get(WindowManagerToken)
|
||||
const tray = services.get(TrayServiceToken) as TrayService
|
||||
tray.initialize()
|
||||
}
|
||||
const autoScore = services.get(AutoScoreServiceToken) as AutoScoreService
|
||||
await autoScore.initialize?.()
|
||||
services.get(DbConnectionService)
|
||||
})
|
||||
|
||||
const host = await builder.build()
|
||||
const ctx = host.services.get(MainContext) as MainContext
|
||||
mainCtxRef = ctx
|
||||
|
||||
ctx.handle('app:register-url-protocol', async () => {
|
||||
if (is.dev) {
|
||||
return { success: false, message: '仅在打包后的应用中可用' }
|
||||
}
|
||||
try {
|
||||
const ok = app.setAsDefaultProtocolClient(PROTOCOL_SCHEME)
|
||||
if (ok) {
|
||||
return { success: true, data: { registered: true } }
|
||||
}
|
||||
return { success: false, data: { registered: false }, message: '系统未接受协议注册' }
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||||
return { success: false, message: `注册失败: ${message}` }
|
||||
}
|
||||
})
|
||||
|
||||
await host.start()
|
||||
|
||||
if (pendingProtocolUrl) {
|
||||
handleProtocolUrl(pendingProtocolUrl, ctx)
|
||||
pendingProtocolUrl = null
|
||||
} else {
|
||||
openMainRoute(ctx, '/')
|
||||
}
|
||||
|
||||
let disposing = false
|
||||
const beforeQuitHandler = () => {
|
||||
if (disposing) return
|
||||
disposing = true
|
||||
ctx.isQuitting = true
|
||||
app.removeListener('before-quit', beforeQuitHandler)
|
||||
void host.dispose()
|
||||
}
|
||||
app.on('before-quit', beforeQuitHandler)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -1,248 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { IsNull } from 'typeorm'
|
||||
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
export interface scoreEvent {
|
||||
id: number
|
||||
uuid: string
|
||||
student_name: string
|
||||
reason_content: string
|
||||
delta: number
|
||||
val_prev: number
|
||||
val_curr: number
|
||||
event_time: string
|
||||
settlement_id?: number | null
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
events: EventRepository
|
||||
}
|
||||
}
|
||||
|
||||
export class EventRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'events')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:event:query', async (_, params) => {
|
||||
try {
|
||||
return { success: true, data: await this.findAll(params?.limit) }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:delete', async (event, uuid) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.deleteByUuid(uuid)
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:create', async (event, data) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'points'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const id = await this.create(data)
|
||||
return { success: true, data: id }
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:event:queryByStudent', async (_, params) => {
|
||||
try {
|
||||
const limit = Number(params?.limit ?? 50)
|
||||
const studentName = String(params?.student_name ?? '')
|
||||
const startTime = params?.startTime ? String(params.startTime) : null
|
||||
if (!studentName) return { success: true, data: [] }
|
||||
const rows = await this.queryByStudent(studentName, startTime, limit)
|
||||
return { success: true, data: rows }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:leaderboard:query', async (_, params) => {
|
||||
try {
|
||||
const range = String(params?.range ?? 'today')
|
||||
const data = await this.queryLeaderboard(range)
|
||||
return { success: true, data }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(limit = 100) {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
|
||||
return await repo.find({
|
||||
where: { settlement_id: IsNull() },
|
||||
order: { event_time: 'DESC' },
|
||||
take: limit
|
||||
})
|
||||
}
|
||||
|
||||
async create(event: { student_name: string; reason_content: string; delta: number }) {
|
||||
const ds = this.ctx.db.dataSource
|
||||
return await ds.transaction(async (manager) => {
|
||||
const studentName = String(event?.student_name ?? '').trim()
|
||||
const reasonContent = String(event?.reason_content ?? '').trim()
|
||||
const delta = Number(event?.delta ?? 0)
|
||||
|
||||
const studentRepo = manager.getRepository(StudentEntity)
|
||||
const existingStudent = await studentRepo.findOne({ where: { name: studentName } })
|
||||
if (!existingStudent) throw new Error('Student not found')
|
||||
|
||||
const val_prev = Number(existingStudent.score ?? 0)
|
||||
const val_curr = val_prev + (Number.isFinite(delta) ? delta : 0)
|
||||
const uuid = uuidv4()
|
||||
const event_time = new Date().toISOString()
|
||||
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const saved = await eventsRepo.save(
|
||||
eventsRepo.create({
|
||||
uuid,
|
||||
student_name: studentName,
|
||||
reason_content: reasonContent,
|
||||
delta: Number.isFinite(delta) ? delta : 0,
|
||||
val_prev,
|
||||
val_curr,
|
||||
event_time,
|
||||
settlement_id: null
|
||||
})
|
||||
)
|
||||
|
||||
await studentRepo.update(
|
||||
{ id: existingStudent.id },
|
||||
{ score: val_curr, updated_at: new Date().toISOString() }
|
||||
)
|
||||
|
||||
return saved.id
|
||||
})
|
||||
}
|
||||
|
||||
async deleteByUuid(uuid: string) {
|
||||
const ds = this.ctx.db.dataSource
|
||||
await ds.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const ev = await eventsRepo.findOne({ where: { uuid: String(uuid ?? '').trim() } })
|
||||
if (!ev) return
|
||||
if (ev.settlement_id !== null && ev.settlement_id !== undefined) {
|
||||
throw new Error('该记录已结算,无法撤销')
|
||||
}
|
||||
|
||||
const studentRepo = manager.getRepository(StudentEntity)
|
||||
const student = await studentRepo.findOne({ where: { name: ev.student_name } })
|
||||
if (student) {
|
||||
await studentRepo.update(
|
||||
{ id: student.id },
|
||||
{
|
||||
score: Number(student.score ?? 0) - Number(ev.delta ?? 0),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await eventsRepo.delete({ uuid: ev.uuid })
|
||||
})
|
||||
}
|
||||
|
||||
private isPostgres(): boolean {
|
||||
return this.ctx.db.dataSource.options.type === 'postgres'
|
||||
}
|
||||
|
||||
async queryByStudent(studentName: string, startTime: string | null, limit: number) {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
|
||||
const qb = repo
|
||||
.createQueryBuilder('e')
|
||||
.where('e.student_name = :studentName', { studentName })
|
||||
.andWhere('e.settlement_id IS NULL')
|
||||
.orderBy('e.event_time', 'DESC')
|
||||
.limit(limit)
|
||||
if (startTime) {
|
||||
if (this.isPostgres()) {
|
||||
qb.andWhere('e.event_time >= :startTime', { startTime })
|
||||
} else {
|
||||
qb.andWhere('julianday(e.event_time) >= julianday(:startTime)', { startTime })
|
||||
}
|
||||
}
|
||||
return await qb.getMany()
|
||||
}
|
||||
|
||||
async queryLeaderboard(range: string) {
|
||||
const now = new Date()
|
||||
let start = new Date(now)
|
||||
if (range === 'today') {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else if (range === 'week') {
|
||||
const day = start.getDay()
|
||||
const diff = (day === 0 ? -6 : 1) - day
|
||||
start.setDate(start.getDate() + diff)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else if (range === 'month') {
|
||||
start = new Date(start.getFullYear(), start.getMonth(), 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
} else {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
}
|
||||
const startTime = start.toISOString()
|
||||
|
||||
const isPg = this.isPostgres()
|
||||
const joinCondition = isPg
|
||||
? 'e.student_name = s.name AND e.settlement_id IS NULL AND e.event_time >= :startTime'
|
||||
: 'e.student_name = s.name AND e.settlement_id IS NULL AND julianday(e.event_time) >= julianday(:startTime)'
|
||||
|
||||
const qb = this.ctx.db.dataSource
|
||||
.getRepository(StudentEntity)
|
||||
.createQueryBuilder('s')
|
||||
.leftJoin(ScoreEventEntity, 'e', joinCondition, { startTime })
|
||||
.select('s.id', 'id')
|
||||
.addSelect('s.name', 'name')
|
||||
.addSelect('s.score', 'score')
|
||||
.addSelect('COALESCE(SUM(e.delta), 0)', 'range_change')
|
||||
.groupBy('s.id')
|
||||
.addGroupBy('s.name')
|
||||
.addGroupBy('s.score')
|
||||
.orderBy('s.score', 'DESC')
|
||||
.addOrderBy('range_change', 'DESC')
|
||||
.addOrderBy('s.name', 'ASC')
|
||||
|
||||
const rows = await qb.getRawMany()
|
||||
return { startTime, rows }
|
||||
}
|
||||
|
||||
async getLastScoreTimeByStudents(studentNames: string[]): Promise<Map<string, Date>> {
|
||||
if (studentNames.length === 0) return new Map()
|
||||
|
||||
const repo = this.ctx.db.dataSource.getRepository(ScoreEventEntity)
|
||||
const results = await repo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.student_name', 'student_name')
|
||||
.addSelect('MAX(e.event_time)', 'last_time')
|
||||
.where('e.student_name IN (:...studentNames)', { studentNames })
|
||||
.groupBy('e.student_name')
|
||||
.getRawMany()
|
||||
|
||||
const map = new Map<string, Date>()
|
||||
for (const row of results) {
|
||||
if (row.last_time) {
|
||||
map.set(row.student_name, new Date(row.last_time))
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { ReasonEntity } from '../db/entities'
|
||||
|
||||
export interface reason {
|
||||
id: number
|
||||
content: string
|
||||
category: string
|
||||
delta: number
|
||||
is_system: number
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
reasons: ReasonRepository
|
||||
}
|
||||
}
|
||||
|
||||
export class ReasonRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'reasons')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:reason:query', async () => ({
|
||||
success: true,
|
||||
data: await this.findAll()
|
||||
}))
|
||||
this.mainCtx.handle('db:reason:create', async (event, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: await this.create(data) }
|
||||
})
|
||||
this.mainCtx.handle('db:reason:update', async (event, id, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.update(id, data)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('db:reason:delete', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const changes = await this.delete(id)
|
||||
if (!changes) return { success: false, message: '记录不存在' }
|
||||
return { success: true, data: { changes } }
|
||||
})
|
||||
// 兼容前端 deleteReason 命名错误
|
||||
this.mainCtx.handle('db:deleteReason', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const changes = await this.delete(id)
|
||||
if (!changes) return { success: false, message: '记录不存在' }
|
||||
return { success: true, data: { changes } }
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(): Promise<reason[]> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
|
||||
return (await repo.find({ order: { category: 'ASC', content: 'ASC' } })) as any
|
||||
}
|
||||
|
||||
async create(reason: Omit<reason, 'id' | 'is_system'>): Promise<number> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(ReasonEntity)
|
||||
const created = repo.create({
|
||||
content: String(reason?.content ?? '').trim(),
|
||||
category: String(reason?.category ?? '其他'),
|
||||
delta: Number(reason?.delta ?? 0),
|
||||
is_system: 0,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
const saved = await repo.save(created)
|
||||
return saved.id
|
||||
}
|
||||
|
||||
async update(id: number, reason: Partial<reason>): Promise<void> {
|
||||
const next: any = {}
|
||||
for (const [key, val] of Object.entries(reason)) {
|
||||
if (key === 'id') continue
|
||||
next[key] = val
|
||||
}
|
||||
next.updated_at = new Date().toISOString()
|
||||
await this.ctx.db.dataSource.getRepository(ReasonEntity).update(id, next)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<number> {
|
||||
const result = await this.ctx.db.dataSource.getRepository(ReasonEntity).delete(id)
|
||||
return Number(result.affected ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { IsNull } from 'typeorm'
|
||||
import { ScoreEventEntity, SettlementEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
function isPostgres(ds: any): boolean {
|
||||
return ds.options?.type === 'postgres'
|
||||
}
|
||||
|
||||
export interface settlementSummary {
|
||||
id: number
|
||||
start_time: string
|
||||
end_time: string
|
||||
event_count: number
|
||||
}
|
||||
|
||||
export interface settlementLeaderboardRow {
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
settlements: SettlementRepository
|
||||
}
|
||||
}
|
||||
|
||||
export class SettlementRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'settlements')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:settlement:query', async () => {
|
||||
try {
|
||||
return { success: true, data: await this.findAll() }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:settlement:create', async (event) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const data = await this.settleNow()
|
||||
return { success: true, data }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:settlement:leaderboard', async (_, params) => {
|
||||
try {
|
||||
const settlementId = Number(params?.settlement_id)
|
||||
if (!Number.isFinite(settlementId))
|
||||
return { success: false, message: 'Invalid settlement_id' }
|
||||
return { success: true, data: await this.getLeaderboard(settlementId) }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(): Promise<settlementSummary[]> {
|
||||
const ds = this.ctx.db.dataSource
|
||||
const orderBy = isPostgres(ds) ? 's.end_time' : 'julianday(s.end_time)'
|
||||
const qb = ds
|
||||
.getRepository(SettlementEntity)
|
||||
.createQueryBuilder('s')
|
||||
.select('s.id', 'id')
|
||||
.addSelect('s.start_time', 'start_time')
|
||||
.addSelect('s.end_time', 'end_time')
|
||||
.addSelect((subQb) => {
|
||||
return subQb
|
||||
.select('COUNT(1)', 'cnt')
|
||||
.from(ScoreEventEntity, 'e')
|
||||
.where('e.settlement_id = s.id')
|
||||
}, 'event_count')
|
||||
.orderBy(orderBy, 'DESC')
|
||||
|
||||
const rows = await qb.getRawMany()
|
||||
return rows.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
start_time: String(r.start_time),
|
||||
end_time: String(r.end_time),
|
||||
event_count: Number(r.event_count ?? 0)
|
||||
}))
|
||||
}
|
||||
|
||||
async settleNow() {
|
||||
const ds = this.ctx.db.dataSource
|
||||
return await ds.transaction(async (manager) => {
|
||||
const eventsRepo = manager.getRepository(ScoreEventEntity)
|
||||
const unassignedCount = await eventsRepo.count({ where: { settlement_id: IsNull() } })
|
||||
const eventCount = Number(unassignedCount ?? 0)
|
||||
if (eventCount <= 0) {
|
||||
throw new Error('暂无可结算记录')
|
||||
}
|
||||
|
||||
const endTime = new Date().toISOString()
|
||||
|
||||
const settlementsRepo = manager.getRepository(SettlementEntity)
|
||||
const ds = this.ctx.db.dataSource
|
||||
const orderBy = isPostgres(ds) ? 's.end_time' : 'julianday(s.end_time)'
|
||||
const lastSettlement = await settlementsRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.end_time', 'end_time')
|
||||
.orderBy(orderBy, 'DESC')
|
||||
.limit(1)
|
||||
.getRawOne<{ end_time?: string }>()
|
||||
|
||||
const minEvent = await eventsRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('MIN(e.event_time)', 'min_time')
|
||||
.where('e.settlement_id IS NULL')
|
||||
.getRawOne<{ min_time?: string }>()
|
||||
|
||||
const startTime = String(lastSettlement?.end_time || minEvent?.min_time || endTime)
|
||||
|
||||
const created = await settlementsRepo.save(
|
||||
settlementsRepo.create({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
)
|
||||
const settlementId = created.id
|
||||
|
||||
await eventsRepo
|
||||
.createQueryBuilder()
|
||||
.update(ScoreEventEntity)
|
||||
.set({ settlement_id: settlementId })
|
||||
.where('settlement_id IS NULL')
|
||||
.execute()
|
||||
|
||||
await manager
|
||||
.getRepository(StudentEntity)
|
||||
.createQueryBuilder()
|
||||
.update(StudentEntity)
|
||||
.set({ score: 0, updated_at: new Date().toISOString() })
|
||||
.execute()
|
||||
|
||||
return { settlementId, startTime, endTime, eventCount }
|
||||
})
|
||||
}
|
||||
|
||||
async getLeaderboard(settlementId: number) {
|
||||
const settlementsRepo = this.ctx.db.dataSource.getRepository(SettlementEntity)
|
||||
const settlement = await settlementsRepo.findOne({ where: { id: settlementId } })
|
||||
if (!settlement) {
|
||||
throw new Error('结算记录不存在')
|
||||
}
|
||||
|
||||
const rows = await this.ctx.db.dataSource
|
||||
.getRepository(ScoreEventEntity)
|
||||
.createQueryBuilder('e')
|
||||
.select('e.student_name', 'name')
|
||||
.addSelect('COALESCE(SUM(e.delta), 0)', 'score')
|
||||
.where('e.settlement_id = :settlementId', { settlementId })
|
||||
.groupBy('e.student_name')
|
||||
.orderBy('score', 'DESC')
|
||||
.addOrderBy('name', 'ASC')
|
||||
.getRawMany<settlementLeaderboardRow>()
|
||||
|
||||
return {
|
||||
settlement: {
|
||||
id: settlement.id,
|
||||
start_time: settlement.start_time,
|
||||
end_time: settlement.end_time
|
||||
},
|
||||
rows: rows.map((r: any) => ({ name: String(r.name), score: Number(r.score ?? 0) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { ScoreEventEntity, StudentEntity } from '../db/entities'
|
||||
|
||||
export interface student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
tags?: string[]
|
||||
extra_json?: string
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
students: StudentRepository
|
||||
}
|
||||
}
|
||||
|
||||
export class StudentRepository extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'students')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:student:query', async () => ({
|
||||
success: true,
|
||||
data: await this.findAll()
|
||||
}))
|
||||
this.mainCtx.handle('db:student:create', async (event, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: await this.create(data) }
|
||||
})
|
||||
this.mainCtx.handle('db:student:update', async (event, id, data) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.update(id, data)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('db:student:delete', async (event, id) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.delete(id)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:student:importFromSecRandom', async (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return {
|
||||
success: false,
|
||||
message: 'SecRandom IPC 导入已禁用(后续再做)'
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:student:importFromXlsx', async (event, input: any) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const rawNames = Array.isArray(input?.names) ? input.names : []
|
||||
const names = rawNames.map((n: any) => String(n ?? '').trim()).filter((n: string) => n)
|
||||
if (!names.length) return { success: false, message: '名单为空' }
|
||||
|
||||
const result = await this.importRosterMerge(
|
||||
names.map((name) => ({ name, secrandomId: null }))
|
||||
)
|
||||
return { success: true, data: result }
|
||||
})
|
||||
}
|
||||
|
||||
async findAll(): Promise<student[]> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
|
||||
const entities = await repo.find({
|
||||
select: ['id', 'name', 'score', 'tags', 'extra_json', 'created_at', 'updated_at'],
|
||||
order: { score: 'DESC', name: 'ASC' }
|
||||
})
|
||||
|
||||
return entities.map((entity) => {
|
||||
let tags: string[] = []
|
||||
try {
|
||||
tags = Array.isArray(entity.tags) ? entity.tags : JSON.parse(entity.tags || '[]')
|
||||
} catch {
|
||||
tags = []
|
||||
}
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
score: entity.score,
|
||||
tags,
|
||||
extra_json: entity.extra_json ?? undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async create(student: { name: string }): Promise<number> {
|
||||
const repo = this.ctx.db.dataSource.getRepository(StudentEntity)
|
||||
const created = repo.create({
|
||||
name: String(student?.name ?? '').trim(),
|
||||
score: 0,
|
||||
extra_json: null,
|
||||
tags: '[]',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
const saved = await repo.save(created)
|
||||
return saved.id
|
||||
}
|
||||
|
||||
async update(id: number, student: Partial<student>): Promise<void> {
|
||||
const next: any = {}
|
||||
for (const [key, val] of Object.entries(student)) {
|
||||
if (key === 'id') continue
|
||||
if (key === 'tags') {
|
||||
next[key] = JSON.stringify(val || [])
|
||||
} else {
|
||||
next[key] = val
|
||||
}
|
||||
}
|
||||
next.updated_at = new Date().toISOString()
|
||||
await this.ctx.db.dataSource.getRepository(StudentEntity).update(id, next)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
const ds = this.ctx.db.dataSource
|
||||
await ds.transaction(async (manager) => {
|
||||
const studentsRepo = manager.getRepository(StudentEntity)
|
||||
const studentRow = await studentsRepo.findOne({ where: { id } })
|
||||
if (!studentRow) return
|
||||
await manager.getRepository(ScoreEventEntity).delete({ student_name: studentRow.name })
|
||||
await studentsRepo.delete({ id })
|
||||
})
|
||||
}
|
||||
|
||||
private async importRosterMerge(items: Array<{ name: string; secrandomId: number | null }>) {
|
||||
const cleaned = Array.from(
|
||||
new Map(
|
||||
items
|
||||
.map((i) => ({
|
||||
name: String(i?.name ?? '').trim(),
|
||||
secrandomId: Number.isFinite(i?.secrandomId as any) ? Number(i.secrandomId) : null
|
||||
}))
|
||||
.filter((i) => i.name && i.name.length <= 64)
|
||||
.map((i) => [i.name, i] as const)
|
||||
).values()
|
||||
)
|
||||
if (!cleaned.length) return { inserted: 0, skipped: 0, total: 0 }
|
||||
|
||||
const ds = this.ctx.db.dataSource
|
||||
return await ds.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(StudentEntity)
|
||||
const existing = await repo.find({ select: ['name'] as any })
|
||||
const existingSet = new Set(existing.map((r: any) => String(r?.name ?? '').trim()))
|
||||
|
||||
const toInsert = cleaned.filter((i) => !existingSet.has(i.name))
|
||||
const now = new Date().toISOString()
|
||||
if (toInsert.length) {
|
||||
await repo.insert(
|
||||
toInsert.map((i) => ({
|
||||
name: i.name,
|
||||
score: 0,
|
||||
extra_json:
|
||||
typeof i.secrandomId === 'number'
|
||||
? JSON.stringify({ secrandom_id: i.secrandomId })
|
||||
: null,
|
||||
tags: '[]',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
inserted: toInsert.length,
|
||||
skipped: cleaned.length - toInsert.length,
|
||||
total: cleaned.length
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { DataSource, Repository } from 'typeorm'
|
||||
import { TagEntity } from '../db/entities/TagEntity'
|
||||
import { StudentTagEntity } from '../db/entities/StudentTagEntity'
|
||||
|
||||
export class TagRepository {
|
||||
private tagRepo: Repository<TagEntity>
|
||||
private studentTagRepo: Repository<StudentTagEntity>
|
||||
|
||||
constructor(dataSource: DataSource) {
|
||||
this.tagRepo = dataSource.getRepository(TagEntity)
|
||||
this.studentTagRepo = dataSource.getRepository(StudentTagEntity)
|
||||
}
|
||||
|
||||
async findAll(): Promise<TagEntity[]> {
|
||||
return this.tagRepo.find({ order: { created_at: 'ASC' } })
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<TagEntity | null> {
|
||||
return this.tagRepo.findOne({ where: { name } })
|
||||
}
|
||||
|
||||
async create(name: string): Promise<TagEntity> {
|
||||
const tag = this.tagRepo.create({ name })
|
||||
return this.tagRepo.save(tag)
|
||||
}
|
||||
|
||||
async findOrCreate(name: string): Promise<TagEntity> {
|
||||
const existing = await this.findByName(name)
|
||||
if (existing) return existing
|
||||
return this.create(name)
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.tagRepo.delete(id)
|
||||
return result.affected === 1
|
||||
}
|
||||
|
||||
async findByStudent(studentId: number): Promise<TagEntity[]> {
|
||||
const relations = await this.studentTagRepo
|
||||
.createQueryBuilder('st')
|
||||
.leftJoinAndSelect('st.tag', 'tag')
|
||||
.where('st.student_id = :studentId', { studentId })
|
||||
.orderBy('st.created_at', 'ASC')
|
||||
.getMany()
|
||||
|
||||
return relations.map((r) => r.tag).filter(Boolean)
|
||||
}
|
||||
|
||||
async addTagToStudent(studentId: number, tagId: number): Promise<void> {
|
||||
const exists = await this.studentTagRepo.findOne({
|
||||
where: { student_id: studentId, tag_id: tagId }
|
||||
})
|
||||
if (!exists) {
|
||||
const relation = this.studentTagRepo.create({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
await this.studentTagRepo.save(relation)
|
||||
}
|
||||
}
|
||||
|
||||
async removeTagFromStudent(studentId: number, tagId: number): Promise<void> {
|
||||
await this.studentTagRepo.delete({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
}
|
||||
|
||||
async updateStudentTags(studentId: number, tagIds: number[]): Promise<void> {
|
||||
await this.studentTagRepo.delete({ student_id: studentId })
|
||||
for (const tagId of tagIds) {
|
||||
const relation = this.studentTagRepo.create({
|
||||
student_id: studentId,
|
||||
tag_id: tagId
|
||||
})
|
||||
await this.studentTagRepo.save(relation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { permissionLevel } from './PermissionService'
|
||||
import crypto from 'crypto'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
auth: AuthService
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthService extends Service {
|
||||
private SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
||||
private SETTINGS_SECURITY_POINTS = 'security_points_password'
|
||||
private SETTINGS_SECURITY_RECOVERY = 'security_recovery_string'
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'auth')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
const ctx = this.mainCtx
|
||||
|
||||
ctx.handle('auth:getStatus', (event) => {
|
||||
const senderId = event?.sender?.id
|
||||
const permission =
|
||||
typeof senderId === 'number'
|
||||
? ctx.permissions.getPermission(senderId)
|
||||
: ctx.permissions.getDefaultPermission()
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
permission,
|
||||
hasAdminPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN),
|
||||
hasPointsPassword: ctx.security.hasSecret(this.SETTINGS_SECURITY_POINTS),
|
||||
hasRecoveryString: ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.handle('auth:login', async (event, password: string) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId !== 'number') return { success: false, message: 'Invalid sender' }
|
||||
if (!ctx.security.isSixDigit(String(password ?? ''))) {
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: false, message: 'Invalid password format' }
|
||||
}
|
||||
|
||||
const adminCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_ADMIN)
|
||||
const pointsCipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_POINTS)
|
||||
const adminPlain = await ctx.security.decryptSecret(adminCipher)
|
||||
const pointsPlain = await ctx.security.decryptSecret(pointsCipher)
|
||||
|
||||
if (adminCipher && adminPlain === password) {
|
||||
ctx.permissions.setPermission(senderId, 'admin')
|
||||
return { success: true, data: { permission: 'admin' as permissionLevel } }
|
||||
}
|
||||
if (pointsCipher && pointsPlain === password) {
|
||||
ctx.permissions.setPermission(senderId, 'points')
|
||||
return { success: true, data: { permission: 'points' as permissionLevel } }
|
||||
}
|
||||
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: false, message: 'Password incorrect' }
|
||||
})
|
||||
|
||||
ctx.handle('auth:logout', (event) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true, data: { permission: ctx.permissions.getDefaultPermission() } }
|
||||
})
|
||||
|
||||
ctx.handle(
|
||||
'auth:setPasswords',
|
||||
async (event, payload: { adminPassword?: string | null; pointsPassword?: string | null }) => {
|
||||
const alreadyHasAdmin = ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN)
|
||||
if (alreadyHasAdmin && !ctx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const adminPasswordRaw = payload?.adminPassword
|
||||
const pointsPasswordRaw = payload?.pointsPassword
|
||||
|
||||
if (typeof adminPasswordRaw === 'string') {
|
||||
const trimmed = adminPasswordRaw.trim()
|
||||
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
else {
|
||||
if (!ctx.security.isSixDigit(trimmed))
|
||||
return { success: false, message: 'Admin password must be 6 digits' }
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_ADMIN,
|
||||
await ctx.security.encryptSecret(trimmed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof pointsPasswordRaw === 'string') {
|
||||
const trimmed = pointsPasswordRaw.trim()
|
||||
if (trimmed.length === 0) await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
else {
|
||||
if (!ctx.security.isSixDigit(trimmed))
|
||||
return { success: false, message: 'Points password must be 6 digits' }
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_POINTS,
|
||||
await ctx.security.encryptSecret(trimmed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.security.hasSecret(this.SETTINGS_SECURITY_RECOVERY)) {
|
||||
const recovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(recovery)
|
||||
)
|
||||
return { success: true, data: { recoveryString: recovery } }
|
||||
}
|
||||
|
||||
return { success: true, data: {} }
|
||||
}
|
||||
)
|
||||
|
||||
ctx.handle('auth:generateRecovery', async (event) => {
|
||||
if (
|
||||
ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN) &&
|
||||
!ctx.permissions.requirePermission(event, 'admin')
|
||||
)
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const recovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(recovery)
|
||||
)
|
||||
return { success: true, data: { recoveryString: recovery } }
|
||||
})
|
||||
|
||||
ctx.handle('auth:resetByRecovery', async (event, recoveryString: string) => {
|
||||
const cipher = ctx.settings.getRaw(this.SETTINGS_SECURITY_RECOVERY)
|
||||
const plain = await ctx.security.decryptSecret(cipher)
|
||||
if (!plain || plain !== String(recoveryString ?? '').trim())
|
||||
return { success: false, message: 'Recovery string incorrect' }
|
||||
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
|
||||
const newRecovery = crypto.randomBytes(18).toString('base64url')
|
||||
await ctx.settings.setRaw(
|
||||
this.SETTINGS_SECURITY_RECOVERY,
|
||||
await ctx.security.encryptSecret(newRecovery)
|
||||
)
|
||||
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true, data: { recoveryString: newRecovery } }
|
||||
})
|
||||
|
||||
ctx.handle('auth:clearAll', async (event) => {
|
||||
if (!ctx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_ADMIN, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_POINTS, '')
|
||||
await ctx.settings.setRaw(this.SETTINGS_SECURITY_RECOVERY, '')
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number')
|
||||
ctx.permissions.setPermission(senderId, ctx.permissions.getDefaultPermission())
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import {
|
||||
AutoScoreRuleEngine,
|
||||
type RuleConfig,
|
||||
type AutoScoreContext
|
||||
} from '../../shared/autoScore/AutoScoreRuleEngine'
|
||||
|
||||
interface AutoScoreRule extends RuleConfig {
|
||||
id: number
|
||||
lastExecuted?: Date
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
autoScore: AutoScoreService
|
||||
}
|
||||
}
|
||||
|
||||
export class AutoScoreService extends Service {
|
||||
private rules: AutoScoreRule[] = []
|
||||
private timers: Map<number, NodeJS.Timeout> = new Map()
|
||||
private initialized = false
|
||||
private ruleEngine: AutoScoreRuleEngine
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'autoScore')
|
||||
this.ruleEngine = new AutoScoreRuleEngine()
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('auto-score:getRules', async (event) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: this.getRules() }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:addRule', async (event, rule) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const id = await this.addRule(rule)
|
||||
return { success: true, data: id }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:updateRule', async (event, rule) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const success = await this.updateRule(rule)
|
||||
return { success, data: success }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:deleteRule', async (event, ruleId) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const success = await this.deleteRule(ruleId)
|
||||
return { success, data: success }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:toggleRule', async (event, params) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const { ruleId, enabled } = params
|
||||
const success = await this.toggleRule(ruleId, enabled)
|
||||
return { success, data: success }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:getStatus', async (event) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
return { success: true, data: { enabled: this.isEnabled() } }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('auto-score:sortRules', async (event, ruleIds) => {
|
||||
try {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
const success = await this.sortRules(ruleIds)
|
||||
return { success, data: success }
|
||||
} catch (err: any) {
|
||||
return { success: false, message: err.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async loadRulesFromFile(): Promise<void> {
|
||||
await this.loadRulesFromSettings()
|
||||
}
|
||||
|
||||
private async loadRulesFromSettings() {
|
||||
try {
|
||||
const settings = await this.mainCtx.settings.getAllRaw()
|
||||
const autoScoreRulesStr = settings['auto_score_rules'] || '[]'
|
||||
const rulesFromSettings = JSON.parse(autoScoreRulesStr)
|
||||
|
||||
this.rules = rulesFromSettings.map((rule: any) => ({
|
||||
...rule,
|
||||
lastExecuted: rule.lastExecuted ? new Date(rule.lastExecuted) : undefined
|
||||
}))
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load auto score rules from settings:', { error })
|
||||
this.rules = []
|
||||
}
|
||||
}
|
||||
|
||||
private async saveRulesToFile(): Promise<void> {
|
||||
await this.saveRulesToSettings()
|
||||
}
|
||||
|
||||
private async saveRulesToSettings() {
|
||||
try {
|
||||
const rulesToSave = this.rules.map(({ lastExecuted, ...rule }) => ({
|
||||
...rule,
|
||||
lastExecuted: lastExecuted?.toISOString()
|
||||
}))
|
||||
await this.mainCtx.settings.setRaw('auto_score_rules', JSON.stringify(rulesToSave))
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to save auto score rules to settings:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
private async startRules() {
|
||||
if (this.initialized) {
|
||||
this.stopRules()
|
||||
}
|
||||
|
||||
this.ruleEngine = new AutoScoreRuleEngine()
|
||||
|
||||
for (const rule of this.rules) {
|
||||
if (rule.enabled) {
|
||||
await this.ruleEngine.addRule(rule)
|
||||
this.startRuleTimer(rule)
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private startRuleTimer(rule: AutoScoreRule) {
|
||||
if (this.timers.has(rule.id)) {
|
||||
clearTimeout(this.timers.get(rule.id)!)
|
||||
this.timers.delete(rule.id)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let delayMs = 0
|
||||
let primaryTrigger: { event: string; value?: string } | undefined
|
||||
|
||||
for (const trigger of rule.triggers || []) {
|
||||
if (trigger.event === 'interval_time_passed') {
|
||||
const minutes = parseInt(trigger.value || '30', 10)
|
||||
const intervalMs = minutes * 60 * 1000
|
||||
|
||||
let nextExecuteTime: Date
|
||||
if (rule.lastExecuted) {
|
||||
nextExecuteTime = new Date(rule.lastExecuted.getTime() + intervalMs)
|
||||
} else {
|
||||
nextExecuteTime = new Date(now.getTime() + intervalMs)
|
||||
}
|
||||
|
||||
const triggerDelayMs = nextExecuteTime.getTime() - now.getTime()
|
||||
if (delayMs === 0 || triggerDelayMs < delayMs) {
|
||||
delayMs = triggerDelayMs
|
||||
primaryTrigger = trigger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!primaryTrigger) {
|
||||
this.logger.warn(`Rule ${rule.name} has no valid triggers with timing logic, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
if (delayMs < 0) {
|
||||
delayMs = 0
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.executeRule(rule)
|
||||
this.setRuleInterval(rule)
|
||||
}, delayMs)
|
||||
|
||||
this.timers.set(rule.id, timer)
|
||||
this.logger.info(`Rule ${rule.name} scheduled to execute in ${delayMs}ms`)
|
||||
}
|
||||
|
||||
private setRuleInterval(rule: AutoScoreRule) {
|
||||
const now = new Date()
|
||||
let minDelayMs = Infinity
|
||||
let primaryTrigger: { event: string; value?: string } | undefined
|
||||
|
||||
for (const trigger of rule.triggers || []) {
|
||||
if (trigger.event === 'interval_time_passed') {
|
||||
const minutes = parseInt(trigger.value || '30', 10)
|
||||
const intervalMs = minutes * 60 * 1000
|
||||
|
||||
let nextExecuteTime: Date
|
||||
if (rule.lastExecuted) {
|
||||
nextExecuteTime = new Date(rule.lastExecuted.getTime() + intervalMs)
|
||||
} else {
|
||||
nextExecuteTime = new Date(now.getTime() + intervalMs)
|
||||
}
|
||||
|
||||
const triggerDelayMs = nextExecuteTime.getTime() - now.getTime()
|
||||
if (triggerDelayMs < minDelayMs) {
|
||||
minDelayMs = triggerDelayMs
|
||||
primaryTrigger = trigger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!primaryTrigger || minDelayMs === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
this.executeRule(rule)
|
||||
}, minDelayMs)
|
||||
|
||||
this.timers.set(rule.id, timer)
|
||||
}
|
||||
|
||||
private async executeRule(rule: AutoScoreRule) {
|
||||
try {
|
||||
this.logger.info(`Executing auto score rule: ${rule.name}`)
|
||||
|
||||
const studentRepo = this.mainCtx.students
|
||||
const eventRepo = this.mainCtx.events
|
||||
|
||||
const allStudents = await studentRepo.findAll()
|
||||
|
||||
let studentsToScore = allStudents
|
||||
if (rule.studentNames.length > 0) {
|
||||
studentsToScore = allStudents.filter((s) => rule.studentNames.includes(s.name))
|
||||
}
|
||||
|
||||
const studentNames = studentsToScore.map((s) => s.name)
|
||||
const lastScoreTimeMap = await eventRepo.getLastScoreTimeByStudents(studentNames)
|
||||
|
||||
const context: AutoScoreContext = {
|
||||
students: studentsToScore.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
tags: s.tags || [],
|
||||
lastScoreTime: lastScoreTimeMap.get(s.name) || new Date(0)
|
||||
})),
|
||||
events: [],
|
||||
rule: {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
studentNames: rule.studentNames,
|
||||
triggers: rule.triggers,
|
||||
actions: rule.actions
|
||||
},
|
||||
now: new Date()
|
||||
}
|
||||
|
||||
const results = await this.ruleEngine.run(context)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.matchedStudents.length > 0) {
|
||||
for (const action of result.actions) {
|
||||
await this.ruleEngine.executeAction(
|
||||
action.event,
|
||||
result.matchedStudents,
|
||||
{
|
||||
...action,
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
studentRepo
|
||||
},
|
||||
eventRepo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rule.lastExecuted = new Date()
|
||||
await this.saveRulesToFile()
|
||||
|
||||
this.logger.info(
|
||||
`Auto score rule executed successfully for ${results.reduce((sum, r) => sum + r.matchedStudents.length, 0)} students`
|
||||
)
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to execute auto score rule ${rule.name}:`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
private stopRules() {
|
||||
for (const timer of this.timers.values()) {
|
||||
clearTimeout(timer)
|
||||
clearInterval(timer)
|
||||
}
|
||||
this.timers.clear()
|
||||
}
|
||||
|
||||
async addRule(rule: Omit<AutoScoreRule, 'id' | 'lastExecuted'>): Promise<number> {
|
||||
const newId = this.rules.length > 0 ? Math.max(...this.rules.map((r) => r.id)) + 1 : 1
|
||||
const newRule: AutoScoreRule = {
|
||||
...rule,
|
||||
id: newId,
|
||||
lastExecuted: undefined
|
||||
}
|
||||
|
||||
this.rules.push(newRule)
|
||||
await this.saveRulesToFile()
|
||||
|
||||
if (rule.enabled) {
|
||||
await this.ruleEngine.addRule(newRule)
|
||||
this.startRuleTimer(newRule)
|
||||
}
|
||||
|
||||
return newId
|
||||
}
|
||||
|
||||
async updateRule(rule: Partial<AutoScoreRule> & { id: number }): Promise<boolean> {
|
||||
const index = this.rules.findIndex((r) => r.id === rule.id)
|
||||
if (index === -1) return false
|
||||
|
||||
if (this.timers.has(rule.id)) {
|
||||
clearTimeout(this.timers.get(rule.id)!)
|
||||
clearInterval(this.timers.get(rule.id)!)
|
||||
this.timers.delete(rule.id)
|
||||
}
|
||||
|
||||
const updatedRule = { ...this.rules[index], ...rule }
|
||||
this.rules[index] = updatedRule
|
||||
|
||||
await this.saveRulesToFile()
|
||||
|
||||
if (updatedRule.enabled) {
|
||||
await this.ruleEngine.addRule(updatedRule)
|
||||
this.startRuleTimer(updatedRule)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async deleteRule(ruleId: number): Promise<boolean> {
|
||||
const index = this.rules.findIndex((r) => r.id === ruleId)
|
||||
if (index === -1) return false
|
||||
|
||||
if (this.timers.has(ruleId)) {
|
||||
clearTimeout(this.timers.get(ruleId)!)
|
||||
clearInterval(this.timers.get(ruleId)!)
|
||||
this.timers.delete(ruleId)
|
||||
}
|
||||
|
||||
this.rules.splice(index, 1)
|
||||
await this.saveRulesToFile()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async toggleRule(ruleId: number, enabled: boolean): Promise<boolean> {
|
||||
const rule = this.rules.find((r) => r.id === ruleId)
|
||||
if (!rule) return false
|
||||
|
||||
rule.enabled = enabled
|
||||
|
||||
if (this.timers.has(ruleId)) {
|
||||
clearTimeout(this.timers.get(ruleId)!)
|
||||
clearInterval(this.timers.get(ruleId)!)
|
||||
this.timers.delete(ruleId)
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await this.ruleEngine.addRule(rule)
|
||||
this.startRuleTimer(rule)
|
||||
}
|
||||
|
||||
await this.saveRulesToFile()
|
||||
return true
|
||||
}
|
||||
|
||||
async sortRules(ruleIds: number[]): Promise<boolean> {
|
||||
const ruleMap = new Map(this.rules.map((r) => [r.id, r]))
|
||||
const sortedRules: AutoScoreRule[] = []
|
||||
for (const id of ruleIds) {
|
||||
const rule = ruleMap.get(id)
|
||||
if (rule) {
|
||||
sortedRules.push(rule)
|
||||
}
|
||||
}
|
||||
for (const rule of this.rules) {
|
||||
if (!ruleIds.includes(rule.id)) {
|
||||
sortedRules.push(rule)
|
||||
}
|
||||
}
|
||||
this.rules = sortedRules
|
||||
await this.saveRulesToFile()
|
||||
return true
|
||||
}
|
||||
|
||||
getRules(): AutoScoreRule[] {
|
||||
return [...this.rules]
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.rules.some((rule) => rule.enabled)
|
||||
}
|
||||
|
||||
async restart() {
|
||||
this.stopRules()
|
||||
await this.loadRulesFromFile()
|
||||
this.startRules()
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.loadRulesFromFile()
|
||||
this.startRules()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.stopRules()
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { DataBackupRepository } from '../db/backup/DataBackupRepository'
|
||||
import { TagRepository } from '../repos/TagRepository'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
data: DataService
|
||||
}
|
||||
}
|
||||
|
||||
export class DataService extends Service {
|
||||
constructor(
|
||||
ctx: MainContext,
|
||||
private tagRepo: TagRepository
|
||||
) {
|
||||
super(ctx, 'data')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('data:exportJson', async (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const backup = new DataBackupRepository(this.mainCtx.db.dataSource)
|
||||
return {
|
||||
success: true,
|
||||
data: await backup.exportJson()
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('data:importJson', async (event, jsonText: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const backup = new DataBackupRepository(this.mainCtx.db.dataSource)
|
||||
const result = await backup.importJson(jsonText)
|
||||
if (!result.success) return result
|
||||
|
||||
await this.mainCtx.settings.reloadFromDb()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:getAll', async (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'view'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tags = await this.tagRepo.findAll()
|
||||
return {
|
||||
success: true,
|
||||
data: tags.map((t) => ({ id: t.id, name: t.name }))
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:getByStudent', async (event, studentId: number) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'view'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tags = await this.tagRepo.findByStudent(studentId)
|
||||
return {
|
||||
success: true,
|
||||
data: tags.map((t) => ({ id: t.id, name: t.name }))
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:create', async (event, name: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const tag = await this.tagRepo.findOrCreate(name)
|
||||
return {
|
||||
success: true,
|
||||
data: { id: tag.id, name: tag.name }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('tags:delete', async (event, id: number) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
const success = await this.tagRepo.delete(id)
|
||||
return {
|
||||
success,
|
||||
message: success ? '删除成功' : '删除失败'
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle(
|
||||
'tags:updateStudentTags',
|
||||
async (event, studentId: number, tagIds: number[]) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
await this.tagRepo.updateStudentTags(studentId, tagIds)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { DataSource } from 'typeorm'
|
||||
import { StudentEntity } from '../db/entities/StudentEntity'
|
||||
import { ReasonEntity } from '../db/entities/ReasonEntity'
|
||||
import { ScoreEventEntity } from '../db/entities/ScoreEventEntity'
|
||||
import { SettlementEntity } from '../db/entities/SettlementEntity'
|
||||
import { SettingEntity } from '../db/entities/SettingEntity'
|
||||
import { TagEntity } from '../db/entities/TagEntity'
|
||||
import { StudentTagEntity } from '../db/entities/StudentTagEntity'
|
||||
import { parsePostgreSQLConnectionString } from '../db/DbManager'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
dbConnection: DbConnectionService
|
||||
}
|
||||
}
|
||||
|
||||
export class DbConnectionService extends Service {
|
||||
private testDataSource: DataSource | null = null
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'dbConnection')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('db:testConnection', async (_event, connectionString: string) => {
|
||||
const result = await this.testConnection(connectionString)
|
||||
return { success: true, data: result }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:switchConnection', async (_event, connectionString: string) => {
|
||||
try {
|
||||
const result = await this.switchConnection(connectionString)
|
||||
return { success: true, data: result }
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error?.message || '切换失败' }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:getStatus', async () => {
|
||||
const status = this.getStatus()
|
||||
return { success: true, data: status }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('db:sync', async () => {
|
||||
const db = this.mainCtx.db
|
||||
const result = await db.syncToRemote()
|
||||
return { success: result.success, data: result }
|
||||
})
|
||||
}
|
||||
|
||||
async testConnection(connectionString: string): Promise<{ success: boolean; error?: string }> {
|
||||
const config = parsePostgreSQLConnectionString(connectionString)
|
||||
if (!config) {
|
||||
return { success: false, error: '无效的 PostgreSQL 连接字符串格式' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.testDataSource) {
|
||||
await this.testDataSource.destroy()
|
||||
}
|
||||
|
||||
this.testDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||
extra: {
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : undefined,
|
||||
sslmode: config.sslmode,
|
||||
channelBinding: config.channelBinding
|
||||
},
|
||||
entities: [
|
||||
StudentEntity,
|
||||
ReasonEntity,
|
||||
ScoreEventEntity,
|
||||
SettlementEntity,
|
||||
SettingEntity,
|
||||
TagEntity,
|
||||
StudentTagEntity
|
||||
],
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
connectTimeoutMS: 10000
|
||||
})
|
||||
|
||||
await this.testDataSource.initialize()
|
||||
await this.testDataSource.destroy()
|
||||
this.testDataSource = null
|
||||
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
if (this.testDataSource) {
|
||||
await this.testDataSource.destroy().catch(() => null)
|
||||
this.testDataSource = null
|
||||
}
|
||||
console.error('PostgreSQL connection test failed:', error)
|
||||
return { success: false, error: error?.message || '连接失败' }
|
||||
}
|
||||
}
|
||||
|
||||
async switchConnection(connectionString: string): Promise<{ type: 'sqlite' | 'postgresql' }> {
|
||||
if (!connectionString) {
|
||||
// 切换到 SQLite
|
||||
const result = await this.mainCtx.db.switchConnection(undefined)
|
||||
await this.mainCtx.settings.setValue('pg_connection_string', '')
|
||||
await this.mainCtx.settings.setValue('pg_connection_status', {
|
||||
connected: true,
|
||||
type: 'sqlite'
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const testResult = await this.testConnection(connectionString)
|
||||
if (!testResult.success) {
|
||||
await this.mainCtx.settings.setValue('pg_connection_status', {
|
||||
connected: false,
|
||||
type: 'postgresql',
|
||||
error: testResult.error
|
||||
})
|
||||
throw new Error(testResult.error || '连接测试失败')
|
||||
}
|
||||
|
||||
// 切换到 PostgreSQL
|
||||
const result = await this.mainCtx.db.switchConnection(connectionString)
|
||||
await this.mainCtx.settings.setValue('pg_connection_string', connectionString)
|
||||
await this.mainCtx.settings.setValue('pg_connection_status', {
|
||||
connected: true,
|
||||
type: 'postgresql'
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
getStatus(): { type: 'sqlite' | 'postgresql'; connected: boolean; error?: string } {
|
||||
const status = this.mainCtx.settings.getValue('pg_connection_status')
|
||||
if (status) {
|
||||
return status
|
||||
}
|
||||
return { type: 'sqlite', connected: true }
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import * as winston from 'winston'
|
||||
import DailyRotateFile from 'winston-daily-rotate-file'
|
||||
|
||||
export type logLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
logger: LoggerService
|
||||
}
|
||||
}
|
||||
|
||||
export class LoggerService extends Service {
|
||||
private logDir: string
|
||||
private currentLevel: logLevel = 'info'
|
||||
private winstonLogger: winston.Logger
|
||||
|
||||
constructor(ctx: MainContext, logDir: string) {
|
||||
super(ctx, 'logger')
|
||||
this.logDir = logDir
|
||||
fs.mkdirSync(this.logDir, { recursive: true })
|
||||
this.winstonLogger = this.createLogger()
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('log:query', (_, lines) => ({ success: true, data: this.readLogs(lines) }))
|
||||
this.mainCtx.handle('log:clear', (event) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
this.clearLogs()
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('log:setLevel', async (event, level: logLevel) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
await this.mainCtx.settings.setValue('log_level', level as any)
|
||||
return { success: true }
|
||||
})
|
||||
this.mainCtx.handle('log:write', (_event, payload: any) => {
|
||||
const level = String(payload?.level || 'info')
|
||||
const message = String(payload?.message || '')
|
||||
const meta = payload?.meta
|
||||
if (level === 'debug' || level === 'info' || level === 'warn' || level === 'error') {
|
||||
this.log(level as logLevel, message, { source: 'renderer', meta })
|
||||
} else {
|
||||
this.info(message, { source: 'renderer', meta })
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
setLevel(level: logLevel) {
|
||||
this.currentLevel = level
|
||||
this.winstonLogger.level = level
|
||||
}
|
||||
|
||||
log(level: logLevel, message: string, meta?: any) {
|
||||
this.winstonLogger.log(level, message, meta ?? {})
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
this.log('info', message, args.length ? { args } : undefined)
|
||||
}
|
||||
warn(message: string, ...args: any[]) {
|
||||
this.log('warn', message, args.length ? { args } : undefined)
|
||||
}
|
||||
error(message: string, ...args: any[]) {
|
||||
this.log('error', message, args.length ? { args } : undefined)
|
||||
}
|
||||
debug(message: string, ...args: any[]) {
|
||||
this.log('debug', message, args.length ? { args } : undefined)
|
||||
}
|
||||
|
||||
private createLogger(): winston.Logger {
|
||||
const timestampFormat = winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' })
|
||||
const withErrors = winston.format.errors({ stack: true })
|
||||
|
||||
const safeJsonStringify = (value: unknown) => {
|
||||
const seen = new WeakSet<object>()
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (!v || typeof v !== 'object') return v
|
||||
if (seen.has(v)) return '[Circular]'
|
||||
seen.add(v)
|
||||
return v
|
||||
})
|
||||
}
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
timestampFormat,
|
||||
withErrors,
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf((info) => {
|
||||
const { timestamp, level, message, stack, ...rest } = info as any
|
||||
const metaText = rest && Object.keys(rest).length ? ` ${safeJsonStringify(rest)}` : ''
|
||||
const stackText = stack ? `\n${String(stack)}` : ''
|
||||
return `${timestamp} ${level} ${String(message)}${metaText}${stackText}`
|
||||
})
|
||||
)
|
||||
|
||||
const fileFormat = winston.format.combine(
|
||||
timestampFormat,
|
||||
withErrors,
|
||||
winston.format.printf((info) => safeJsonStringify(info))
|
||||
)
|
||||
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
filename: path.join(this.logDir, 'secscore-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxFiles: '30d',
|
||||
maxSize: '20m'
|
||||
})
|
||||
rotateTransport.format = fileFormat
|
||||
|
||||
return winston.createLogger({
|
||||
level: this.currentLevel,
|
||||
defaultMeta: { source: 'main', pid: process.pid },
|
||||
transports: [new winston.transports.Console({ format: consoleFormat }), rotateTransport]
|
||||
})
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
try {
|
||||
const files = this.getLogFiles()
|
||||
for (const f of files) {
|
||||
try {
|
||||
fs.unlinkSync(f)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.winstonLogger.log('error', 'Failed to clear logs', {
|
||||
meta: err instanceof Error ? { message: err.message, stack: err.stack } : { err }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
readLogs(lines: number = 100): string[] {
|
||||
try {
|
||||
const files = this.getLogFiles()
|
||||
const picked: string[] = []
|
||||
for (let i = files.length - 1; i >= 0; i--) {
|
||||
const filePath = files[i]
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const fileLines = content.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
for (let j = fileLines.length - 1; j >= 0 && picked.length < lines; j--) {
|
||||
picked.push(fileLines[j])
|
||||
}
|
||||
if (picked.length >= lines) break
|
||||
}
|
||||
return picked.reverse()
|
||||
} catch (err) {
|
||||
this.winstonLogger.log('error', 'Failed to read logs', {
|
||||
meta: err instanceof Error ? { message: err.message, stack: err.stack } : { err }
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getLogFiles(): string[] {
|
||||
if (!fs.existsSync(this.logDir)) return []
|
||||
const entries = fs.readdirSync(this.logDir)
|
||||
const files = entries.filter((f) => f.endsWith('.log')).map((f) => path.join(this.logDir, f))
|
||||
|
||||
files.sort((a, b) => {
|
||||
try {
|
||||
const sa = fs.statSync(a)
|
||||
const sb = fs.statSync(b)
|
||||
return sa.mtimeMs - sb.mtimeMs
|
||||
} catch {
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
})
|
||||
return files
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Context, Service } from '../../shared/kernel'
|
||||
|
||||
export type permissionLevel = 'admin' | 'points' | 'view'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
permissions: PermissionService
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionService extends Service {
|
||||
public permissionRank: Record<permissionLevel, number> = { view: 0, points: 1, admin: 2 }
|
||||
private permissionsBySenderId = new Map<number, permissionLevel>()
|
||||
private SETTINGS_SECURITY_ADMIN = 'security_admin_password'
|
||||
private SETTINGS_SECURITY_POINTS = 'security_points_password'
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'permissions')
|
||||
}
|
||||
|
||||
shouldProtect() {
|
||||
return (
|
||||
this.ctx.security.hasSecret(this.SETTINGS_SECURITY_ADMIN) ||
|
||||
this.ctx.security.hasSecret(this.SETTINGS_SECURITY_POINTS)
|
||||
)
|
||||
}
|
||||
|
||||
getDefaultPermission(): permissionLevel {
|
||||
return this.shouldProtect() ? 'view' : 'admin'
|
||||
}
|
||||
|
||||
getPermission(senderId: number): permissionLevel {
|
||||
const existing = this.permissionsBySenderId.get(senderId)
|
||||
if (existing) return existing
|
||||
const def = this.getDefaultPermission()
|
||||
this.permissionsBySenderId.set(senderId, def)
|
||||
return def
|
||||
}
|
||||
|
||||
setPermission(senderId: number, level: permissionLevel) {
|
||||
this.permissionsBySenderId.set(senderId, level)
|
||||
}
|
||||
|
||||
requirePermission(event: any, required: permissionLevel): boolean {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId !== 'number') return false
|
||||
const current = this.getPermission(senderId)
|
||||
return this.permissionRank[current] >= this.permissionRank[required]
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { MainContext } from '../context'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
security: SecurityService
|
||||
}
|
||||
}
|
||||
|
||||
export class SecurityService extends Service {
|
||||
private ivKey = 'security_crypto_iv'
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'security')
|
||||
}
|
||||
|
||||
async ensureSecurityIv() {
|
||||
let ivHex = this.ctx.settings.getRaw(this.ivKey)
|
||||
if (!ivHex) {
|
||||
ivHex = crypto.randomBytes(16).toString('hex')
|
||||
await this.ctx.settings.setRaw(this.ivKey, ivHex)
|
||||
}
|
||||
return ivHex
|
||||
}
|
||||
|
||||
getCryptoKey() {
|
||||
return crypto.scryptSync(app.getPath('userData'), 'secscore-salt', 32)
|
||||
}
|
||||
|
||||
async encryptSecret(plainText: string) {
|
||||
const ivHex = await this.ensureSecurityIv()
|
||||
const key = this.getCryptoKey()
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'))
|
||||
let encrypted = cipher.update(plainText, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async decryptSecret(cipherText: string) {
|
||||
try {
|
||||
if (!cipherText) return ''
|
||||
const ivHex = await this.ensureSecurityIv()
|
||||
const key = this.getCryptoKey()
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, Buffer.from(ivHex, 'hex'))
|
||||
let plain = decipher.update(cipherText, 'hex', 'utf8')
|
||||
plain += decipher.final('utf8')
|
||||
return plain
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
isSixDigit(s: string) {
|
||||
return /^\d{6}$/.test(s)
|
||||
}
|
||||
|
||||
hasSecret(key: string) {
|
||||
const v = this.ctx.settings.getRaw(key)
|
||||
return typeof v === 'string' && v.trim().length > 0
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow, webContents } from 'electron'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { settingsKey, settingsSpec, settingChange } from '../../preload/types'
|
||||
import type { permissionLevel } from './PermissionService'
|
||||
import { SettingEntity } from '../db/entities'
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
settings: SettingsService
|
||||
}
|
||||
}
|
||||
|
||||
type settingValueKind = 'string' | 'boolean' | 'number' | 'json'
|
||||
|
||||
type settingDefinition = {
|
||||
kind: settingValueKind
|
||||
defaultValue: unknown
|
||||
readPermission?: permissionLevel | 'any'
|
||||
writePermission?: permissionLevel | 'any'
|
||||
validate?: (value: unknown) => boolean
|
||||
normalize?: (value: unknown) => unknown
|
||||
onChanged?: (ctx: MainContext, next: unknown, prev: unknown) => void
|
||||
}
|
||||
|
||||
export class SettingsService extends Service {
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'settings')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
private definitions: Record<settingsKey, settingDefinition> = {
|
||||
is_wizard_completed: {
|
||||
kind: 'boolean',
|
||||
defaultValue: false,
|
||||
writePermission: 'any'
|
||||
},
|
||||
log_level: {
|
||||
kind: 'string',
|
||||
defaultValue: 'info',
|
||||
writePermission: 'admin',
|
||||
validate: (v) => v === 'debug' || v === 'info' || v === 'warn' || v === 'error',
|
||||
onChanged: (ctx, next) => {
|
||||
ctx.logger.setLevel(next as any)
|
||||
}
|
||||
},
|
||||
window_zoom: {
|
||||
kind: 'number',
|
||||
defaultValue: 1.0,
|
||||
writePermission: 'admin',
|
||||
onChanged: (_ctx, next) => {
|
||||
const zoom = Number(next) || 1.0
|
||||
webContents.getAllWebContents().forEach((wc: any) => {
|
||||
wc.setZoomFactor(zoom)
|
||||
})
|
||||
}
|
||||
},
|
||||
themes_custom: {
|
||||
kind: 'json',
|
||||
defaultValue: [],
|
||||
writePermission: 'admin'
|
||||
},
|
||||
auto_score_enabled: {
|
||||
kind: 'boolean',
|
||||
defaultValue: false,
|
||||
writePermission: 'admin'
|
||||
},
|
||||
auto_score_rules: {
|
||||
kind: 'json',
|
||||
defaultValue: [],
|
||||
writePermission: 'admin'
|
||||
},
|
||||
current_theme_id: {
|
||||
kind: 'string',
|
||||
defaultValue: 'light-default',
|
||||
writePermission: 'admin'
|
||||
},
|
||||
pg_connection_string: {
|
||||
kind: 'string',
|
||||
defaultValue: '',
|
||||
writePermission: 'admin'
|
||||
},
|
||||
pg_connection_status: {
|
||||
kind: 'json',
|
||||
defaultValue: { connected: false, type: 'sqlite' },
|
||||
writePermission: 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
private cache = new Map<string, string>()
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise
|
||||
this.initPromise = (async () => {
|
||||
await this.loadCache()
|
||||
await this.ensureDefaults()
|
||||
})()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
private async loadCache() {
|
||||
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
|
||||
const rows = await repo.find()
|
||||
this.cache.clear()
|
||||
for (const r of rows) this.cache.set(r.key, String(r.value ?? ''))
|
||||
}
|
||||
|
||||
private async ensureDefaults() {
|
||||
const repo = this.ctx.db.dataSource.getRepository(SettingEntity)
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
if (this.cache.has(key)) continue
|
||||
const def = this.definitions[key]
|
||||
const raw = this.serializeValue(key, def.defaultValue)
|
||||
await repo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(SettingEntity)
|
||||
.values({ key, value: raw })
|
||||
.orIgnore()
|
||||
.execute()
|
||||
this.cache.set(key, raw)
|
||||
}
|
||||
}
|
||||
|
||||
private serializeValue(key: settingsKey, value: unknown): string {
|
||||
const def = this.definitions[key]
|
||||
switch (def.kind) {
|
||||
case 'boolean':
|
||||
return value ? '1' : '0'
|
||||
case 'number':
|
||||
return String(value)
|
||||
case 'json':
|
||||
return JSON.stringify(value)
|
||||
case 'string':
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
private deserializeValue(key: settingsKey, raw: string): unknown {
|
||||
const def = this.definitions[key]
|
||||
switch (def.kind) {
|
||||
case 'boolean':
|
||||
return raw === '1' || raw.toLowerCase() === 'true'
|
||||
case 'number':
|
||||
return Number(raw)
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return def.defaultValue
|
||||
}
|
||||
case 'string':
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
private parseValue(key: settingsKey, raw: string | undefined): unknown {
|
||||
const def = this.definitions[key]
|
||||
if (raw == null) return def.defaultValue
|
||||
const deserialized = this.deserializeValue(key, raw)
|
||||
const normalized = def.normalize ? def.normalize(deserialized) : deserialized
|
||||
if (def.validate && !def.validate(normalized)) return def.defaultValue
|
||||
return normalized
|
||||
}
|
||||
|
||||
private canWrite(event: IpcMainInvokeEvent, key: settingsKey): boolean {
|
||||
const required = this.definitions[key].writePermission
|
||||
if (!required || required === 'any') return true
|
||||
return this.mainCtx.permissions.requirePermission(event, required)
|
||||
}
|
||||
|
||||
private notifyChanged(key: settingsKey, value: unknown) {
|
||||
const change: settingChange = { key, value } as any
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
for (const win of windows) {
|
||||
win.webContents.send('settings:changed', change)
|
||||
}
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('settings:getAll', async () => {
|
||||
await this.initialize()
|
||||
return { success: true, data: this.getAll() }
|
||||
})
|
||||
this.mainCtx.handle('settings:get', async (_event, key: settingsKey) => {
|
||||
await this.initialize()
|
||||
return { success: true, data: this.getValue(key) }
|
||||
})
|
||||
this.mainCtx.handle('settings:set', async (event, key: settingsKey, value: any) => {
|
||||
if (!this.canWrite(event, key)) return { success: false, message: 'Permission denied' }
|
||||
await this.setValue(key, value)
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
async reloadFromDb(options: { notify?: boolean } = {}): Promise<void> {
|
||||
await this.initialize()
|
||||
const notify = options.notify ?? true
|
||||
const prev = this.getAll()
|
||||
await this.loadCache()
|
||||
await this.ensureDefaults()
|
||||
if (!notify) return
|
||||
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
const def = this.definitions[key]
|
||||
const next = this.getValue(key as any) as any
|
||||
const prevValue = prev[key]
|
||||
if (next !== prevValue) {
|
||||
def.onChanged?.(this.mainCtx, next, prevValue)
|
||||
this.notifyChanged(key, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRaw(key: string): string {
|
||||
return this.cache.get(key) ?? ''
|
||||
}
|
||||
|
||||
async setRaw(key: string, value: string): Promise<void> {
|
||||
await this.initialize()
|
||||
const prev = this.cache.get(key) ?? ''
|
||||
if (key in this.definitions) {
|
||||
const typedKey = key as settingsKey
|
||||
const def = this.definitions[typedKey]
|
||||
const nextDeserialized = this.deserializeValue(typedKey, value)
|
||||
const nextNormalized = def.normalize ? def.normalize(nextDeserialized) : nextDeserialized
|
||||
const nextRaw =
|
||||
def.validate && !def.validate(nextNormalized)
|
||||
? this.serializeValue(typedKey, def.defaultValue)
|
||||
: this.serializeValue(typedKey, nextNormalized)
|
||||
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: nextRaw })
|
||||
this.cache.set(key, nextRaw)
|
||||
|
||||
const nextTyped = this.parseValue(typedKey, nextRaw)
|
||||
const prevTyped = this.parseValue(typedKey, prev)
|
||||
if (nextTyped !== prevTyped) {
|
||||
def.onChanged?.(this.mainCtx, nextTyped, prevTyped)
|
||||
this.notifyChanged(typedKey, nextTyped)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value })
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
getValue<K extends settingsKey>(key: K): settingsSpec[K] {
|
||||
return this.parseValue(key, this.cache.get(key)) as settingsSpec[K]
|
||||
}
|
||||
|
||||
async setValue<K extends settingsKey>(key: K, value: settingsSpec[K]): Promise<void> {
|
||||
await this.initialize()
|
||||
const def = this.definitions[key]
|
||||
const prev = this.getValue(key)
|
||||
const normalized = def.normalize ? def.normalize(value) : value
|
||||
if (def.validate && !def.validate(normalized)) {
|
||||
throw new Error(`Invalid value for setting: ${String(key)}`)
|
||||
}
|
||||
const raw = this.serializeValue(key, normalized)
|
||||
await this.ctx.db.dataSource.getRepository(SettingEntity).save({ key, value: raw })
|
||||
this.cache.set(key, raw)
|
||||
const next = this.getValue(key)
|
||||
if (next !== prev) {
|
||||
def.onChanged?.(this.mainCtx, next, prev)
|
||||
this.notifyChanged(key, next)
|
||||
}
|
||||
}
|
||||
|
||||
getAllRaw(): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of this.cache.entries()) out[k] = v
|
||||
return out
|
||||
}
|
||||
|
||||
getAll(): settingsSpec {
|
||||
const out: any = {}
|
||||
for (const key of Object.keys(this.definitions) as settingsKey[]) {
|
||||
out[key] = this.getValue(key)
|
||||
}
|
||||
return out as settingsSpec
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
config: {
|
||||
tdesign: Record<string, string>
|
||||
custom: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
themes: ThemeService
|
||||
}
|
||||
}
|
||||
|
||||
export class ThemeService extends Service {
|
||||
private currentThemeId: string = 'light-default'
|
||||
private customThemes: themeConfig[] = []
|
||||
private readonly builtinThemes: themeConfig[] = [
|
||||
{
|
||||
name: '极简浅色',
|
||||
id: 'light-default',
|
||||
mode: 'light',
|
||||
config: {
|
||||
tdesign: {
|
||||
brandColor: '#0052D9',
|
||||
warningColor: '#ED7B2F',
|
||||
errorColor: '#D54941',
|
||||
successColor: '#2BA471'
|
||||
},
|
||||
custom: {
|
||||
'--ss-bg-color': 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
||||
'--ss-card-bg': '#ffffff',
|
||||
'--ss-text-main': '#181818',
|
||||
'--ss-text-secondary': '#666666',
|
||||
'--ss-border-color': '#dcdcdc',
|
||||
'--ss-header-bg': 'linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.7) 100%)',
|
||||
'--ss-sidebar-bg': 'rgba(255, 255, 255, 0.88)',
|
||||
'--ss-item-hover': '#f3f3f3',
|
||||
'--ss-sidebar-text': '#181818',
|
||||
'--ss-sidebar-active-bg': 'rgba(0, 0, 0, 0.06)',
|
||||
'--ss-sidebar-active-text': '#181818'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '极客深蓝',
|
||||
id: 'dark-default',
|
||||
mode: 'dark',
|
||||
config: {
|
||||
tdesign: {
|
||||
brandColor: '#0052D9',
|
||||
warningColor: '#E37318',
|
||||
errorColor: '#D32029',
|
||||
successColor: '#248232'
|
||||
},
|
||||
custom: {
|
||||
'--ss-bg-color': 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)',
|
||||
'--ss-card-bg': '#1e1e1e',
|
||||
'--ss-text-main': '#ffffff',
|
||||
'--ss-text-secondary': '#a0a0a0',
|
||||
'--ss-border-color': '#333333',
|
||||
'--ss-header-bg': 'rgba(30, 30, 30, 0.92)',
|
||||
'--ss-sidebar-bg': 'rgba(30, 30, 30, 0.92)',
|
||||
'--ss-item-hover': '#2c2c2c',
|
||||
'--ss-sidebar-text': '#ffffff',
|
||||
'--ss-sidebar-active-bg': 'rgba(255, 255, 255, 0.10)',
|
||||
'--ss-sidebar-active-text': '#ffffff'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '冷静青蓝',
|
||||
id: 'dark-cyan',
|
||||
mode: 'dark',
|
||||
config: {
|
||||
tdesign: {
|
||||
brandColor: '#16A085',
|
||||
warningColor: '#F39C12',
|
||||
errorColor: '#E74C3C',
|
||||
successColor: '#1ABC9C'
|
||||
},
|
||||
custom: {
|
||||
'--ss-bg-color': 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)',
|
||||
'--ss-card-bg': '#0f1a23',
|
||||
'--ss-text-main': '#E5F7FF',
|
||||
'--ss-text-secondary': '#7FA4B8',
|
||||
'--ss-border-color': '#1F3645',
|
||||
'--ss-header-bg': 'rgba(15, 26, 35, 0.92)',
|
||||
'--ss-sidebar-bg': 'rgba(15, 26, 35, 0.92)',
|
||||
'--ss-item-hover': '#182635',
|
||||
'--ss-sidebar-text': '#E5F7FF',
|
||||
'--ss-sidebar-active-bg': '#182635',
|
||||
'--ss-sidebar-active-text': '#E5F7FF'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '清新马卡龙',
|
||||
id: 'light-pastel',
|
||||
mode: 'light',
|
||||
config: {
|
||||
tdesign: {
|
||||
brandColor: '#FF9AA2',
|
||||
warningColor: '#FFB347',
|
||||
errorColor: '#FF6F69',
|
||||
successColor: '#B5EAD7'
|
||||
},
|
||||
custom: {
|
||||
'--ss-bg-color': 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
||||
'--ss-card-bg': '#ffffff',
|
||||
'--ss-text-main': '#3A3A3A',
|
||||
'--ss-text-secondary': '#8A8A8A',
|
||||
'--ss-border-color': '#F1D3D3',
|
||||
'--ss-header-bg': 'rgba(255, 255, 255, 0.88)',
|
||||
'--ss-sidebar-bg': 'rgba(255, 255, 255, 0.90)',
|
||||
'--ss-item-hover': '#FFE7E0',
|
||||
'--ss-sidebar-text': '#3A3A3A',
|
||||
'--ss-sidebar-active-bg': '#FFE7E0',
|
||||
'--ss-sidebar-active-text': '#3A3A3A'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
constructor(ctx: MainContext) {
|
||||
super(ctx, 'themes')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await this.mainCtx.settings.initialize()
|
||||
await this.loadSavedTheme()
|
||||
await this.loadCustomThemes()
|
||||
}
|
||||
|
||||
private async loadSavedTheme() {
|
||||
try {
|
||||
const savedThemeId = this.mainCtx.settings.getValue('current_theme_id')
|
||||
if (savedThemeId && typeof savedThemeId === 'string') {
|
||||
const themes = this.getThemeList()
|
||||
const exists = themes.some((t) => t.id === savedThemeId)
|
||||
if (exists) {
|
||||
this.currentThemeId = savedThemeId
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.mainCtx.logger.warn('Failed to load saved theme', { meta: e })
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCurrentTheme() {
|
||||
try {
|
||||
await this.mainCtx.settings.setValue('current_theme_id', this.currentThemeId)
|
||||
} catch (e) {
|
||||
this.mainCtx.logger.warn('Failed to save theme', { meta: e })
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCustomThemes() {
|
||||
try {
|
||||
const v = this.mainCtx.settings.getValue('themes_custom')
|
||||
if (Array.isArray(v)) {
|
||||
this.customThemes = v.filter((t) => t && typeof t === 'object') as any
|
||||
} else {
|
||||
this.customThemes = []
|
||||
}
|
||||
} catch {
|
||||
this.customThemes = []
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCustomThemes() {
|
||||
await this.mainCtx.settings.setValue('themes_custom', this.customThemes)
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('theme:list', async () => {
|
||||
return { success: true, data: this.getThemeList() }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:current', async () => {
|
||||
const theme = this.getThemeById(this.currentThemeId)
|
||||
return { success: true, data: theme }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:set', async (event, themeId: string) => {
|
||||
const senderId = event?.sender?.id
|
||||
if (typeof senderId === 'number') {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
}
|
||||
this.currentThemeId = themeId
|
||||
await this.saveCurrentTheme()
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:save', async (event, theme: themeConfig) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
try {
|
||||
if (!theme?.id || !theme?.name) {
|
||||
return { success: false, message: 'Invalid theme' }
|
||||
}
|
||||
const isBuiltin = this.builtinThemes.some((t) => t.id === theme.id)
|
||||
if (isBuiltin) {
|
||||
return { success: false, message: 'Cannot overwrite builtin themes' }
|
||||
}
|
||||
|
||||
const normalized: themeConfig = {
|
||||
name: String(theme.name),
|
||||
id: String(theme.id),
|
||||
mode: theme.mode === 'dark' ? 'dark' : 'light',
|
||||
config: {
|
||||
tdesign: { ...(theme.config?.tdesign || {}) },
|
||||
custom: { ...(theme.config?.custom || {}) }
|
||||
}
|
||||
}
|
||||
|
||||
const idx = this.customThemes.findIndex((t) => t.id === normalized.id)
|
||||
if (idx >= 0) this.customThemes[idx] = normalized
|
||||
else this.customThemes.unshift(normalized)
|
||||
|
||||
await this.saveCustomThemes()
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, message: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('theme:delete', async (event, themeId: string) => {
|
||||
if (!this.mainCtx.permissions.requirePermission(event, 'admin'))
|
||||
return { success: false, message: 'Permission denied' }
|
||||
|
||||
try {
|
||||
const id = String(themeId || '').trim()
|
||||
if (!id) return { success: false, message: 'Invalid theme id' }
|
||||
if (this.builtinThemes.some((t) => t.id === id)) {
|
||||
return { success: false, message: 'Cannot delete builtin themes' }
|
||||
}
|
||||
|
||||
const before = this.customThemes.length
|
||||
this.customThemes = this.customThemes.filter((t) => t.id !== id)
|
||||
if (this.customThemes.length === before) {
|
||||
return { success: false, message: 'Theme not found' }
|
||||
}
|
||||
|
||||
await this.saveCustomThemes()
|
||||
if (this.currentThemeId === themeId) {
|
||||
this.currentThemeId = 'light-default'
|
||||
await this.saveCurrentTheme()
|
||||
}
|
||||
this.notifyThemeUpdate()
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, message: String(e) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getThemeList(): themeConfig[] {
|
||||
return [...this.builtinThemes, ...this.customThemes]
|
||||
}
|
||||
|
||||
private getThemeById(id: string): themeConfig | null {
|
||||
const list = this.getThemeList()
|
||||
return list.find((t) => t.id === id) || list[0] || null
|
||||
}
|
||||
|
||||
private notifyThemeUpdate() {
|
||||
const theme = this.getThemeById(this.currentThemeId)
|
||||
if (!theme) return
|
||||
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
for (const win of windows) {
|
||||
win.webContents.send('theme:updated', theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { Tray, Menu, app, nativeImage } from 'electron'
|
||||
import type { windowManagerOptions } from './WindowManager'
|
||||
|
||||
export class TrayService extends Service {
|
||||
private tray: Tray | null = null
|
||||
|
||||
constructor(
|
||||
ctx: MainContext,
|
||||
private readonly opts: windowManagerOptions
|
||||
) {
|
||||
super(ctx, 'tray')
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
const icon = nativeImage.createFromPath(this.opts.icon)
|
||||
this.tray = new Tray(icon)
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示主窗口',
|
||||
click: () => {
|
||||
this.showMainWindow()
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '重启应用',
|
||||
click: () => {
|
||||
this.restartApp()
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '关闭应用',
|
||||
click: () => {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
this.tray.setToolTip('SecScore 积分管理')
|
||||
this.tray.setContextMenu(contextMenu)
|
||||
|
||||
this.tray.on('double-click', () => {
|
||||
this.showMainWindow()
|
||||
})
|
||||
}
|
||||
|
||||
private showMainWindow() {
|
||||
const mainWin = this.mainCtx.windows.get('main')
|
||||
if (mainWin) {
|
||||
if (mainWin.isMinimized()) mainWin.restore()
|
||||
mainWin.show()
|
||||
mainWin.focus()
|
||||
} else {
|
||||
this.mainCtx.windows.open({ key: 'main', title: 'SecScore', route: '/' })
|
||||
}
|
||||
}
|
||||
|
||||
private restartApp() {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { Service } from '../../shared/kernel'
|
||||
import { MainContext } from '../context'
|
||||
import { BrowserWindow, shell } from 'electron'
|
||||
import type { BrowserWindowConstructorOptions } from 'electron'
|
||||
|
||||
export type windowOpenInput = {
|
||||
key: string
|
||||
title?: string
|
||||
route?: string
|
||||
options?: BrowserWindowConstructorOptions
|
||||
}
|
||||
|
||||
export type windowManagerOptions = {
|
||||
icon: any
|
||||
preloadPath: string
|
||||
rendererHtmlPath: string
|
||||
getRendererUrl: () => string | undefined
|
||||
}
|
||||
|
||||
declare module '../../shared/kernel' {
|
||||
interface Context {
|
||||
windows: WindowManager
|
||||
}
|
||||
}
|
||||
|
||||
export class WindowManager extends Service {
|
||||
private readonly windows = new Map<string, BrowserWindow>()
|
||||
|
||||
constructor(
|
||||
ctx: MainContext,
|
||||
private readonly opts: windowManagerOptions
|
||||
) {
|
||||
super(ctx, 'windows')
|
||||
this.registerIpc()
|
||||
}
|
||||
|
||||
private get mainCtx() {
|
||||
return this.ctx as MainContext
|
||||
}
|
||||
|
||||
public get(key: string) {
|
||||
const existing = this.windows.get(key)
|
||||
if (!existing) return null
|
||||
if (existing.isDestroyed()) {
|
||||
this.windows.delete(key)
|
||||
return null
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
public open(input: windowOpenInput) {
|
||||
const existing = this.get(input.key)
|
||||
if (existing) {
|
||||
if (input.route) {
|
||||
existing.webContents.send('app:navigate', input.route)
|
||||
}
|
||||
existing.show()
|
||||
existing.focus()
|
||||
return existing
|
||||
}
|
||||
|
||||
const baseOptions: BrowserWindowConstructorOptions = {
|
||||
width: 1180,
|
||||
height: 680,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
frame: true,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
icon: this.opts.icon,
|
||||
title: input.title,
|
||||
webPreferences: {
|
||||
preload: this.opts.preloadPath,
|
||||
sandbox: false
|
||||
},
|
||||
...input.options
|
||||
}
|
||||
|
||||
const win = new BrowserWindow(baseOptions)
|
||||
|
||||
const zoomSettings = this.mainCtx.settings
|
||||
const zoom = zoomSettings ? Number(zoomSettings.getValue('window_zoom')) || 1.0 : 1.0
|
||||
win.webContents.setZoomFactor(zoom)
|
||||
|
||||
this.windows.set(input.key, win)
|
||||
|
||||
win.on('close', (event) => {
|
||||
if (!this.mainCtx.isQuitting && input.key === 'main') {
|
||||
event.preventDefault()
|
||||
win.hide()
|
||||
}
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
this.windows.delete(input.key)
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
// Notify renderer about maximize state changes
|
||||
win.on('maximize', () => {
|
||||
win.webContents.send('window:maximized-changed', true)
|
||||
})
|
||||
win.on('unmaximize', () => {
|
||||
win.webContents.send('window:maximized-changed', false)
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
void this.loadRoute(win, input.route ?? '/')
|
||||
return win
|
||||
}
|
||||
|
||||
public navigate(key: string, route: string) {
|
||||
const win = this.get(key)
|
||||
if (!win) return false
|
||||
win.webContents.send('app:navigate', route)
|
||||
return true
|
||||
}
|
||||
|
||||
public navigateWindow(win: BrowserWindow, route: string) {
|
||||
if (win.isDestroyed()) return false
|
||||
win.webContents.send('app:navigate', route)
|
||||
return true
|
||||
}
|
||||
|
||||
private async loadRoute(win: BrowserWindow, route: string) {
|
||||
const normalizedRoute = route.startsWith('/') ? route : `/${route}`
|
||||
const rendererUrl = this.opts.getRendererUrl()
|
||||
|
||||
if (rendererUrl) {
|
||||
await win.loadURL(`${rendererUrl}#${normalizedRoute}`)
|
||||
return
|
||||
}
|
||||
|
||||
await win.loadFile(this.opts.rendererHtmlPath, { hash: normalizedRoute })
|
||||
}
|
||||
|
||||
private registerIpc() {
|
||||
this.mainCtx.handle('window:open', async (_event, input: any) => {
|
||||
const key = String(input?.key ?? '').trim()
|
||||
if (!key) return { success: false, message: 'Missing key' }
|
||||
this.open({
|
||||
key,
|
||||
title: input?.title ? String(input.title) : undefined,
|
||||
route: input?.route ? String(input.route) : undefined,
|
||||
options: input?.options
|
||||
})
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:navigate', async (event, input: any) => {
|
||||
const route = String(input?.route ?? '').trim()
|
||||
if (!route) return { success: false, message: 'Missing route' }
|
||||
|
||||
const key = input?.key ? String(input.key).trim() : ''
|
||||
if (key) {
|
||||
const ok = this.navigate(key, route)
|
||||
return ok ? { success: true } : { success: false, message: 'Window not found' }
|
||||
}
|
||||
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return { success: false, message: 'Window not found' }
|
||||
const ok = this.navigateWindow(win, route)
|
||||
return ok ? { success: true } : { success: false, message: 'Window not found' }
|
||||
})
|
||||
|
||||
// Window controls
|
||||
this.mainCtx.handle('window:minimize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) win.minimize()
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:maximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) {
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize()
|
||||
return false
|
||||
} else {
|
||||
win.maximize()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:close', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) win.close()
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:isMaximized', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win ? win.isMaximized() : false
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:set-zoom', (event, zoom: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win && zoom >= 0.5 && zoom <= 2.0) {
|
||||
win.webContents.setZoomFactor(zoom)
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:toggle-devtools', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) {
|
||||
if (win.webContents.isDevToolsOpened()) {
|
||||
win.webContents.closeDevTools()
|
||||
} else {
|
||||
win.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.mainCtx.handle('window:resize', (event, width: number, height: number) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win) {
|
||||
const bounds = win.getBounds()
|
||||
const newX = bounds.x + (bounds.width - width)
|
||||
win.setBounds({
|
||||
x: newX,
|
||||
y: bounds.y,
|
||||
width,
|
||||
height
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { ConfigProvider, theme as antTheme, message } from 'antd'
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect, createContext, useContext } from 'react'
|
||||
import { MobileHome } from './pages/Home'
|
||||
import { MobileScore } from './pages/Score'
|
||||
import { MobileLeaderboard } from './pages/Leaderboard'
|
||||
import { MobileSettings } from './pages/Settings'
|
||||
import { MobileLayout } from './components/Layout'
|
||||
|
||||
interface ThemeContextType {
|
||||
isDark: boolean
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
isDark: false,
|
||||
toggleTheme: () => {}
|
||||
})
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext)
|
||||
|
||||
interface ApiConfig {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
interface ApiContextType {
|
||||
api: {
|
||||
get: (path: string) => Promise<any>
|
||||
post: (path: string, data?: any) => Promise<any>
|
||||
put: (path: string, data?: any) => Promise<any>
|
||||
del: (path: string) => Promise<any>
|
||||
}
|
||||
config: ApiConfig
|
||||
setConfig: (config: ApiConfig) => void
|
||||
}
|
||||
|
||||
const ApiContext = createContext<ApiContextType | null>(null)
|
||||
|
||||
export const useApi = () => {
|
||||
const ctx = useContext(ApiContext)
|
||||
if (!ctx) throw new Error('useApi must be used within ApiProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const [apiConfig, setApiConfig] = useState<ApiConfig>({ baseUrl: '' })
|
||||
const [, messageHolder] = message.useMessage()
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme === 'dark') setIsDark(true)
|
||||
|
||||
const savedApiUrl = localStorage.getItem('apiUrl')
|
||||
if (savedApiUrl) setApiConfig({ baseUrl: savedApiUrl })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('theme-mode', isDark ? 'dark' : 'light')
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
||||
}, [isDark])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfig.baseUrl) {
|
||||
localStorage.setItem('apiUrl', apiConfig.baseUrl)
|
||||
}
|
||||
}, [apiConfig])
|
||||
|
||||
const toggleTheme = () => setIsDark((prev) => !prev)
|
||||
|
||||
const api = {
|
||||
get: async (path: string) => {
|
||||
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
|
||||
const res = await fetch(`${apiConfig.baseUrl}${path}`)
|
||||
return res.json()
|
||||
},
|
||||
post: async (path: string, data?: any) => {
|
||||
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
|
||||
const res = await fetch(`${apiConfig.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
return res.json()
|
||||
},
|
||||
put: async (path: string, data?: any) => {
|
||||
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
|
||||
const res = await fetch(`${apiConfig.baseUrl}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
return res.json()
|
||||
},
|
||||
del: async (path: string) => {
|
||||
if (!apiConfig.baseUrl) throw new Error('API URL not configured')
|
||||
const res = await fetch(`${apiConfig.baseUrl}${path}`, { method: 'DELETE' })
|
||||
return res.json()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#1890ff'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{messageHolder}
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
|
||||
<ApiContext.Provider value={{ api, config: apiConfig, setConfig: setApiConfig }}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route element={<MobileLayout />}>
|
||||
<Route path="/" element={<MobileHome />} />
|
||||
<Route path="/score" element={<MobileScore />} />
|
||||
<Route path="/leaderboard" element={<MobileLeaderboard />} />
|
||||
<Route path="/settings" element={<MobileSettings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ApiContext.Provider>
|
||||
</ThemeContext.Provider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { TabBar } from 'antd-mobile'
|
||||
import {
|
||||
HomeOutlined,
|
||||
PlusCircleOutlined,
|
||||
TrophyOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useTheme } from '../App'
|
||||
|
||||
const tabs = [
|
||||
{ key: '/', title: '主页', icon: <HomeOutlined /> },
|
||||
{ key: '/score', title: '积分', icon: <PlusCircleOutlined /> },
|
||||
{ key: '/leaderboard', title: '排行', icon: <TrophyOutlined /> },
|
||||
{ key: '/settings', title: '设置', icon: <SettingOutlined /> }
|
||||
]
|
||||
|
||||
export function MobileLayout(): React.JSX.Element {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#f5f5f5'
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingBottom: '50px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: isDark ? '#2a2a2a' : '#fff',
|
||||
borderTop: `1px solid ${isDark ? '#333' : '#eee'}`
|
||||
}}
|
||||
>
|
||||
<TabBar
|
||||
activeKey={location.pathname}
|
||||
onChange={(key) => navigate(key)}
|
||||
items={tabs.map((tab) => ({
|
||||
key: tab.key,
|
||||
title: tab.title,
|
||||
icon: tab.icon
|
||||
}))}
|
||||
style={{
|
||||
'--color': isDark ? '#999' : '#666',
|
||||
'--active-color': '#1890ff',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#1890ff" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>SecScore - 教育积分管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +0,0 @@
|
||||
import '../renderer/src/assets/main.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Tag, SearchBar, Space, SpinLoading } from 'antd-mobile'
|
||||
import { useApi } from '../App'
|
||||
|
||||
interface Student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export function MobileHome(): React.JSX.Element {
|
||||
const { api, config } = useApi()
|
||||
const [students, setStudents] = useState<Student[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.baseUrl) return
|
||||
loadStudents()
|
||||
}, [config.baseUrl])
|
||||
|
||||
const loadStudents = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get('/api/students')
|
||||
if (res.success) {
|
||||
setStudents(res.data || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load students:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredStudents = students.filter((s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const getAvatarColor = (name: string) => {
|
||||
const colors = [
|
||||
'#FF6B6B',
|
||||
'#4ECDC4',
|
||||
'#45B7D1',
|
||||
'#FFA07A',
|
||||
'#98D8C8',
|
||||
'#F7DC6F',
|
||||
'#BB8FCE',
|
||||
'#85C1E2'
|
||||
]
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
if (!config.baseUrl) {
|
||||
return (
|
||||
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<p>请先在设置中配置服务器地址</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px' }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}>学生积分</h2>
|
||||
|
||||
<SearchBar
|
||||
placeholder="搜索学生"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<SpinLoading />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{filteredStudents.map((student) => (
|
||||
<Card key={student.id} style={{ borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getAvatarColor(student.name),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{student.name.slice(-2)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{student.name}</div>
|
||||
<Tag
|
||||
color={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
|
||||
>
|
||||
{student.score > 0 ? `+${student.score}` : student.score}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{!loading && filteredStudents.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
{search ? '未找到匹配的学生' : '暂无学生数据'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, SpinLoading, Selector } from 'antd-mobile'
|
||||
import { useApi } from '../App'
|
||||
|
||||
interface RankRow {
|
||||
name: string
|
||||
score: number
|
||||
range_change: number
|
||||
}
|
||||
|
||||
export function MobileLeaderboard(): React.JSX.Element {
|
||||
const { api, config } = useApi()
|
||||
const [data, setData] = useState<RankRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [range, setRange] = useState<'today' | 'week' | 'month'>('today')
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.baseUrl) return
|
||||
loadData()
|
||||
}, [config.baseUrl, range])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get(`/api/leaderboard?range=${range}`)
|
||||
if (res.success) {
|
||||
setData(res.data?.rows || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load leaderboard:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.baseUrl) {
|
||||
return (
|
||||
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<p>请先在设置中配置服务器地址</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px' }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}>排行榜</h2>
|
||||
|
||||
<Selector
|
||||
columns={3}
|
||||
value={[range]}
|
||||
onChange={(v) => setRange(v[0] as 'today' | 'week' | 'month')}
|
||||
options={[
|
||||
{ label: '今天', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]}
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<SpinLoading />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
{data.map((row, index) => (
|
||||
<div
|
||||
key={row.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 0',
|
||||
borderBottom: index < data.length - 1 ? '1px solid var(--adm-color-border)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
fontWeight: 'bold',
|
||||
color: index < 3 ? '#1890ff' : 'inherit'
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontWeight: 500 }}>{row.name}</div>
|
||||
<div style={{ fontWeight: 'bold', marginRight: '12px' }}>{row.score}</div>
|
||||
<div
|
||||
style={{
|
||||
color:
|
||||
row.range_change > 0 ? '#52c41a' : row.range_change < 0 ? '#ff4d4f' : '#999'
|
||||
}}
|
||||
>
|
||||
{row.range_change > 0 ? `+${row.range_change}` : row.range_change}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>暂无数据</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Button, Selector, Input, TextArea, SpinLoading, Toast } from 'antd-mobile'
|
||||
import { useApi } from '../App'
|
||||
|
||||
interface Student {
|
||||
id: number
|
||||
name: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface Reason {
|
||||
id: number
|
||||
content: string
|
||||
delta: number
|
||||
category: string
|
||||
}
|
||||
|
||||
export function MobileScore(): React.JSX.Element {
|
||||
const { api, config } = useApi()
|
||||
const [students, setStudents] = useState<Student[]>([])
|
||||
const [reasons, setReasons] = useState<Reason[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [selectedStudents, setSelectedStudents] = useState<string[]>([])
|
||||
const [selectedReason, setSelectedReason] = useState<Reason | null>(null)
|
||||
const [customScore, setCustomScore] = useState('')
|
||||
const [customReason, setCustomReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.baseUrl) return
|
||||
loadData()
|
||||
}, [config.baseUrl])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [stuRes, reaRes] = await Promise.all([
|
||||
api.get('/api/students'),
|
||||
api.get('/api/reasons')
|
||||
])
|
||||
if (stuRes.success) setStudents(stuRes.data || [])
|
||||
if (reaRes.success) setReasons(reaRes.data || [])
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedStudents.length === 0) {
|
||||
Toast.show({ content: '请选择学生', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
|
||||
const delta = selectedReason?.delta ?? parseInt(customScore, 10)
|
||||
if (isNaN(delta)) {
|
||||
Toast.show({ content: '请选择理由或输入分值', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
|
||||
const reasonContent = selectedReason?.content || customReason || '积分变更'
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
let successCount = 0
|
||||
for (const studentName of selectedStudents) {
|
||||
const res = await api.post('/api/events', {
|
||||
student_name: studentName,
|
||||
reason_content: reasonContent,
|
||||
delta
|
||||
})
|
||||
if (res.success) successCount++
|
||||
}
|
||||
|
||||
Toast.show({
|
||||
content: `已为 ${successCount} 名学生提交积分`,
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
setSelectedStudents([])
|
||||
setSelectedReason(null)
|
||||
setCustomScore('')
|
||||
setCustomReason('')
|
||||
} catch {
|
||||
Toast.show({ content: '提交失败', icon: 'fail' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.baseUrl) {
|
||||
return (
|
||||
<div style={{ padding: '16px', textAlign: 'center' }}>
|
||||
<p>请先在设置中配置服务器地址</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px' }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}>积分操作</h2>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<SpinLoading />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Card title="选择学生" style={{ marginBottom: '12px' }}>
|
||||
<Selector
|
||||
columns={3}
|
||||
multiple
|
||||
value={selectedStudents}
|
||||
onChange={(v) => setSelectedStudents(v as string[])}
|
||||
options={students.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="快捷理由" style={{ marginBottom: '12px' }}>
|
||||
<Selector
|
||||
columns={2}
|
||||
value={selectedReason ? [selectedReason.id] : []}
|
||||
onChange={(v) => {
|
||||
const reason = reasons.find((r) => r.id === v[0])
|
||||
setSelectedReason(reason || null)
|
||||
if (reason) {
|
||||
setCustomScore(String(Math.abs(reason.delta)))
|
||||
}
|
||||
}}
|
||||
options={reasons.map((r) => ({
|
||||
label: `${r.content} (${r.delta > 0 ? `+${r.delta}` : r.delta})`,
|
||||
value: r.id
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="自定义" style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Input
|
||||
placeholder="分值(如:2 或 -2)"
|
||||
value={customScore}
|
||||
onChange={setCustomScore}
|
||||
type="number"
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="理由(可选)"
|
||||
value={customReason}
|
||||
onChange={setCustomReason}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button block color="primary" size="large" loading={submitting} onClick={handleSubmit}>
|
||||
提交
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, Input, Button, List, Switch, Toast } from 'antd-mobile'
|
||||
import { useTheme, useApi } from '../App'
|
||||
|
||||
export function MobileSettings(): React.JSX.Element {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { config, setConfig } = useApi()
|
||||
const [apiUrl, setApiUrl] = useState(config.baseUrl)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const handleSaveApiUrl = () => {
|
||||
setConfig({ baseUrl: apiUrl })
|
||||
Toast.show({ content: '已保存服务器地址', icon: 'success' })
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!apiUrl) {
|
||||
Toast.show({ content: '请输入服务器地址', icon: 'fail' })
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/students`)
|
||||
if (res.ok) {
|
||||
Toast.show({ content: '连接成功', icon: 'success' })
|
||||
} else {
|
||||
Toast.show({ content: '连接失败', icon: 'fail' })
|
||||
}
|
||||
} catch {
|
||||
Toast.show({ content: '连接失败', icon: 'fail' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px' }}>
|
||||
<h2 style={{ margin: '0 0 12px', fontSize: '20px' }}>设置</h2>
|
||||
|
||||
<Card style={{ marginBottom: '12px' }}>
|
||||
<List header="外观">
|
||||
<List.Item extra={<Switch checked={isDark} onChange={toggleTheme} />}>深色模式</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginBottom: '12px' }}>
|
||||
<List header="服务器配置">
|
||||
<div style={{ padding: '12px' }}>
|
||||
<div style={{ marginBottom: '8px', color: '#666', fontSize: '12px' }}>
|
||||
输入桌面端 SecScore 的 HTTP 服务地址
|
||||
</div>
|
||||
<Input
|
||||
placeholder="http://192.168.1.100:3000"
|
||||
value={apiUrl}
|
||||
onChange={setApiUrl}
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button color="primary" size="small" onClick={handleSaveApiUrl}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="small" loading={testing} onClick={handleTestConnection}>
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<List header="关于">
|
||||
<List.Item>SecScore 移动端</List.Item>
|
||||
<List.Item description="连接桌面端进行积分操作">版本 1.0.0</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { electronApi } from './types'
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: electronApi
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { settingChange, settingsKey, settingsSpec, themeConfig } from './types'
|
||||
|
||||
const api = {
|
||||
// Theme
|
||||
getThemes: () => ipcRenderer.invoke('theme:list'),
|
||||
getCurrentTheme: () => ipcRenderer.invoke('theme:current'),
|
||||
setTheme: (themeId: string) => ipcRenderer.invoke('theme:set', themeId),
|
||||
saveTheme: (theme: themeConfig) => ipcRenderer.invoke('theme:save', theme),
|
||||
deleteTheme: (themeId: string) => ipcRenderer.invoke('theme:delete', themeId),
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => {
|
||||
const subscription = (_event: any, theme: themeConfig) => callback(theme)
|
||||
ipcRenderer.on('theme:updated', subscription)
|
||||
return () => ipcRenderer.removeListener('theme:updated', subscription)
|
||||
},
|
||||
|
||||
// DB - Student
|
||||
queryStudents: (params: any) => ipcRenderer.invoke('db:student:query', params),
|
||||
createStudent: (data: any) => ipcRenderer.invoke('db:student:create', data),
|
||||
updateStudent: (id: number, data: any) => ipcRenderer.invoke('db:student:update', id, data),
|
||||
deleteStudent: (id: number) => ipcRenderer.invoke('db:student:delete', id),
|
||||
importStudentsFromXlsx: (params: { names: string[] }) =>
|
||||
ipcRenderer.invoke('db:student:importFromXlsx', params),
|
||||
|
||||
// DB - Tags
|
||||
tagsGetAll: () => ipcRenderer.invoke('tags:getAll'),
|
||||
tagsGetByStudent: (studentId: number) => ipcRenderer.invoke('tags:getByStudent', studentId),
|
||||
tagsCreate: (name: string) => ipcRenderer.invoke('tags:create', name),
|
||||
tagsDelete: (id: number) => ipcRenderer.invoke('tags:delete', id),
|
||||
tagsUpdateStudentTags: (studentId: number, tagIds: number[]) =>
|
||||
ipcRenderer.invoke('tags:updateStudentTags', studentId, tagIds),
|
||||
|
||||
// DB - Reason
|
||||
queryReasons: () => ipcRenderer.invoke('db:reason:query'),
|
||||
createReason: (data: any) => ipcRenderer.invoke('db:reason:create', data),
|
||||
updateReason: (id: number, data: any) => ipcRenderer.invoke('db:reason:update', id, data),
|
||||
deleteReason: (id: number) => ipcRenderer.invoke('db:reason:delete', id),
|
||||
|
||||
// DB - Event
|
||||
queryEvents: (params: any) => ipcRenderer.invoke('db:event:query', params),
|
||||
createEvent: (data: any) => ipcRenderer.invoke('db:event:create', data),
|
||||
deleteEvent: (uuid: string) => ipcRenderer.invoke('db:event:delete', uuid),
|
||||
queryEventsByStudent: (params: any) => ipcRenderer.invoke('db:event:queryByStudent', params),
|
||||
queryLeaderboard: (params: any) => ipcRenderer.invoke('db:leaderboard:query', params),
|
||||
|
||||
// Settlement
|
||||
querySettlements: () => ipcRenderer.invoke('db:settlement:query'),
|
||||
createSettlement: () => ipcRenderer.invoke('db:settlement:create'),
|
||||
querySettlementLeaderboard: (params: any) =>
|
||||
ipcRenderer.invoke('db:settlement:leaderboard', params),
|
||||
|
||||
// Settings & Sync
|
||||
getAllSettings: () => ipcRenderer.invoke('settings:getAll'),
|
||||
getSetting: <K extends settingsKey>(key: K) => ipcRenderer.invoke('settings:get', key),
|
||||
setSetting: <K extends settingsKey>(key: K, value: settingsSpec[K]) =>
|
||||
ipcRenderer.invoke('settings:set', key, value),
|
||||
onSettingChanged: (callback: (change: settingChange) => void) => {
|
||||
const subscription = (_event: any, change: settingChange) => callback(change)
|
||||
ipcRenderer.on('settings:changed', subscription)
|
||||
return () => ipcRenderer.removeListener('settings:changed', subscription)
|
||||
},
|
||||
|
||||
// Auth & Security
|
||||
authGetStatus: () => ipcRenderer.invoke('auth:getStatus'),
|
||||
authLogin: (password: string) => ipcRenderer.invoke('auth:login', password),
|
||||
authLogout: () => ipcRenderer.invoke('auth:logout'),
|
||||
authSetPasswords: (payload: { adminPassword?: string | null; pointsPassword?: string | null }) =>
|
||||
ipcRenderer.invoke('auth:setPasswords', payload),
|
||||
authGenerateRecovery: () => ipcRenderer.invoke('auth:generateRecovery'),
|
||||
authResetByRecovery: (recoveryString: string) =>
|
||||
ipcRenderer.invoke('auth:resetByRecovery', recoveryString),
|
||||
authClearAll: () => ipcRenderer.invoke('auth:clearAll'),
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: () => ipcRenderer.invoke('data:exportJson'),
|
||||
importDataJson: (jsonText: string) => ipcRenderer.invoke('data:importJson', jsonText),
|
||||
|
||||
// Window
|
||||
openWindow: (input: { key: string; title?: string; route?: string; options?: any }) =>
|
||||
ipcRenderer.invoke('window:open', input),
|
||||
navigateWindow: (input: { key?: string; route: string }) =>
|
||||
ipcRenderer.invoke('window:navigate', input),
|
||||
windowMinimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
windowMaximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
windowClose: () => ipcRenderer.invoke('window:close'),
|
||||
windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => {
|
||||
const subscription = (_event: any, maximized: boolean) => callback(maximized)
|
||||
ipcRenderer.on('window:maximized-changed', subscription)
|
||||
return () => ipcRenderer.removeListener('window:maximized-changed', subscription)
|
||||
},
|
||||
onNavigate: (callback: (route: string) => void) => {
|
||||
const subscription = (_event: any, route: string) => callback(route)
|
||||
ipcRenderer.on('app:navigate', subscription)
|
||||
return () => ipcRenderer.removeListener('app:navigate', subscription)
|
||||
},
|
||||
toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'),
|
||||
windowResize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke('window:resize', width, height),
|
||||
|
||||
// Logger
|
||||
queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines),
|
||||
clearLogs: () => ipcRenderer.invoke('log:clear'),
|
||||
setLogLevel: (level: string) => ipcRenderer.invoke('log:setLevel', level),
|
||||
writeLog: (payload: { level: string; message: string; meta?: any }) =>
|
||||
ipcRenderer.invoke('log:write', payload),
|
||||
|
||||
registerUrlProtocol: () => ipcRenderer.invoke('app:register-url-protocol'),
|
||||
|
||||
// Database Connection
|
||||
dbTestConnection: (connectionString: string) =>
|
||||
ipcRenderer.invoke('db:testConnection', connectionString),
|
||||
dbSwitchConnection: (connectionString: string) =>
|
||||
ipcRenderer.invoke('db:switchConnection', connectionString),
|
||||
dbGetStatus: () => ipcRenderer.invoke('db:getStatus'),
|
||||
dbSync: () => ipcRenderer.invoke('db:sync'),
|
||||
|
||||
// HTTP Server
|
||||
httpServerStart: (config?: { port?: number; host?: string; corsOrigin?: string }) =>
|
||||
ipcRenderer.invoke('http:server:start', config),
|
||||
httpServerStop: () => ipcRenderer.invoke('http:server:stop'),
|
||||
httpServerStatus: () => ipcRenderer.invoke('http:server:status'),
|
||||
|
||||
// File System
|
||||
fsGetConfigStructure: () => ipcRenderer.invoke('fs:getConfigStructure'),
|
||||
fsReadJson: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:readJson', relativePath, folder ?? 'automatic'),
|
||||
fsWriteJson: (relativePath: string, data: any, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:writeJson', relativePath, data, folder ?? 'automatic'),
|
||||
fsReadText: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:readText', relativePath, folder ?? 'automatic'),
|
||||
fsWriteText: (content: string, relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:writeText', content, relativePath, folder ?? 'automatic'),
|
||||
fsDeleteFile: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:deleteFile', relativePath, folder ?? 'automatic'),
|
||||
fsListFiles: (folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:listFiles', folder ?? 'automatic'),
|
||||
fsFileExists: (relativePath: string, folder?: 'automatic' | 'script') =>
|
||||
ipcRenderer.invoke('fs:fileExists', relativePath, folder ?? 'automatic'),
|
||||
|
||||
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
||||
invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args)
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
try {
|
||||
ipcRenderer.invoke('log:write', {
|
||||
level: 'error',
|
||||
message: 'preload:expose failed',
|
||||
meta:
|
||||
error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: { error: String(error) }
|
||||
})
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -1,227 +1,297 @@
|
||||
export interface ipcResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
message?: string
|
||||
}
|
||||
|
||||
export type logLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
export type permissionLevel = 'admin' | 'points' | 'view'
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event"
|
||||
|
||||
export interface themeConfig {
|
||||
name: string
|
||||
id: string
|
||||
mode: 'light' | 'dark'
|
||||
mode: "light" | "dark"
|
||||
config: {
|
||||
tdesign: Record<string, string>
|
||||
custom: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export type settingsSpec = {
|
||||
export interface settingChange {
|
||||
key: string
|
||||
value: any
|
||||
oldValue: any
|
||||
}
|
||||
|
||||
export type settingsKey =
|
||||
| "is_wizard_completed"
|
||||
| "log_level"
|
||||
| "window_zoom"
|
||||
| "themes_custom"
|
||||
| "auto_score_enabled"
|
||||
| "auto_score_rules"
|
||||
| "current_theme_id"
|
||||
| "pg_connection_string"
|
||||
| "pg_connection_status"
|
||||
|
||||
export interface settingsSpec {
|
||||
is_wizard_completed: boolean
|
||||
log_level: logLevel
|
||||
log_level: string
|
||||
window_zoom: number
|
||||
themes_custom: themeConfig[]
|
||||
auto_score_enabled: boolean
|
||||
auto_score_rules: any[]
|
||||
current_theme_id: string
|
||||
pg_connection_string: string
|
||||
pg_connection_status: { connected: boolean; type: 'sqlite' | 'postgresql'; error?: string }
|
||||
pg_connection_status: {
|
||||
connected: boolean
|
||||
type: "sqlite" | "postgresql"
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type settingsKey = keyof settingsSpec
|
||||
|
||||
export interface ConfigFileInfo {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
modified: string
|
||||
}
|
||||
|
||||
export interface ConfigFolderStructure {
|
||||
configRoot: string
|
||||
automatic: string
|
||||
sscript: string
|
||||
}
|
||||
|
||||
export type settingChange<K extends settingsKey = settingsKey> = {
|
||||
key: K
|
||||
value: settingsSpec[K]
|
||||
}
|
||||
|
||||
export interface electronApi {
|
||||
const api = {
|
||||
// Theme
|
||||
getThemes: () => Promise<ipcResponse<themeConfig[]>>
|
||||
getCurrentTheme: () => Promise<ipcResponse<themeConfig>>
|
||||
setTheme: (themeId: string) => Promise<ipcResponse<void>>
|
||||
saveTheme: (theme: themeConfig) => Promise<ipcResponse<void>>
|
||||
deleteTheme: (themeId: string) => Promise<ipcResponse<void>>
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void) => () => void
|
||||
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
|
||||
getCurrentTheme: (): Promise<{ success: boolean; data: themeConfig }> => invoke("theme_current"),
|
||||
setTheme: (themeId: string): Promise<{ success: boolean }> => invoke("theme_set", { themeId }),
|
||||
saveTheme: (theme: themeConfig): Promise<{ success: boolean }> => invoke("theme_save", { theme }),
|
||||
deleteTheme: (themeId: string): Promise<{ success: boolean }> =>
|
||||
invoke("theme_delete", { themeId }),
|
||||
onThemeChanged: (callback: (theme: themeConfig) => void): Promise<UnlistenFn> => {
|
||||
return listen<{ theme: themeConfig }>("theme:updated", (event) => {
|
||||
callback(event.payload.theme)
|
||||
})
|
||||
},
|
||||
|
||||
// DB - Student
|
||||
queryStudents: (params?: any) => Promise<ipcResponse<any[]>>
|
||||
createStudent: (data: { name: string }) => Promise<ipcResponse<number>>
|
||||
updateStudent: (id: number, data: any) => Promise<ipcResponse<void>>
|
||||
deleteStudent: (id: number) => Promise<ipcResponse<void>>
|
||||
queryStudents: (params?: any): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("student_query", { params }),
|
||||
createStudent: (data: {
|
||||
name: string
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("student_create", { data }),
|
||||
updateStudent: (id: number, data: any): Promise<{ success: boolean }> =>
|
||||
invoke("student_update", { id, data }),
|
||||
deleteStudent: (id: number): Promise<{ success: boolean }> => invoke("student_delete", { id }),
|
||||
importStudentsFromXlsx: (params: {
|
||||
names: string[]
|
||||
}): Promise<{ success: boolean; data: { inserted: number; skipped: number; total: number } }> =>
|
||||
invoke("student_import_from_xlsx", { params }),
|
||||
|
||||
// DB - Tags
|
||||
tagsGetAll: () => Promise<ipcResponse<{ id: number; name: string }[]>>
|
||||
tagsGetByStudent: (studentId: number) => Promise<ipcResponse<{ id: number; name: string }[]>>
|
||||
tagsCreate: (name: string) => Promise<ipcResponse<{ id: number; name: string }>>
|
||||
tagsDelete: (id: number) => Promise<ipcResponse<void>>
|
||||
tagsUpdateStudentTags: (studentId: number, tagIds: number[]) => Promise<ipcResponse<void>>
|
||||
tagsGetAll: (): Promise<{ success: boolean; data: any[] }> => invoke("tags_get_all"),
|
||||
tagsGetByStudent: (studentId: number): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("tags_get_by_student", { studentId }),
|
||||
tagsCreate: (name: string): Promise<{ success: boolean; data: any }> =>
|
||||
invoke("tags_create", { name }),
|
||||
tagsDelete: (id: number): Promise<{ success: boolean }> => invoke("tags_delete", { id }),
|
||||
tagsUpdateStudentTags: (studentId: number, tagIds: number[]): Promise<{ success: boolean }> =>
|
||||
invoke("tags_update_student_tags", { studentId, tagIds }),
|
||||
|
||||
// DB - Reason
|
||||
queryReasons: () => Promise<ipcResponse<any[]>>
|
||||
createReason: (data: any) => Promise<ipcResponse<number>>
|
||||
updateReason: (id: number, data: any) => Promise<ipcResponse<void>>
|
||||
deleteReason: (id: number) => Promise<ipcResponse<void>>
|
||||
queryReasons: (): Promise<{ success: boolean; data: any[] }> => invoke("reason_query"),
|
||||
createReason: (data: any): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("reason_create", { data }),
|
||||
updateReason: (id: number, data: any): Promise<{ success: boolean }> =>
|
||||
invoke("reason_update", { id, data }),
|
||||
deleteReason: (id: number): Promise<{ success: boolean }> => invoke("reason_delete", { id }),
|
||||
|
||||
// DB - Event
|
||||
queryEvents: (params?: any) => Promise<ipcResponse<any[]>>
|
||||
queryEvents: (params?: { limit?: number }): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("event_query", { params }),
|
||||
createEvent: (data: {
|
||||
student_name: string
|
||||
reason_content: string
|
||||
studentName: string
|
||||
reasonContent: string
|
||||
delta: number
|
||||
}) => Promise<ipcResponse<number>>
|
||||
deleteEvent: (uuid: string) => Promise<ipcResponse<void>>
|
||||
}): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("event_create", { data }),
|
||||
deleteEvent: (uuid: string): Promise<{ success: boolean }> => invoke("event_delete", { uuid }),
|
||||
queryEventsByStudent: (params: {
|
||||
student_name: string
|
||||
studentName: string
|
||||
limit?: number
|
||||
startTime?: string | null
|
||||
}) => Promise<ipcResponse<any[]>>
|
||||
startTime?: string
|
||||
}): Promise<{ success: boolean; data: any[] }> => invoke("event_query_by_student", { params }),
|
||||
queryLeaderboard: (params: {
|
||||
range: 'today' | 'week' | 'month'
|
||||
}) => Promise<ipcResponse<{ startTime: string; rows: any[] }>>
|
||||
range: "today" | "week" | "month"
|
||||
}): Promise<{ success: boolean; data: { startTime: string; rows: any[] } }> =>
|
||||
invoke("leaderboard_query", { params }),
|
||||
|
||||
// Settlement
|
||||
querySettlements: () => Promise<
|
||||
ipcResponse<{ id: number; start_time: string; end_time: string; event_count: number }[]>
|
||||
>
|
||||
createSettlement: () => Promise<
|
||||
ipcResponse<{ settlementId: number; startTime: string; endTime: string; eventCount: number }>
|
||||
>
|
||||
querySettlementLeaderboard: (params: { settlement_id: number }) => Promise<
|
||||
ipcResponse<{
|
||||
settlement: { id: number; start_time: string; end_time: string }
|
||||
rows: { name: string; score: number }[]
|
||||
}>
|
||||
>
|
||||
querySettlements: (): Promise<{ success: boolean; data: any[] }> => invoke("db_settlement_query"),
|
||||
createSettlement: (): Promise<{ success: boolean; data: any }> => invoke("db_settlement_create"),
|
||||
querySettlementLeaderboard: (params: {
|
||||
settlementId: number
|
||||
}): Promise<{ success: boolean; data: any }> => invoke("db_settlement_leaderboard", { params }),
|
||||
|
||||
// Settings
|
||||
getAllSettings: () => Promise<ipcResponse<settingsSpec>>
|
||||
getSetting: <K extends settingsKey>(key: K) => Promise<ipcResponse<settingsSpec[K]>>
|
||||
setSetting: <K extends settingsKey>(key: K, value: settingsSpec[K]) => Promise<ipcResponse<void>>
|
||||
onSettingChanged: (callback: (change: settingChange) => void) => () => void
|
||||
// Settings & Sync
|
||||
getAllSettings: (): Promise<{ success: boolean; data: settingsSpec }> =>
|
||||
invoke("settings_get_all"),
|
||||
getSetting: <K extends settingsKey>(
|
||||
key: K
|
||||
): Promise<{ success: boolean; data: settingsSpec[K] }> => invoke("settings_get", { key }),
|
||||
setSetting: <K extends settingsKey>(
|
||||
key: K,
|
||||
value: settingsSpec[K]
|
||||
): Promise<{ success: boolean }> => invoke("settings_set", { key, value }),
|
||||
onSettingChanged: (callback: (change: settingChange) => void): Promise<UnlistenFn> => {
|
||||
return listen<settingChange>("settings:changed", (event) => {
|
||||
callback(event.payload)
|
||||
})
|
||||
},
|
||||
|
||||
// Auth & Security
|
||||
authGetStatus: () => Promise<
|
||||
ipcResponse<{
|
||||
permission: permissionLevel
|
||||
authGetStatus: (): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
permission: string
|
||||
hasAdminPassword: boolean
|
||||
hasPointsPassword: boolean
|
||||
hasRecoveryString: boolean
|
||||
}>
|
||||
>
|
||||
authLogin: (password: string) => Promise<ipcResponse<{ permission: permissionLevel }>>
|
||||
authLogout: () => Promise<ipcResponse<{ permission: permissionLevel }>>
|
||||
}
|
||||
}> => invoke("auth_get_status"),
|
||||
authLogin: (
|
||||
password: string
|
||||
): Promise<{ success: boolean; data?: { permission: string }; message?: string }> =>
|
||||
invoke("auth_login", { password }),
|
||||
authLogout: (): Promise<{ success: boolean; data: { permission: string } }> =>
|
||||
invoke("auth_logout"),
|
||||
authSetPasswords: (payload: {
|
||||
adminPassword?: string | null
|
||||
pointsPassword?: string | null
|
||||
}) => Promise<ipcResponse<{ recoveryString?: string }>>
|
||||
authGenerateRecovery: () => Promise<ipcResponse<{ recoveryString: string }>>
|
||||
authResetByRecovery: (recoveryString: string) => Promise<ipcResponse<{ recoveryString: string }>>
|
||||
authClearAll: () => Promise<ipcResponse<void>>
|
||||
}): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> =>
|
||||
invoke("auth_set_passwords", payload),
|
||||
authGenerateRecovery: (): Promise<{ success: boolean; data: { recoveryString: string } }> =>
|
||||
invoke("auth_generate_recovery"),
|
||||
authResetByRecovery: (
|
||||
recoveryString: string
|
||||
): Promise<{ success: boolean; data?: { recoveryString: string }; message?: string }> =>
|
||||
invoke("auth_reset_by_recovery", { recoveryString }),
|
||||
authClearAll: (): Promise<{ success: boolean }> => invoke("auth_clear_all"),
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: () => Promise<ipcResponse<string>>
|
||||
importDataJson: (jsonText: string) => Promise<ipcResponse<void>>
|
||||
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
||||
importDataJson: (jsonText: string): Promise<{ success: boolean }> =>
|
||||
invoke("data_import_json", { jsonText }),
|
||||
|
||||
// Window
|
||||
openWindow: (input: {
|
||||
key: string
|
||||
title?: string
|
||||
route?: string
|
||||
options?: any
|
||||
}) => Promise<ipcResponse<void>>
|
||||
navigateWindow: (input: { key?: string; route: string }) => Promise<ipcResponse<void>>
|
||||
windowMinimize: () => Promise<void>
|
||||
windowMaximize: () => Promise<boolean>
|
||||
windowClose: () => Promise<void>
|
||||
windowIsMaximized: () => Promise<boolean>
|
||||
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
|
||||
onNavigate: (callback: (route: string) => void) => () => void
|
||||
toggleDevTools: () => Promise<void>
|
||||
windowResize: (width: number, height: number) => Promise<void>
|
||||
windowMinimize: (): Promise<void> => invoke("window_minimize"),
|
||||
windowMaximize: (): Promise<boolean> => invoke("window_maximize"),
|
||||
windowClose: (): Promise<void> => invoke("window_close"),
|
||||
windowIsMaximized: (): Promise<boolean> => invoke("window_is_maximized"),
|
||||
onWindowMaximizedChanged: (callback: (maximized: boolean) => void): Promise<UnlistenFn> => {
|
||||
return listen<boolean>("window:maximized-changed", (event) => {
|
||||
callback(event.payload)
|
||||
})
|
||||
},
|
||||
onNavigate: (callback: (route: string) => void): Promise<UnlistenFn> => {
|
||||
return listen<string>("app:navigate", (event) => {
|
||||
callback(event.payload)
|
||||
})
|
||||
},
|
||||
|
||||
// Logger
|
||||
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
|
||||
clearLogs: () => Promise<ipcResponse<void>>
|
||||
setLogLevel: (level: logLevel) => Promise<ipcResponse<void>>
|
||||
queryLogs: (params?: { lines?: number }): Promise<{ success: boolean; data: string[] }> =>
|
||||
invoke("log_query", { params }),
|
||||
clearLogs: (): Promise<{ success: boolean }> => invoke("log_clear"),
|
||||
setLogLevel: (level: string): Promise<{ success: boolean }> => invoke("log_set_level", { level }),
|
||||
writeLog: (payload: {
|
||||
level: logLevel
|
||||
level: string
|
||||
message: string
|
||||
meta?: any
|
||||
}) => Promise<ipcResponse<void>>
|
||||
|
||||
registerUrlProtocol: () => Promise<ipcResponse<{ registered?: boolean }>>
|
||||
}): Promise<{ success: boolean }> => invoke("log_write", payload),
|
||||
|
||||
// Database Connection
|
||||
dbTestConnection: (
|
||||
connectionString: string
|
||||
) => Promise<ipcResponse<{ success: boolean; error?: string }>>
|
||||
): Promise<{ success: boolean; data: { success: boolean; error?: string } }> =>
|
||||
invoke("db_test_connection", { connectionString }),
|
||||
dbSwitchConnection: (
|
||||
connectionString: string
|
||||
) => Promise<ipcResponse<{ type: 'sqlite' | 'postgresql' }>>
|
||||
dbGetStatus: () => Promise<
|
||||
ipcResponse<{ type: 'sqlite' | 'postgresql'; connected: boolean; error?: string }>
|
||||
>
|
||||
dbSync: () => Promise<ipcResponse<{ success: boolean; message?: string }>>
|
||||
): Promise<{ success: boolean; data: { type: "sqlite" | "postgresql" } }> =>
|
||||
invoke("db_switch_connection", { connectionString }),
|
||||
dbGetStatus: (): Promise<{
|
||||
success: boolean
|
||||
data: { type: string; connected: boolean; error?: string }
|
||||
}> => invoke("db_get_status"),
|
||||
dbSync: (): Promise<{ success: boolean; data: { success: boolean; message?: string } }> =>
|
||||
invoke("db_sync"),
|
||||
|
||||
// HTTP Server
|
||||
httpServerStart: (config?: {
|
||||
port?: number
|
||||
host?: string
|
||||
corsOrigin?: string
|
||||
}) => Promise<
|
||||
ipcResponse<{ url: string; config: { port: number; host: string; corsOrigin?: string } }>
|
||||
>
|
||||
httpServerStop: () => Promise<ipcResponse<void>>
|
||||
httpServerStatus: () => Promise<
|
||||
ipcResponse<{
|
||||
isRunning: boolean
|
||||
config: { port: number; host: string; corsOrigin?: string }
|
||||
url: string | null
|
||||
}>
|
||||
>
|
||||
}): Promise<{ success: boolean; data: { url: string; config: any } }> =>
|
||||
invoke("http_server_start", { config }),
|
||||
httpServerStop: (): Promise<{ success: boolean }> => invoke("http_server_stop"),
|
||||
httpServerStatus: (): Promise<{
|
||||
success: boolean
|
||||
data: { isRunning: boolean; config?: any; url?: string }
|
||||
}> => invoke("http_server_status"),
|
||||
|
||||
// File System
|
||||
fsGetConfigStructure: () => Promise<ipcResponse<ConfigFolderStructure>>
|
||||
fsReadJson: (relativePath: string, folder?: 'automatic' | 'sscript') => Promise<ipcResponse<any>>
|
||||
fsGetConfigStructure: (): Promise<{
|
||||
success: boolean
|
||||
data: { configRoot: string; automatic: string; script: string }
|
||||
}> => invoke("fs_get_config_structure"),
|
||||
fsReadJson: (
|
||||
relativePath: string,
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean; data: any }> => invoke("fs_read_json", { relativePath, folder }),
|
||||
fsWriteJson: (
|
||||
relativePath: string,
|
||||
data: any,
|
||||
folder?: 'automatic' | 'sscript'
|
||||
) => Promise<ipcResponse<void>>
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean }> => invoke("fs_write_json", { relativePath, data, folder }),
|
||||
fsReadText: (
|
||||
relativePath: string,
|
||||
folder?: 'automatic' | 'sscript'
|
||||
) => Promise<ipcResponse<string | null>>
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean; data: string }> =>
|
||||
invoke("fs_read_text", { relativePath, folder }),
|
||||
fsWriteText: (
|
||||
content: string,
|
||||
relativePath: string,
|
||||
folder?: 'automatic' | 'sscript'
|
||||
) => Promise<ipcResponse<void>>
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean }> => invoke("fs_write_text", { content, relativePath, folder }),
|
||||
fsDeleteFile: (
|
||||
relativePath: string,
|
||||
folder?: 'automatic' | 'sscript'
|
||||
) => Promise<ipcResponse<void>>
|
||||
fsListFiles: (folder?: 'automatic' | 'sscript') => Promise<ipcResponse<ConfigFileInfo[]>>
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean }> => invoke("fs_delete_file", { relativePath, folder }),
|
||||
fsListFiles: (folder?: "automatic" | "script"): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("fs_list_files", { folder }),
|
||||
fsFileExists: (
|
||||
relativePath: string,
|
||||
folder?: 'automatic' | 'sscript'
|
||||
) => Promise<ipcResponse<boolean>>
|
||||
folder?: "automatic" | "script"
|
||||
): Promise<{ success: boolean; data: boolean }> =>
|
||||
invoke("fs_file_exists", { relativePath, folder }),
|
||||
|
||||
// Generic invoke wrapper (minimal compatibility API)
|
||||
invoke?: (channel: string, ...args: any[]) => Promise<any>
|
||||
// App
|
||||
registerUrlProtocol: (): Promise<{
|
||||
success: boolean
|
||||
data?: { registered: boolean }
|
||||
message?: string
|
||||
}> => invoke("register_url_protocol"),
|
||||
|
||||
// Auto Score
|
||||
autoScoreGetRules: (): Promise<{ success: boolean; data: any[] }> =>
|
||||
invoke("auto_score_get_rules"),
|
||||
autoScoreAddRule: (rule: any): Promise<{ success: boolean; data?: number; message?: string }> =>
|
||||
invoke("auto_score_add_rule", { rule }),
|
||||
autoScoreUpdateRule: (
|
||||
rule: any
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_update_rule", { rule }),
|
||||
autoScoreDeleteRule: (
|
||||
ruleId: number
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_delete_rule", { ruleId }),
|
||||
autoScoreToggleRule: (params: {
|
||||
ruleId: number
|
||||
enabled: boolean
|
||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_toggle_rule", params),
|
||||
autoScoreGetStatus: (): Promise<{ success: boolean; data: { enabled: boolean } }> =>
|
||||
invoke("auto_score_get_status"),
|
||||
autoScoreSortRules: (
|
||||
ruleIds: number[]
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_sort_rules", { ruleIds }),
|
||||
}
|
||||
|
||||
export default api
|
||||
export { api }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import ReactDOM from "react-dom"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
const MARK = '__td_react_root__'
|
||||
const MARK = "__td_react_root__"
|
||||
|
||||
// 兼容 React 19:给 ReactDOM 补上 render
|
||||
// TDesign 在初始化时会读取 ReactDOM.render,所以必须尽早 patch
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SecScore</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
function Versions(): React.JSX.Element {
|
||||
const [versions] = useState(window.electron.process.versions)
|
||||
|
||||
return (
|
||||
<ul className="versions">
|
||||
<li className="electron-version">Electron v{versions.electron}</li>
|
||||
<li className="chrome-version">Chromium v{versions.chrome}</li>
|
||||
<li className="node-version">Node v{versions.node}</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default Versions
|
||||
@@ -1,65 +0,0 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
import enUS from './locales/en-US.json'
|
||||
|
||||
export const defaultNS = 'translation'
|
||||
export const resources = {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'en-US': { translation: enUS }
|
||||
} as const
|
||||
|
||||
export type AppLanguage = 'zh-CN' | 'en-US'
|
||||
|
||||
export const languageNames: Record<AppLanguage, string> = {
|
||||
'zh-CN': '简体中文',
|
||||
'en-US': 'English'
|
||||
}
|
||||
|
||||
export const languageOptions: { value: AppLanguage; label: string }[] = [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'en-US', label: 'English' }
|
||||
]
|
||||
|
||||
const savedLanguage = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('secscore_language')
|
||||
if (stored && (stored === 'zh-CN' || stored === 'en-US')) {
|
||||
return stored
|
||||
}
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
const browserLang = navigator.language || (navigator as any).userLanguage
|
||||
if (browserLang?.startsWith('zh')) return 'zh-CN'
|
||||
return 'en-US'
|
||||
})()
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: savedLanguage,
|
||||
fallbackLng: 'zh-CN',
|
||||
defaultNS,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
},
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
})
|
||||
|
||||
export const changeLanguage = async (lang: AppLanguage): Promise<void> => {
|
||||
await i18n.changeLanguage(lang)
|
||||
try {
|
||||
localStorage.setItem('secscore_language', lang)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentLanguage = (): AppLanguage => {
|
||||
return (i18n.language as AppLanguage) || 'zh-CN'
|
||||
}
|
||||
|
||||
export { i18n }
|
||||
export default i18n
|
||||
@@ -1,105 +0,0 @@
|
||||
import './assets/main.css'
|
||||
import './i18n'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ClientContext } from './ClientContext'
|
||||
import { StudentService } from './services/StudentService'
|
||||
import { ServiceProvider } from './contexts/ServiceContext'
|
||||
|
||||
const ctx = new ClientContext()
|
||||
new StudentService(ctx)
|
||||
|
||||
const safeWriteLog = (payload: {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
message: string
|
||||
meta?: any
|
||||
}) => {
|
||||
try {
|
||||
const api = (window as any).api
|
||||
if (!api?.writeLog) return
|
||||
api.writeLog(payload)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const patchConsole = () => {
|
||||
const c = window.console as any
|
||||
const set = (name: string, fn: (...args: any[]) => void) => {
|
||||
try {
|
||||
c[name] = fn
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
set('log', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('info', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('warn', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'warn', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('debug', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'debug', message: String(args[0] ?? ''), meta: args.slice(1) })
|
||||
)
|
||||
set('error', (...args: any[]) => {
|
||||
const first = args[0]
|
||||
if (first instanceof Error) {
|
||||
safeWriteLog({
|
||||
level: 'error',
|
||||
message: first.message,
|
||||
meta: { stack: first.stack, args: args.slice(1) }
|
||||
})
|
||||
return
|
||||
}
|
||||
safeWriteLog({ level: 'error', message: String(first ?? ''), meta: args.slice(1) })
|
||||
})
|
||||
set('trace', (...args: any[]) =>
|
||||
safeWriteLog({
|
||||
level: 'debug',
|
||||
message: 'console.trace',
|
||||
meta: { args, stack: new Error('console.trace').stack }
|
||||
})
|
||||
)
|
||||
set('table', (...args: any[]) =>
|
||||
safeWriteLog({ level: 'info', message: 'console.table', meta: args })
|
||||
)
|
||||
}
|
||||
patchConsole()
|
||||
|
||||
window.addEventListener('error', (e: any) => {
|
||||
const error = e?.error
|
||||
safeWriteLog({
|
||||
level: 'error',
|
||||
message: 'renderer:error',
|
||||
meta: {
|
||||
message: error?.message || e?.message,
|
||||
stack: error?.stack,
|
||||
filename: e?.filename,
|
||||
lineno: e?.lineno,
|
||||
colno: e?.colno
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (e: any) => {
|
||||
const reason = e?.reason
|
||||
safeWriteLog({
|
||||
level: 'error',
|
||||
message: 'renderer:unhandledrejection',
|
||||
meta: reason instanceof Error ? { message: reason.message, stack: reason.stack } : { reason }
|
||||
})
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ServiceProvider value={ctx}>
|
||||
<App />
|
||||
</ServiceProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
// 监听主线程消息
|
||||
self.addEventListener('message', async (event: MessageEvent) => {
|
||||
const { type, data } = event.data
|
||||
|
||||
if (type === 'parseXlsx') {
|
||||
try {
|
||||
const { buffer } = data
|
||||
const wb = XLSX.read(buffer, { type: 'array' })
|
||||
const firstSheetName = wb.SheetNames?.[0]
|
||||
if (!firstSheetName) {
|
||||
self.postMessage({ type: 'error', error: 'xlsx 中未找到工作表' })
|
||||
return
|
||||
}
|
||||
const ws = wb.Sheets[firstSheetName]
|
||||
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][]
|
||||
if (!Array.isArray(aoa) || aoa.length === 0) {
|
||||
self.postMessage({ type: 'error', error: 'xlsx 内容为空' })
|
||||
return
|
||||
}
|
||||
|
||||
self.postMessage({ type: 'success', data: aoa })
|
||||
} catch (error: any) {
|
||||
self.postMessage({ type: 'error', error: error?.message || '解析 xlsx 失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service } from '../../../shared/kernel'
|
||||
import { ClientContext } from '../ClientContext'
|
||||
import { Service } from "../shared/kernel"
|
||||
import { ClientContext } from "../ClientContext"
|
||||
|
||||
export interface AutoScoreRule {
|
||||
id: number
|
||||
@@ -7,7 +7,7 @@ export interface AutoScoreRule {
|
||||
name: string
|
||||
studentNames: string[]
|
||||
lastExecuted?: string
|
||||
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
|
||||
triggers?: { event: string; value?: string; relation?: "AND" | "OR" }[]
|
||||
actions?: { event: string; value?: string; reason?: string }[]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ export interface AutoScoreRuleInput {
|
||||
enabled: boolean
|
||||
name: string
|
||||
studentNames: string[]
|
||||
triggers?: { event: string; value?: string; relation?: 'AND' | 'OR' }[]
|
||||
triggers?: { event: string; value?: string; relation?: "AND" | "OR" }[]
|
||||
actions?: { event: string; value?: string; reason?: string }[]
|
||||
}
|
||||
|
||||
declare module '../../../shared/kernel' {
|
||||
declare module "../shared/kernel" {
|
||||
interface Context {
|
||||
autoScore: AutoScoreService
|
||||
}
|
||||
@@ -27,45 +27,45 @@ declare module '../../../shared/kernel' {
|
||||
|
||||
export class AutoScoreService extends Service {
|
||||
constructor(ctx: ClientContext) {
|
||||
super(ctx, 'autoScore')
|
||||
super(ctx, "autoScore")
|
||||
}
|
||||
|
||||
async getRules(): Promise<{ success: boolean; data?: AutoScoreRule[]; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:getRules', {})
|
||||
return await (window as any).api.invoke("auto-score:getRules", {})
|
||||
}
|
||||
|
||||
async addRule(
|
||||
rule: AutoScoreRuleInput
|
||||
): Promise<{ success: boolean; data?: number; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:addRule', rule)
|
||||
return await (window as any).api.invoke("auto-score:addRule", rule)
|
||||
}
|
||||
|
||||
async updateRule(
|
||||
rule: Partial<AutoScoreRule> & { id: number }
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:updateRule', rule)
|
||||
return await (window as any).api.invoke("auto-score:updateRule", rule)
|
||||
}
|
||||
|
||||
async deleteRule(
|
||||
ruleId: number
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:deleteRule', ruleId)
|
||||
return await (window as any).api.invoke("auto-score:deleteRule", ruleId)
|
||||
}
|
||||
|
||||
async toggleRule(
|
||||
ruleId: number,
|
||||
enabled: boolean
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
|
||||
return await (window as any).api.invoke("auto-score:toggleRule", { ruleId, enabled })
|
||||
}
|
||||
|
||||
async sortRules(
|
||||
ruleIds: number[]
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:sortRules', ruleIds)
|
||||
return await (window as any).api.invoke("auto-score:sortRules", ruleIds)
|
||||
}
|
||||
|
||||
async getStatus(): Promise<{ success: boolean; data?: { enabled: boolean }; message?: string }> {
|
||||
return await (window as any).api.invoke('auto-score:getStatus', {})
|
||||
return await (window as any).api.invoke("auto-score:getStatus", {})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Service } from '../../../shared/kernel'
|
||||
import { ClientContext } from '../ClientContext'
|
||||
import { Service } from "../shared/kernel"
|
||||
import { ClientContext } from "../ClientContext"
|
||||
|
||||
declare module '../../../shared/kernel' {
|
||||
declare module "../shared/kernel" {
|
||||
interface Context {
|
||||
students: StudentService
|
||||
}
|
||||
@@ -9,7 +9,7 @@ declare module '../../../shared/kernel' {
|
||||
|
||||
export class StudentService extends Service {
|
||||
constructor(ctx: ClientContext) {
|
||||
super(ctx, 'students')
|
||||
super(ctx, "students")
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
@@ -16,7 +16,7 @@ export interface AutoScoreContext {
|
||||
id: number
|
||||
name: string
|
||||
studentNames: string[]
|
||||
triggers?: Array<{ event: string; value?: string; relation?: 'AND' | 'OR' }>
|
||||
triggers?: Array<{ event: string; value?: string; relation?: "AND" | "OR" }>
|
||||
actions?: Array<{ event: string; value?: string; reason?: string }>
|
||||
}
|
||||
now: Date
|
||||
@@ -39,7 +39,7 @@ export interface RuleConfig {
|
||||
triggers: Array<{
|
||||
event: string
|
||||
value?: string
|
||||
relation?: 'AND' | 'OR'
|
||||
relation?: "AND" | "OR"
|
||||
}>
|
||||
actions: Array<{
|
||||
event: string
|
||||
@@ -61,11 +61,11 @@ export class AutoScoreRuleEngine {
|
||||
}
|
||||
|
||||
private checkIntervalTimeTrigger(
|
||||
students: AutoScoreContext['students'],
|
||||
students: AutoScoreContext["students"],
|
||||
value: string | undefined,
|
||||
now: Date
|
||||
): Set<number> {
|
||||
const minutes = parseInt(value || '30', 10)
|
||||
const minutes = parseInt(value || "30", 10)
|
||||
if (isNaN(minutes) || minutes <= 0) return new Set()
|
||||
|
||||
const intervalMs = minutes * 60 * 1000
|
||||
@@ -87,12 +87,12 @@ export class AutoScoreRuleEngine {
|
||||
}
|
||||
|
||||
private checkStudentTagTrigger(
|
||||
students: AutoScoreContext['students'],
|
||||
students: AutoScoreContext["students"],
|
||||
value: string | undefined
|
||||
): Set<number> {
|
||||
const requiredTags =
|
||||
value
|
||||
?.split(',')
|
||||
?.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean) || []
|
||||
|
||||
@@ -129,17 +129,17 @@ export class AutoScoreRuleEngine {
|
||||
if (triggers.length === 0) continue
|
||||
|
||||
const triggerResults: Set<number>[] = []
|
||||
const relations: ('AND' | 'OR')[] = []
|
||||
const relations: ("AND" | "OR")[] = []
|
||||
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i]
|
||||
let matchedIds: Set<number>
|
||||
|
||||
switch (trigger.event) {
|
||||
case 'interval_time_passed':
|
||||
case "interval_time_passed":
|
||||
matchedIds = this.checkIntervalTimeTrigger(context.students, trigger.value, context.now)
|
||||
break
|
||||
case 'student_has_tag':
|
||||
case "student_has_tag":
|
||||
matchedIds = this.checkStudentTagTrigger(context.students, trigger.value)
|
||||
break
|
||||
default:
|
||||
@@ -147,15 +147,15 @@ export class AutoScoreRuleEngine {
|
||||
}
|
||||
|
||||
triggerResults.push(matchedIds)
|
||||
relations.push(trigger.relation || (i === 0 ? 'AND' : 'AND'))
|
||||
relations.push(trigger.relation || (i === 0 ? "AND" : "AND"))
|
||||
}
|
||||
|
||||
if (triggerResults.length === 0) continue
|
||||
|
||||
let finalMatchedIds: Set<number>
|
||||
const firstRelation = triggers[0]?.relation || 'AND'
|
||||
const firstRelation = triggers[0]?.relation || "AND"
|
||||
|
||||
if (firstRelation === 'OR') {
|
||||
if (firstRelation === "OR") {
|
||||
finalMatchedIds = new Set<number>()
|
||||
for (const ids of triggerResults) {
|
||||
for (const id of ids) {
|
||||
@@ -165,8 +165,8 @@ export class AutoScoreRuleEngine {
|
||||
} else {
|
||||
finalMatchedIds = new Set(triggerResults[0])
|
||||
for (let i = 1; i < triggerResults.length; i++) {
|
||||
const relation = triggers[i]?.relation || 'AND'
|
||||
if (relation === 'OR') {
|
||||
const relation = triggers[i]?.relation || "AND"
|
||||
if (relation === "OR") {
|
||||
for (const id of triggerResults[i]) {
|
||||
finalMatchedIds.add(id)
|
||||
}
|
||||
@@ -190,9 +190,9 @@ export class AutoScoreRuleEngine {
|
||||
matchedStudents: matchedStudents.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
tags: s.tags
|
||||
tags: s.tags,
|
||||
})),
|
||||
actions: rule.actions || []
|
||||
actions: rule.actions || [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -207,19 +207,19 @@ export class AutoScoreRuleEngine {
|
||||
eventRepo: any
|
||||
): Promise<void> {
|
||||
switch (actionType) {
|
||||
case 'add_score': {
|
||||
case "add_score": {
|
||||
const scoreValue = params.value ? parseInt(params.value, 10) : 0
|
||||
const reason = params.reason || `自动化加分 - ${params.ruleName}`
|
||||
for (const student of students) {
|
||||
await eventRepo.create({
|
||||
student_name: student.name,
|
||||
reason_content: reason,
|
||||
delta: scoreValue
|
||||
delta: scoreValue,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'add_tag': {
|
||||
case "add_tag": {
|
||||
const tagName = params.value
|
||||
if (tagName) {
|
||||
const studentRepo: any = params.studentRepo
|
||||
@@ -227,7 +227,7 @@ export class AutoScoreRuleEngine {
|
||||
const currentTags = student.tags || []
|
||||
if (!currentTags.includes(tagName)) {
|
||||
await studentRepo.update(student.id, {
|
||||
tags: [...currentTags, tagName]
|
||||
tags: [...currentTags, tagName],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './AutoScoreRuleEngine' // interfaces and engine consolidated
|
||||
export * from "./AutoScoreRuleEngine" // interfaces and engine consolidated
|
||||
|
||||
export * from './triggers/IntervalTimeTrigger'
|
||||
export * from './triggers/StudentTagTrigger'
|
||||
export * from "./triggers/IntervalTimeTrigger"
|
||||
export * from "./triggers/StudentTagTrigger"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AutoScoreContext } from '../AutoScoreRuleEngine'
|
||||
import type { AutoScoreContext } from "../AutoScoreRuleEngine"
|
||||
|
||||
export interface IntervalTimeTriggerConfig {
|
||||
intervalMinutes: number
|
||||
@@ -6,14 +6,14 @@ export interface IntervalTimeTriggerConfig {
|
||||
|
||||
export function createIntervalTimeTrigger() {
|
||||
return {
|
||||
id: 'interval_time_passed',
|
||||
label: '间隔时间',
|
||||
description: '每隔指定时间自动触发',
|
||||
id: "interval_time_passed",
|
||||
label: "间隔时间",
|
||||
description: "每隔指定时间自动触发",
|
||||
|
||||
validate: (value: string): { valid: boolean; message?: string } => {
|
||||
const minutes = parseInt(value, 10)
|
||||
if (isNaN(minutes) || minutes <= 0) {
|
||||
return { valid: false, message: '请输入有效的分钟数' }
|
||||
return { valid: false, message: "请输入有效的分钟数" }
|
||||
}
|
||||
return { valid: true }
|
||||
},
|
||||
@@ -65,7 +65,7 @@ export function createIntervalTimeTrigger() {
|
||||
|
||||
return {
|
||||
shouldExecute: matchedStudents.length > 0,
|
||||
matchedStudents
|
||||
matchedStudents,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -76,13 +76,13 @@ export function createIntervalTimeTrigger() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
fact: 'student',
|
||||
path: '.lastScoreTime',
|
||||
operator: 'lessThan',
|
||||
value: new Date(Date.now() - intervalMs).toISOString()
|
||||
}
|
||||
]
|
||||
fact: "student",
|
||||
path: ".lastScoreTime",
|
||||
operator: "lessThan",
|
||||
value: new Date(Date.now() - intervalMs).toISOString(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||