先提交上去

This commit is contained in:
Fox_block
2026-03-16 20:49:46 +08:00
parent 86abcb5602
commit 192b48cab0
303 changed files with 30019 additions and 5846 deletions
+72 -72
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
+17
View File
@@ -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
}
View File
+65
View File
@@ -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
+105
View File
@@ -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>
)
-23
View File
@@ -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))
}
}
-245
View File
@@ -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 || '同步失败' }
}
}
}
-170
View File
@@ -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 }
}
}
-5
View File
@@ -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'
-23
View File
@@ -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
}
-33
View File
@@ -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
}
-10
View File
@@ -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
}
-16
View File
@@ -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
}
-34
View File
@@ -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[]
}
-27
View File
@@ -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
}
-16
View File
@@ -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
}
-7
View File
@@ -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"`)
}
}
-3
View File
@@ -1,3 +0,0 @@
import { InitSchema2026011800000 } from './InitSchema2026011800000'
export const migrations = [InitSchema2026011800000]
-92
View File
@@ -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))
}
}
-110
View File
@@ -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
}
-32
View File
@@ -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'
-61
View File
@@ -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) // 记录错误但不中断
}
}
}
}
-216
View File
@@ -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()
}
}
}
-17
View File
@@ -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')
-71
View File
@@ -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 }
-367
View File
@@ -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()
}
})
-248
View File
@@ -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
}
}
-94
View File
@@ -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)
}
}
-180
View File
@@ -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) }))
}
}
}
-184
View File
@@ -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
}
})
}
}
-79
View File
@@ -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)
}
}
}
-175
View File
@@ -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 }
})
}
}
-445
View File
@@ -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()
}
}
-104
View File
@@ -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 }
}
)
}
}
-151
View File
@@ -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 }
}
}
-184
View File
@@ -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
}
}
-50
View File
@@ -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]
}
}
-63
View File
@@ -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
}
}
-291
View File
@@ -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
}
}
-291
View File
@@ -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)
}
}
}
-70
View File
@@ -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()
}
}
-235
View File
@@ -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
})
}
})
}
}
-131
View File
@@ -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
-60
View File
@@ -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>
)
}
-19
View File
@@ -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>
-11
View File
@@ -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>
)
-122
View File
@@ -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>
)
}
-104
View File
@@ -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>
)
}
-165
View File
@@ -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>
)
}
-79
View File
@@ -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>
)
}
-9
View File
@@ -1,9 +0,0 @@
import { electronApi } from './types'
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: electronApi
}
}
-167
View File
@@ -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
}
+216 -146
View File
@@ -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
-17
View File
@@ -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>
-15
View File
@@ -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
-65
View File
@@ -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
-105
View File
@@ -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>
)
-30
View File
@@ -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() {
+20 -20
View File
@@ -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],
})
}
}
+3 -3
View File
@@ -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(),
},
],
}
}
},
}
}

Some files were not shown because too many files have changed in this diff Show More