mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat:i18n and OOBE
This commit is contained in:
@@ -18,4 +18,3 @@ db.sqlite
|
|||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
/.trae/
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
该项目使用pnpm作为包管理器
|
||||||
|
每次写完代码后请务必跑一遍格式化和lint,typecheck,一个warning都不可以有
|
||||||
|
每次完成任务请详细告知用户“你干了什么”和要确认的问题(如有)。
|
||||||
@@ -40,10 +40,12 @@
|
|||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"es-object-atoms": "^1.1.1",
|
"es-object-atoms": "^1.1.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"i18next": "^25.8.14",
|
||||||
"mica-electron": "^1.5.16",
|
"mica-electron": "^1.5.16",
|
||||||
"os": "^0.1.2",
|
"os": "^0.1.2",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"pinyin-pro": "^3.27.0",
|
"pinyin-pro": "^3.27.0",
|
||||||
|
"react-i18next": "^16.5.6",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"typeorm": "^0.3.27",
|
"typeorm": "^0.3.27",
|
||||||
|
|||||||
Generated
+60
@@ -47,6 +47,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
i18next:
|
||||||
|
specifier: ^25.8.14
|
||||||
|
version: 25.8.14(typescript@5.9.3)
|
||||||
mica-electron:
|
mica-electron:
|
||||||
specifier: ^1.5.16
|
specifier: ^1.5.16
|
||||||
version: 1.5.16
|
version: 1.5.16
|
||||||
@@ -57,6 +60,9 @@ importers:
|
|||||||
pinyin-pro:
|
pinyin-pro:
|
||||||
specifier: ^3.27.0
|
specifier: ^3.27.0
|
||||||
version: 3.27.0
|
version: 3.27.0
|
||||||
|
react-i18next:
|
||||||
|
specifier: ^16.5.6
|
||||||
|
version: 16.5.6(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.0
|
specifier: ^6.28.0
|
||||||
version: 6.30.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 6.30.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -2464,6 +2470,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
http-cache-semantics@4.2.0:
|
http-cache-semantics@4.2.0:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
@@ -2483,6 +2492,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
i18next@25.8.14:
|
||||||
|
resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ^5
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
iconv-corefoundation@1.1.7:
|
iconv-corefoundation@1.1.7:
|
||||||
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
|
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
|
||||||
engines: {node: ^8.11.2 || >=10}
|
engines: {node: ^8.11.2 || >=10}
|
||||||
@@ -3313,6 +3330,22 @@ packages:
|
|||||||
react-fast-compare@3.2.2:
|
react-fast-compare@3.2.2:
|
||||||
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
|
||||||
|
|
||||||
|
react-i18next@16.5.6:
|
||||||
|
resolution: {integrity: sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==}
|
||||||
|
peerDependencies:
|
||||||
|
i18next: '>= 25.6.2'
|
||||||
|
react: '>= 16.8.0'
|
||||||
|
react-dom: '*'
|
||||||
|
react-native: '*'
|
||||||
|
typescript: ^5
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
react-native:
|
||||||
|
optional: true
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -3977,6 +4010,10 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
void-elements@3.1.0:
|
||||||
|
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
@@ -6873,6 +6910,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
void-elements: 3.1.0
|
||||||
|
|
||||||
http-cache-semantics@4.2.0: {}
|
http-cache-semantics@4.2.0: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
@@ -6902,6 +6943,12 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
i18next@25.8.14(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
iconv-corefoundation@1.1.7:
|
iconv-corefoundation@1.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
cli-truncate: 2.1.0
|
cli-truncate: 2.1.0
|
||||||
@@ -7721,6 +7768,17 @@ snapshots:
|
|||||||
|
|
||||||
react-fast-compare@3.2.2: {}
|
react-fast-compare@3.2.2: {}
|
||||||
|
|
||||||
|
react-i18next@16.5.6(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
html-parse-stringify: 3.0.1
|
||||||
|
i18next: 25.8.14(typescript@5.9.3)
|
||||||
|
react: 19.2.3
|
||||||
|
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
@@ -8440,6 +8498,8 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
|
|||||||
+12
-10
@@ -1,12 +1,14 @@
|
|||||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
|
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
|
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { ContentArea } from './components/ContentArea'
|
import { ContentArea } from './components/ContentArea'
|
||||||
import { Wizard } from './components/Wizard'
|
import { OOBE } from './components/OOBE'
|
||||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
|
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
|
||||||
|
|
||||||
function MainContent(): React.JSX.Element {
|
function MainContent(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { currentTheme } = useTheme()
|
const { currentTheme } = useTheme()
|
||||||
@@ -80,9 +82,9 @@ function MainContent(): React.JSX.Element {
|
|||||||
setPermission(res.data.permission)
|
setPermission(res.data.permission)
|
||||||
setAuthVisible(false)
|
setAuthVisible(false)
|
||||||
setAuthPassword('')
|
setAuthPassword('')
|
||||||
messageApi.success('权限已解锁')
|
messageApi.success(t('auth.unlocked'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '密码错误')
|
messageApi.error(res.message || t('common.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
const res = await (window as any).api.authLogout()
|
const res = await (window as any).api.authLogout()
|
||||||
if (res?.success && res.data) {
|
if (res?.success && res.data) {
|
||||||
setPermission(res.data.permission)
|
setPermission(res.data.permission)
|
||||||
messageApi.success('已切换为只读')
|
messageApi.success(t('auth.logout'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,25 +131,25 @@ function MainContent(): React.JSX.Element {
|
|||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="权限解锁"
|
title={t('auth.unlock')}
|
||||||
open={authVisible}
|
open={authVisible}
|
||||||
onCancel={() => setAuthVisible(false)}
|
onCancel={() => setAuthVisible(false)}
|
||||||
onOk={login}
|
onOk={login}
|
||||||
confirmLoading={authLoading}
|
confirmLoading={authLoading}
|
||||||
okText="解锁"
|
okText={t('auth.unlockButton')}
|
||||||
cancelText="取消"
|
cancelText={t('common.cancel')}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||||
输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。
|
{t('auth.unlockHint')}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={authPassword}
|
value={authPassword}
|
||||||
onChange={(e) => setAuthPassword(e.target.value)}
|
onChange={(e) => setAuthPassword(e.target.value)}
|
||||||
placeholder="例如 123456"
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
|
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
|
import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
|
||||||
import type { TriggerItem, ActionItem } from './com.automatically/types'
|
import type { TriggerItem, ActionItem } from './com.automatically/types'
|
||||||
import TriggerItemComponent from './com.automatically/TriggerItem'
|
import TriggerItemComponent from './com.automatically/TriggerItem'
|
||||||
@@ -36,6 +37,7 @@ interface AutoScoreRuleFormValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AutoScoreManager: React.FC = () => {
|
export const AutoScoreManager: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||||
const [students, setStudents] = useState<{ id: number; name: string }[]>([])
|
const [students, setStudents] = useState<{ id: number; name: string }[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -55,7 +57,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const authRes = await (window as any).api.authGetStatus()
|
const authRes = await (window as any).api.authGetStatus()
|
||||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||||
messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
|
messageApi.error(t('autoScore.adminRequired'))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -70,14 +72,14 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
if (rulesRes.success) {
|
if (rulesRes.success) {
|
||||||
setRules(rulesRes.data)
|
setRules(rulesRes.data)
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(rulesRes.message || '获取自动化失败')
|
messageApi.error(rulesRes.message || t('autoScore.fetchFailed'))
|
||||||
}
|
}
|
||||||
if (studentsRes.success) {
|
if (studentsRes.success) {
|
||||||
setStudents(studentsRes.data)
|
setStudents(studentsRes.data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch auto score rules:', error)
|
console.error('Failed to fetch auto score rules:', error)
|
||||||
messageApi.error('获取自动化失败')
|
messageApi.error(t('autoScore.fetchFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -93,17 +95,17 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
|
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
|
||||||
|
|
||||||
if (!values.name) {
|
if (!values.name) {
|
||||||
messageApi.warning('请填写自动化名称')
|
messageApi.warning(t('autoScore.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (triggerList.length === 0) {
|
if (triggerList.length === 0) {
|
||||||
messageApi.warning('请至少添加一个触发器')
|
messageApi.warning(t('autoScore.triggerRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionList.length === 0) {
|
if (actionList.length === 0) {
|
||||||
messageApi.warning('请至少添加一个行动')
|
messageApi.warning(t('autoScore.actionRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const authRes = await (window as any).api.authGetStatus()
|
const authRes = await (window as any).api.authGetStatus()
|
||||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||||
messageApi.error('需要管理员权限以创建或更新自动加分自动化')
|
messageApi.error(t('autoScore.adminCreateRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -150,7 +152,9 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
|
messageApi.success(
|
||||||
|
editingRuleId !== null ? t('autoScore.updateSuccess') : t('autoScore.createSuccess')
|
||||||
|
)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: '',
|
name: '',
|
||||||
studentNames: ''
|
studentNames: ''
|
||||||
@@ -161,12 +165,15 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
fetchRules()
|
fetchRules()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(
|
messageApi.error(
|
||||||
res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
|
res.message ||
|
||||||
|
(editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed'))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit auto score rule:', error)
|
console.error('Failed to submit auto score rule:', error)
|
||||||
messageApi.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
|
messageApi.error(
|
||||||
|
editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +212,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const authRes = await (window as any).api.authGetStatus()
|
const authRes = await (window as any).api.authGetStatus()
|
||||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||||
messageApi.error('需要管理员权限以删除自动加分自动化')
|
messageApi.error(t('autoScore.adminDeleteRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -215,14 +222,14 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
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) {
|
if (res.success) {
|
||||||
messageApi.success('自动化删除成功')
|
messageApi.success(t('autoScore.deleteSuccess'))
|
||||||
fetchRules()
|
fetchRules()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '删除自动化失败')
|
messageApi.error(res.message || t('autoScore.deleteFailed'))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete auto score rule:', error)
|
console.error('Failed to delete auto score rule:', error)
|
||||||
messageApi.error('删除自动化失败')
|
messageApi.error(t('autoScore.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +238,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const authRes = await (window as any).api.authGetStatus()
|
const authRes = await (window as any).api.authGetStatus()
|
||||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||||
messageApi.error('需要管理员权限以启用/禁用自动加分自动化')
|
messageApi.error(t('autoScore.adminToggleRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -241,14 +248,16 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
try {
|
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) {
|
if (res.success) {
|
||||||
messageApi.success(enabled ? '自动化已启用' : '自动化已禁用')
|
messageApi.success(enabled ? t('autoScore.enabled') : t('autoScore.disabled'))
|
||||||
fetchRules()
|
fetchRules()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
|
messageApi.error(
|
||||||
|
res.message || (enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed'))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle auto score rule:', error)
|
console.error('Failed to toggle auto score rule:', error)
|
||||||
messageApi.error(enabled ? '启用自动化失败' : '禁用自动化失败')
|
messageApi.error(enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +275,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
|
const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1
|
||||||
const defaultTrigger = allTriggers.list[0]
|
const defaultTrigger = allTriggers.list[0]
|
||||||
if (!defaultTrigger) {
|
if (!defaultTrigger) {
|
||||||
messageApi.error('没有可用的触发器类型,请检查配置')
|
messageApi.error(t('autoScore.noTriggerAvailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTriggerList((prev) => [
|
setTriggerList((prev) => [
|
||||||
@@ -300,7 +309,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
|
const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1
|
||||||
const defaultAction = allActions.list[0]
|
const defaultAction = allActions.list[0]
|
||||||
if (!defaultAction) {
|
if (!defaultAction) {
|
||||||
messageApi.error('没有可用的行动类型,请检查配置')
|
messageApi.error(t('autoScore.noActionAvailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setActionList((prev) => [
|
setActionList((prev) => [
|
||||||
@@ -333,12 +342,12 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
const columns: ColumnsType<AutoScoreRule> = [
|
const columns: ColumnsType<AutoScoreRule> = [
|
||||||
{
|
{
|
||||||
key: 'drag',
|
key: 'drag',
|
||||||
title: '排序',
|
title: t('autoScore.sort'),
|
||||||
width: 60,
|
width: 60,
|
||||||
render: () => <HolderOutlined style={{ cursor: 'move' }} />
|
render: () => <HolderOutlined style={{ cursor: 'move' }} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: t('autoScore.status'),
|
||||||
dataIndex: 'enabled',
|
dataIndex: 'enabled',
|
||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -346,15 +355,15 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
<Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
|
<Switch checked={enabled} onChange={(value) => handleToggle(row.id, value)} size="small" />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ title: '自动化名称', dataIndex: 'name', key: 'name', width: 150 },
|
{ title: t('autoScore.name'), dataIndex: 'name', key: 'name', width: 150 },
|
||||||
{
|
{
|
||||||
title: '触发器',
|
title: t('autoScore.triggers'),
|
||||||
dataIndex: 'triggers',
|
dataIndex: 'triggers',
|
||||||
key: 'triggers',
|
key: 'triggers',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (triggers: AutoScoreRule['triggers']) => {
|
render: (triggers: AutoScoreRule['triggers']) => {
|
||||||
if (!triggers || triggers.length === 0) {
|
if (!triggers || triggers.length === 0) {
|
||||||
return <span>无</span>
|
return <span>{t('common.none')}</span>
|
||||||
}
|
}
|
||||||
const triggerLabels = triggers.map((t) => {
|
const triggerLabels = triggers.map((t) => {
|
||||||
const def = triggerRegistry.get(t.event)
|
const def = triggerRegistry.get(t.event)
|
||||||
@@ -362,19 +371,19 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Tooltip title={triggerLabels.join(', ')}>
|
<Tooltip title={triggerLabels.join(', ')}>
|
||||||
<span>{triggers.length} 个触发器</span>
|
<span>{t('autoScore.triggerCount', { count: triggers.length })}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '行动',
|
title: t('autoScore.actions'),
|
||||||
dataIndex: 'actions',
|
dataIndex: 'actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (actions: AutoScoreRule['actions']) => {
|
render: (actions: AutoScoreRule['actions']) => {
|
||||||
if (!actions || actions.length === 0) {
|
if (!actions || actions.length === 0) {
|
||||||
return <span>无</span>
|
return <span>{t('common.none')}</span>
|
||||||
}
|
}
|
||||||
const actionLabels = actions.map((a) => {
|
const actionLabels = actions.map((a) => {
|
||||||
const def = actionRegistry.get(a.event)
|
const def = actionRegistry.get(a.event)
|
||||||
@@ -382,55 +391,55 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<Tooltip title={actionLabels.join(', ')}>
|
<Tooltip title={actionLabels.join(', ')}>
|
||||||
<span>{actions.length} 个行动</span>
|
<span>{t('autoScore.actionCount', { count: actions.length })}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '适用学生',
|
title: t('autoScore.applicableStudents'),
|
||||||
dataIndex: 'studentNames',
|
dataIndex: 'studentNames',
|
||||||
key: 'studentNames',
|
key: 'studentNames',
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (studentNames: string[]) => {
|
render: (studentNames: string[]) => {
|
||||||
if (!studentNames || studentNames.length === 0) {
|
if (!studentNames || studentNames.length === 0) {
|
||||||
return <span>所有学生</span>
|
return <span>{t('autoScore.allStudents')}</span>
|
||||||
}
|
}
|
||||||
const studentList = studentNames.join(',\n')
|
const studentList = studentNames.join(',\n')
|
||||||
return (
|
return (
|
||||||
<Tooltip title={studentList}>
|
<Tooltip title={studentList}>
|
||||||
<span>{studentNames.length} 名学生</span>
|
<span>{t('autoScore.studentCount', { count: studentNames.length })}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '最后执行',
|
title: t('autoScore.lastExecuted'),
|
||||||
dataIndex: 'lastExecuted',
|
dataIndex: 'lastExecuted',
|
||||||
key: 'lastExecuted',
|
key: 'lastExecuted',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (lastExecuted: string) => {
|
render: (lastExecuted: string) => {
|
||||||
if (!lastExecuted) return <span>未执行</span>
|
if (!lastExecuted) return <span>{t('autoScore.notExecuted')}</span>
|
||||||
try {
|
try {
|
||||||
const date = new Date(lastExecuted)
|
const date = new Date(lastExecuted)
|
||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
} catch {
|
} catch {
|
||||||
return <span>无效时间</span>
|
return <span>{t('autoScore.invalidTime')}</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: t('common.operation'),
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button size="small" onClick={() => handleEdit(row)}>
|
<Button size="small" onClick={() => handleEdit(row)}>
|
||||||
编辑
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm title="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
|
<Popconfirm title={t('autoScore.deleteConfirm')} onConfirm={() => handleDelete(row.id)}>
|
||||||
<Button size="small" danger>
|
<Button size="small" danger>
|
||||||
删除
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -468,24 +477,24 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}>自动化加分管理</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">
|
<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
|
<Form.Item
|
||||||
label="自动化名称"
|
label={t('autoScore.name')}
|
||||||
name="name"
|
name="name"
|
||||||
rules={[{ required: true, message: '请输入自动化名称' }]}
|
rules={[{ required: true, message: t('autoScore.nameRequired') }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="例如:每日签到加分" />
|
<Input placeholder={t('autoScore.namePlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="适用学生" name="studentNames">
|
<Form.Item label={t('autoScore.applicableStudents')} name="studentNames">
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="请选择或搜索学生(留空表示所有学生)"
|
placeholder={t('autoScore.studentPlaceholder')}
|
||||||
options={students.map((student) => ({ label: student.name, value: student.name }))}
|
options={students.map((student) => ({ label: student.name, value: student.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -493,10 +502,12 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
|
|
||||||
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
|
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
|
||||||
<Button type="primary" onClick={handleSubmit}>
|
<Button type="primary" onClick={handleSubmit}>
|
||||||
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
|
{editingRuleId !== null
|
||||||
|
? t('autoScore.updateAutomation')
|
||||||
|
: t('autoScore.addAutomation')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleResetForm}>
|
<Button onClick={handleResetForm}>
|
||||||
{editingRuleId !== null ? '取消编辑' : '重置表单'}
|
{editingRuleId !== null ? t('autoScore.cancelEdit') : t('autoScore.resetForm')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -504,7 +515,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
||||||
title="当以下规则触发时"
|
title={t('autoScore.whenTriggered')}
|
||||||
>
|
>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
{triggerItems}
|
{triggerItems}
|
||||||
@@ -514,14 +525,14 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
onClick={handleAddTrigger}
|
onClick={handleAddTrigger}
|
||||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||||
>
|
>
|
||||||
添加规则
|
{t('autoScore.addTrigger')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
||||||
title="满足规则时触发的行动"
|
title={t('autoScore.triggeredActions')}
|
||||||
>
|
>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||||
{actionItems}
|
{actionItems}
|
||||||
@@ -531,7 +542,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
onClick={handleAddAction}
|
onClick={handleAddAction}
|
||||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||||
>
|
>
|
||||||
添加行动
|
{t('autoScore.addAction')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -555,7 +566,7 @@ export const AutoScoreManager: React.FC = () => {
|
|||||||
setPageSize(size)
|
setPageSize(size)
|
||||||
}}
|
}}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total) => `共 ${total} 条`}
|
showTotal={(total) => t('common.total', { count: total })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { Suspense, lazy } from 'react'
|
import React, { Suspense, lazy } from 'react'
|
||||||
import { Layout, Space, Button, Tag } from 'antd'
|
import { Layout, Space, Button, Tag } from 'antd'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
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(() =>
|
const StudentManager = lazy(() =>
|
||||||
@@ -34,11 +35,16 @@ export function ContentArea({
|
|||||||
onAuthClick,
|
onAuthClick,
|
||||||
onLogout
|
onLogout
|
||||||
}: ContentAreaProps): React.JSX.Element {
|
}: ContentAreaProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
const permissionTag = (
|
const permissionTag = (
|
||||||
<Tag
|
<Tag
|
||||||
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
|
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
|
||||||
>
|
>
|
||||||
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
|
{permission === 'admin'
|
||||||
|
? t('permissions.admin')
|
||||||
|
: permission === 'points'
|
||||||
|
? t('permissions.points')
|
||||||
|
: t('permissions.view')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,10 +85,10 @@ export function ContentArea({
|
|||||||
{hasAnyPassword && (
|
{hasAnyPassword && (
|
||||||
<>
|
<>
|
||||||
<Button size="small" onClick={onAuthClick}>
|
<Button size="small" onClick={onAuthClick}>
|
||||||
输入密码
|
{t('auth.enterPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" danger onClick={onLogout}>
|
<Button size="small" danger onClick={onLogout}>
|
||||||
锁定
|
{t('auth.lock')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
|
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
|
||||||
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { match, pinyin } from 'pinyin-pro'
|
import { match, pinyin } from 'pinyin-pro'
|
||||||
|
|
||||||
interface student {
|
interface student {
|
||||||
@@ -21,6 +22,7 @@ interface reason {
|
|||||||
type SortType = 'alphabet' | 'surname' | 'score'
|
type SortType = 'alphabet' | 'surname' | 'score'
|
||||||
|
|
||||||
export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [students, setStudents] = useState<student[]>([])
|
const [students, setStudents] = useState<student[]>([])
|
||||||
const [reasons, setReasons] = useState<reason[]>([])
|
const [reasons, setReasons] = useState<reason[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -164,13 +166,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const groupedReasons = useMemo(() => {
|
const groupedReasons = useMemo(() => {
|
||||||
const groups: Record<string, reason[]> = {}
|
const groups: Record<string, reason[]> = {}
|
||||||
reasons.forEach((r) => {
|
reasons.forEach((r) => {
|
||||||
const cat = r.category || '其他'
|
const cat = r.category || t('home.category.others')
|
||||||
if (!groups[cat]) groups[cat] = []
|
if (!groups[cat]) groups[cat] = []
|
||||||
groups[cat].push(r)
|
groups[cat].push(r)
|
||||||
})
|
})
|
||||||
return Object.entries(groups).sort(([a], [b]) => {
|
return Object.entries(groups).sort(([a], [b]) => {
|
||||||
if (a === '其他') return 1
|
if (a === t('home.category.others')) return 1
|
||||||
if (b === '其他') return -1
|
if (b === t('home.category.others')) return -1
|
||||||
return a.localeCompare(b, 'zh-CN')
|
return a.localeCompare(b, 'zh-CN')
|
||||||
})
|
})
|
||||||
}, [reasons])
|
}, [reasons])
|
||||||
@@ -205,7 +207,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
const openOperation = (student: student) => {
|
const openOperation = (student: student) => {
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedStudent(student)
|
setSelectedStudent(student)
|
||||||
@@ -217,7 +219,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const performSubmit = async (student: student, delta: number, content: string) => {
|
const performSubmit = async (student: student, delta: number, content: string) => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +231,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}分`)
|
messageApi.success(
|
||||||
|
delta > 0
|
||||||
|
? t('home.scoreAdded', { name: student.name, points: Math.abs(delta) })
|
||||||
|
: t('home.scoreDeducted', { name: student.name, points: Math.abs(delta) })
|
||||||
|
)
|
||||||
setOperationVisible(false)
|
setOperationVisible(false)
|
||||||
|
|
||||||
setStudents((prev) =>
|
setStudents((prev) =>
|
||||||
@@ -238,7 +244,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
emitDataUpdated('events')
|
emitDataUpdated('events')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '提交失败')
|
messageApi.error(res.message || t('home.submitFailed'))
|
||||||
}
|
}
|
||||||
setSubmitLoading(false)
|
setSubmitLoading(false)
|
||||||
}
|
}
|
||||||
@@ -248,11 +254,17 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
const delta = customScore
|
const delta = customScore
|
||||||
if (delta === undefined || !Number.isFinite(delta)) {
|
if (delta === undefined || !Number.isFinite(delta)) {
|
||||||
messageApi.warning('请选择或输入分值')
|
messageApi.warning(t('home.pleaseSelectPoints'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更')
|
const content =
|
||||||
|
reasonContent ||
|
||||||
|
(delta > 0
|
||||||
|
? t('home.addPoints')
|
||||||
|
: delta < 0
|
||||||
|
? t('home.deductPoints')
|
||||||
|
: t('home.pointsChange'))
|
||||||
await performSubmit(selectedStudent, delta, content)
|
await performSubmit(selectedStudent, delta, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +386,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<span
|
<span
|
||||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
|
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
|
||||||
>
|
>
|
||||||
({group.students.length} 人)
|
({t('home.studentCount', { count: group.students.length })})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -524,10 +536,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}>
|
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}>
|
||||||
学生积分主页
|
{t('home.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||||
共 {students.length} 名学生,点击卡片进行积分操作
|
{t('home.subtitle', { count: students.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -535,7 +547,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<Input
|
<Input
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
placeholder="搜索姓名/拼音..."
|
placeholder={t('home.searchPlaceholder')}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '220px' }}
|
style={{ width: '220px' }}
|
||||||
@@ -546,9 +558,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
onChange={(v) => setSortType(v as SortType)}
|
onChange={(v) => setSortType(v as SortType)}
|
||||||
style={{ width: '140px' }}
|
style={{ width: '140px' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'alphabet', label: '姓名排序' },
|
{ value: 'alphabet', label: t('home.sortBy.alphabet') },
|
||||||
{ value: 'surname', label: '姓氏分组' },
|
{ value: 'surname', label: t('home.sortBy.surname') },
|
||||||
{ value: 'score', label: '积分排行' }
|
{ value: 'score', label: t('home.sortBy.score') }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -559,7 +571,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
|
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>加载中...</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('common.loading')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : sortedStudents.length === 0 ? (
|
) : sortedStudents.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
@@ -572,11 +584,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
|
||||||
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
|
{searchKeyword ? t('home.noMatch') : t('home.noStudents')}
|
||||||
</div>
|
</div>
|
||||||
{searchKeyword && (
|
{searchKeyword && (
|
||||||
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
|
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
|
||||||
清除搜索
|
{t('home.clearSearch')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,13 +598,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`积分操作:${selectedStudent?.name}`}
|
title={t('home.operationTitle', { name: selectedStudent?.name })}
|
||||||
open={operationVisible}
|
open={operationVisible}
|
||||||
onCancel={() => setOperationVisible(false)}
|
onCancel={() => setOperationVisible(false)}
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
confirmLoading={submitLoading}
|
confirmLoading={submitLoading}
|
||||||
okText="提交操作"
|
okText={t('home.submitOperation')}
|
||||||
cancelText="取消"
|
cancelText={t('common.cancel')}
|
||||||
width={560}
|
width={560}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
@@ -629,7 +641,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||||
当前积分:
|
{t('home.currentScore')}:
|
||||||
</span>
|
</span>
|
||||||
<Tag
|
<Tag
|
||||||
color={
|
color={
|
||||||
@@ -656,7 +668,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
gap: '8px'
|
gap: '8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600, fontSize: '14px' }}>快捷选项</span>
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>
|
||||||
|
{t('home.quickOptions')}
|
||||||
|
</span>
|
||||||
<Divider style={{ flex: 1, margin: 0 }} />
|
<Divider style={{ flex: 1, margin: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -724,7 +738,7 @@ 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' }}>调整分值</span>
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>{t('home.adjustPoints')}</span>
|
||||||
<Divider style={{ flex: 1, margin: 0 }} />
|
<Divider style={{ flex: 1, margin: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
||||||
@@ -752,10 +766,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
max={99}
|
max={99}
|
||||||
step={1}
|
step={1}
|
||||||
style={{ width: '140px' }}
|
style={{ width: '140px' }}
|
||||||
placeholder="自定义分值"
|
placeholder={t('home.customPoints')}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
|
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
|
||||||
可在输入框微调特输入任意分值
|
{t('home.customPointsHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,13 +778,13 @@ 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' }}>操作理由</span>
|
<span style={{ fontWeight: 600, fontSize: '14px' }}>{t('home.reason')}</span>
|
||||||
<Divider style={{ flex: 1, margin: 0 }} />
|
<Divider style={{ flex: 1, margin: 0 }} />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={reasonContent}
|
value={reasonContent}
|
||||||
onChange={(e) => setReasonContent(e.target.value)}
|
onChange={(e) => setReasonContent(e.target.value)}
|
||||||
placeholder="输入加分/扣分的原因(可选)"
|
placeholder={t('home.reasonPlaceholder')}
|
||||||
suffix={
|
suffix={
|
||||||
reasonContent ? (
|
reasonContent ? (
|
||||||
<DeleteOutlined
|
<DeleteOutlined
|
||||||
@@ -805,7 +819,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
color: 'var(--ss-text-main)'
|
color: 'var(--ss-text-main)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
变更预览:
|
{t('home.preview')}:
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '15px' }}>
|
<div style={{ fontSize: '15px' }}>
|
||||||
{selectedStudent.name}{' '}
|
{selectedStudent.name}{' '}
|
||||||
@@ -822,9 +836,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
>
|
>
|
||||||
{customScore > 0 ? `+${customScore}` : customScore}
|
{customScore > 0 ? `+${customScore}` : customScore}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
分
|
{t('home.points')}
|
||||||
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
|
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
|
||||||
{reasonContent ? `理由:${reasonContent}` : '(无理由)'}
|
{reasonContent
|
||||||
|
? `${t('home.reasonLabel')}${reasonContent}`
|
||||||
|
: t('home.noReason')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
|
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
|
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ interface studentRank {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Leaderboard: React.FC = () => {
|
export const Leaderboard: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [data, setData] = useState<studentRank[]>([])
|
const [data, setData] = useState<studentRank[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [timeRange, setTimeRange] = useState('today')
|
const [timeRange, setTimeRange] = useState('today')
|
||||||
@@ -58,7 +60,7 @@ export const Leaderboard: React.FC = () => {
|
|||||||
startTime
|
startTime
|
||||||
})
|
})
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
messageApi.error(res.message || '查询失败')
|
messageApi.error(res.message || t('leaderboard.queryFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,14 +70,19 @@ export const Leaderboard: React.FC = () => {
|
|||||||
return `${time} ${delta} ${e.reason_content}`
|
return `${time} ${delta} ${e.reason_content}`
|
||||||
})
|
})
|
||||||
|
|
||||||
setHistoryHeader(`${studentName} - 操作记录`)
|
setHistoryHeader(t('leaderboard.historyTitle', { name: studentName }))
|
||||||
setHistoryText(lines.join('\n') || '暂无记录')
|
setHistoryText(lines.join('\n') || t('common.noData'))
|
||||||
setHistoryVisible(true)
|
setHistoryVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
|
const title =
|
||||||
|
timeRange === 'today'
|
||||||
|
? t('leaderboard.today')
|
||||||
|
: timeRange === 'week'
|
||||||
|
? t('leaderboard.week')
|
||||||
|
: t('leaderboard.month')
|
||||||
|
|
||||||
const sanitizeCell = (v: unknown) => {
|
const sanitizeCell = (v: unknown) => {
|
||||||
if (typeof v !== 'string') return v
|
if (typeof v !== 'string') return v
|
||||||
@@ -84,7 +91,12 @@ export const Leaderboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sheetData = [
|
const sheetData = [
|
||||||
['排名', '姓名', '总积分', `${title}变化`],
|
[
|
||||||
|
t('leaderboard.rank'),
|
||||||
|
t('leaderboard.name'),
|
||||||
|
t('leaderboard.totalScore'),
|
||||||
|
`${title}${t('leaderboard.change')}`
|
||||||
|
],
|
||||||
...data.map((item, index) => [
|
...data.map((item, index) => [
|
||||||
index + 1,
|
index + 1,
|
||||||
sanitizeCell(item.name),
|
sanitizeCell(item.name),
|
||||||
@@ -97,7 +109,7 @@ export const Leaderboard: React.FC = () => {
|
|||||||
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()
|
const wb = XLSX.utils.book_new()
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '排行榜')
|
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], {
|
const blob = new Blob([xlsxBytes], {
|
||||||
@@ -109,20 +121,20 @@ export const Leaderboard: React.FC = () => {
|
|||||||
link.setAttribute('href', url)
|
link.setAttribute('href', url)
|
||||||
link.setAttribute(
|
link.setAttribute(
|
||||||
'download',
|
'download',
|
||||||
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
`${t('leaderboard.title')}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
)
|
)
|
||||||
link.style.visibility = 'hidden'
|
link.style.visibility = 'hidden'
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
messageApi.success('导出成功')
|
messageApi.success(t('leaderboard.exportSuccess'))
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<studentRank> = [
|
const columns: ColumnsType<studentRank> = [
|
||||||
{
|
{
|
||||||
title: '排名',
|
title: t('leaderboard.rank'),
|
||||||
key: 'rank',
|
key: 'rank',
|
||||||
width: 70,
|
width: 70,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
@@ -139,9 +151,9 @@ export const Leaderboard: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120, align: 'center' },
|
{ title: t('leaderboard.name'), dataIndex: 'name', key: 'name', width: 120, align: 'center' },
|
||||||
{
|
{
|
||||||
title: '总积分',
|
title: t('leaderboard.totalScore'),
|
||||||
dataIndex: 'score',
|
dataIndex: 'score',
|
||||||
key: 'score',
|
key: 'score',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -149,7 +161,12 @@ export const Leaderboard: React.FC = () => {
|
|||||||
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
|
render: (score: number) => <span style={{ fontWeight: 'bold' }}>{score}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化',
|
title:
|
||||||
|
timeRange === 'today'
|
||||||
|
? t('leaderboard.todayChange')
|
||||||
|
: timeRange === 'week'
|
||||||
|
? t('leaderboard.weekChange')
|
||||||
|
: t('leaderboard.monthChange'),
|
||||||
dataIndex: 'range_change',
|
dataIndex: 'range_change',
|
||||||
key: 'range_change',
|
key: 'range_change',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -161,13 +178,13 @@ export const Leaderboard: React.FC = () => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作记录',
|
title: t('leaderboard.operationRecord'),
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 100,
|
width: 100,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
|
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
|
||||||
查看
|
{t('leaderboard.viewHistory')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,20 +201,20 @@ export const Leaderboard: React.FC = () => {
|
|||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>积分排行榜</h2>
|
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('leaderboard.title')}</h2>
|
||||||
<Space>
|
<Space>
|
||||||
<Select
|
<Select
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(v) => setTimeRange(v)}
|
onChange={(v) => setTimeRange(v)}
|
||||||
style={{ width: '120px' }}
|
style={{ width: '120px' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'today', label: '今天' },
|
{ value: 'today', label: t('leaderboard.today') },
|
||||||
{ value: 'week', label: '本周' },
|
{ value: 'week', label: t('leaderboard.week') },
|
||||||
{ value: 'month', label: '本月' }
|
{ value: 'month', label: t('leaderboard.month') }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||||
导出 XLSX
|
{t('leaderboard.exportXlsx')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +235,7 @@ export const Leaderboard: React.FC = () => {
|
|||||||
title={historyHeader}
|
title={historyHeader}
|
||||||
open={historyVisible}
|
open={historyVisible}
|
||||||
onCancel={() => setHistoryVisible(false)}
|
onCancel={() => setHistoryVisible(false)}
|
||||||
footer={<Button onClick={() => setHistoryVisible(false)}>关闭</Button>}
|
footer={<Button onClick={() => setHistoryVisible(false)}>{t('common.close')}</Button>}
|
||||||
width="80%"
|
width="80%"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Segmented, Input, Tag, message, Typography, InputNumber } 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' | 'students' | 'reasons' | 'start'
|
||||||
|
|
||||||
|
interface studentItem {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface reasonItem {
|
||||||
|
content: string
|
||||||
|
delta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepClone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T
|
||||||
|
|
||||||
|
const presetPrimaryColors = [
|
||||||
|
'#1677FF',
|
||||||
|
'#2F54EB',
|
||||||
|
'#722ED1',
|
||||||
|
'#EB2F96',
|
||||||
|
'#F5222D',
|
||||||
|
'#FA8C16',
|
||||||
|
'#FADB14',
|
||||||
|
'#52C41A',
|
||||||
|
'#13C2C2'
|
||||||
|
]
|
||||||
|
|
||||||
|
const presetGradients: {
|
||||||
|
id: string
|
||||||
|
labelKey: string
|
||||||
|
light: string
|
||||||
|
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: '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: '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 }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<oobeStep>('language')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState<AppLanguage>('zh-CN')
|
||||||
|
const [workingTheme, setWorkingTheme] = useState<themeConfig | null>(null)
|
||||||
|
const [primaryInput, setPrimaryInput] = useState('')
|
||||||
|
const [students, setStudents] = useState<studentItem[]>([])
|
||||||
|
const [newStudentName, setNewStudentName] = useState('')
|
||||||
|
const [reasons, setReasons] = useState<reasonItem[]>([])
|
||||||
|
const [newReasonContent, setNewReasonContent] = useState('')
|
||||||
|
const [newReasonDelta, setNewReasonDelta] = useState(1)
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const steps: oobeStep[] = ['language', 'theme', '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'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTheme) return
|
||||||
|
const base = deepClone(currentTheme)
|
||||||
|
const editable =
|
||||||
|
base.id === 'custom-default' || base.id.startsWith('custom-')
|
||||||
|
? base
|
||||||
|
: { ...base, id: 'custom-default', name: t('theme.myTheme') }
|
||||||
|
setWorkingTheme(editable)
|
||||||
|
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
||||||
|
}, [currentTheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workingTheme) return
|
||||||
|
applyTheme(workingTheme)
|
||||||
|
}, [workingTheme, applyTheme])
|
||||||
|
|
||||||
|
const saveThemeToDb = async (theme: themeConfig) => {
|
||||||
|
if (!(window as any).api) return false
|
||||||
|
try {
|
||||||
|
const exists = themes.some((t) => t.id === theme.id)
|
||||||
|
if (!exists) {
|
||||||
|
const res = await (window as any).api.saveTheme(theme)
|
||||||
|
if (!res?.success) return false
|
||||||
|
}
|
||||||
|
if (currentTheme?.id !== theme.id) {
|
||||||
|
await setTheme(theme.id)
|
||||||
|
}
|
||||||
|
const res = await (window as any).api.saveTheme(theme)
|
||||||
|
return res?.success
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPrimary = async (hex: string) => {
|
||||||
|
setPrimaryInput(hex)
|
||||||
|
setWorkingTheme((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = deepClone(prev)
|
||||||
|
next.config = next.config || ({} as any)
|
||||||
|
next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex }
|
||||||
|
saveThemeToDb(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setGradientPreset = async (value: string) => {
|
||||||
|
setWorkingTheme((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = deepClone(prev)
|
||||||
|
next.config = next.config || ({} as any)
|
||||||
|
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'
|
||||||
|
await setTheme(targetThemeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLanguageChange = async (lang: AppLanguage) => {
|
||||||
|
setSelectedLanguage(lang)
|
||||||
|
await changeLanguage(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStudent = () => {
|
||||||
|
const name = newStudentName.trim()
|
||||||
|
if (!name) return
|
||||||
|
if (students.some((s) => s.name === name)) {
|
||||||
|
messageApi.warning(t('oobe.steps.students.studentExists'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStudents([...students, { name }])
|
||||||
|
setNewStudentName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStudent = (name: string) => {
|
||||||
|
setStudents(students.filter((s) => s.name !== name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addReason = () => {
|
||||||
|
const content = newReasonContent.trim()
|
||||||
|
if (!content) return
|
||||||
|
if (reasons.some((r) => r.content === content)) {
|
||||||
|
messageApi.warning(t('oobe.steps.reasons.reasonExists'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setReasons([...reasons, { content, delta: newReasonDelta }])
|
||||||
|
setNewReasonContent('')
|
||||||
|
setNewReasonDelta(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeReason = (content: string) => {
|
||||||
|
setReasons(reasons.filter((r) => r.content !== content))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileImport = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
} else if (data.students && Array.isArray(data.students)) {
|
||||||
|
studentList = data.students.map((item: any) =>
|
||||||
|
typeof item === 'string' ? item : item.name || item.student_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const newStudents = studentList
|
||||||
|
.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 })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
messageApi.info(t('oobe.steps.students.noNewStudents'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('oobe.steps.students.parseFailed'))
|
||||||
|
}
|
||||||
|
} else if (ext === 'xlsx') {
|
||||||
|
try {
|
||||||
|
const { read, utils } = await import('xlsx')
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
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') {
|
||||||
|
names.push(name.trim())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const newStudents = names
|
||||||
|
.filter((name) => !students.some((s) => s.name === name))
|
||||||
|
.map((name) => ({ name }))
|
||||||
|
if (newStudents.length > 0) {
|
||||||
|
setStudents([...students, ...newStudents])
|
||||||
|
messageApi.success(
|
||||||
|
t('oobe.steps.students.importSuccess', { count: newStudents.length })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
messageApi.info(t('oobe.steps.students.noNewStudents'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('oobe.steps.students.parseFailed'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messageApi.error(t('oobe.steps.students.unsupportedFormat'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[students, messageApi]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleFileImport(file)
|
||||||
|
},
|
||||||
|
[handleFileImport]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const idx = steps.indexOf(currentStep)
|
||||||
|
if (idx < steps.length - 1) {
|
||||||
|
setCurrentStep(steps[idx + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
const idx = steps.indexOf(currentStep)
|
||||||
|
if (idx > 0) {
|
||||||
|
setCurrentStep(steps[idx - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
handleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinish = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (!(window as any).api) throw new Error('api not ready')
|
||||||
|
|
||||||
|
if (workingTheme) {
|
||||||
|
const exists = themes.some((t) => t.id === workingTheme.id)
|
||||||
|
if (!exists) {
|
||||||
|
await (window as any).api.saveTheme(workingTheme)
|
||||||
|
}
|
||||||
|
if (currentTheme?.id !== workingTheme.id) {
|
||||||
|
await setTheme(workingTheme.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const student of students) {
|
||||||
|
await (window as any).api.createStudent({ name: student.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reason of reasons) {
|
||||||
|
await (window as any).api.createReason({
|
||||||
|
content: reason.content,
|
||||||
|
delta: reason.delta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
onComplete()
|
||||||
|
} catch (e: any) {
|
||||||
|
messageApi.error(e?.message || t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
const currentBg = workingTheme?.config?.custom?.['--ss-bg-color'] || ''
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 'language':
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{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
|
||||||
|
}))}
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Segmented
|
||||||
|
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' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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' }}>
|
||||||
|
<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}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
{presetPrimaryColors.slice(0, 6).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPrimary(c)}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border:
|
||||||
|
primaryInput === c
|
||||||
|
? '2px solid var(--ss-primary-color)'
|
||||||
|
: isDark
|
||||||
|
? '1px solid rgba(255,255,255,0.3)'
|
||||||
|
: '1px solid rgba(0,0,0,0.2)',
|
||||||
|
background: c,
|
||||||
|
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' }}>
|
||||||
|
{presetGradients.map((g) => {
|
||||||
|
const gradientValue = g[workingTheme?.mode || 'light']
|
||||||
|
const active = currentBg === gradientValue
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGradientPreset(gradientValue)}
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: active
|
||||||
|
? '2px solid var(--ss-primary-color)'
|
||||||
|
: isDark
|
||||||
|
? '1px solid rgba(255,255,255,0.2)'
|
||||||
|
: '1px solid rgba(0,0,0,0.1)',
|
||||||
|
background: gradientValue,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
title={t(`theme.gradientLabels.${g.labelKey}`)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'students':
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.students.description')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{t('oobe.steps.students.import')}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFileImport(file)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
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)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
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 style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
{t('oobe.steps.students.supportedFormats')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={newStudentName}
|
||||||
|
onChange={(e) => setNewStudentName(e.target.value)}
|
||||||
|
placeholder={t('oobe.steps.students.studentName')}
|
||||||
|
onPressEnter={addStudent}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={addStudent}>
|
||||||
|
{t('oobe.steps.students.addStudent')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 150,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{students.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.students.noStudents')}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
students.map((s) => (
|
||||||
|
<Tag
|
||||||
|
key={s.name}
|
||||||
|
closable
|
||||||
|
onClose={() => removeStudent(s.name)}
|
||||||
|
style={{ margin: 2 }}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Tag>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{students.length > 0 && (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.students.studentCount', { count: students.length })}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'reasons':
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.reasons.description')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
value={newReasonContent}
|
||||||
|
onChange={(e) => setNewReasonContent(e.target.value)}
|
||||||
|
placeholder={t('oobe.steps.reasons.reasonName')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
value={newReasonDelta}
|
||||||
|
onChange={(v) => setNewReasonDelta(v || 0)}
|
||||||
|
min={-999}
|
||||||
|
max={999}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={addReason}>
|
||||||
|
{t('oobe.steps.reasons.addReason')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 150,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reasons.length === 0 ? (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.reasons.noReasons')}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
reasons.map((r) => (
|
||||||
|
<Tag
|
||||||
|
key={r.content}
|
||||||
|
closable
|
||||||
|
onClose={() => removeReason(r.content)}
|
||||||
|
color={r.delta > 0 ? 'success' : 'error'}
|
||||||
|
style={{ margin: 2 }}
|
||||||
|
>
|
||||||
|
{r.content} ({r.delta > 0 ? '+' : ''}
|
||||||
|
{r.delta})
|
||||||
|
</Tag>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reasons.length > 0 && (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('oobe.steps.reasons.reasonCount', { count: reasons.length })}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
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')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||||
|
borderRadius: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#52c41a' }}>✓</span>
|
||||||
|
<span>{t('oobe.steps.start.features.score')}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||||
|
borderRadius: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#52c41a' }}>✓</span>
|
||||||
|
<span>{t('oobe.steps.start.features.history')}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)',
|
||||||
|
borderRadius: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#52c41a' }}>✓</span>
|
||||||
|
<span>{t('oobe.steps.start.features.settlement')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OOBEBackground primaryColor={primaryColor} mode={workingTheme?.mode || 'light'} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Title
|
||||||
|
level={3}
|
||||||
|
style={{ margin: 0, color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title
|
||||||
|
level={4}
|
||||||
|
style={{ margin: 0, color: isDark ? '#fff' : 'rgba(0, 0, 0, 0.88)' }}
|
||||||
|
>
|
||||||
|
{t(`oobe.steps.${currentStep}.title`)}
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ minHeight: 200, marginBottom: 24 }}>{renderStepContent()}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
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)',
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(stepIndex / totalSteps) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: primaryColor,
|
||||||
|
transition: 'width 0.3s'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{t('oobe.step', { current: stepIndex, total: totalSteps })}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{currentStep !== 'start' ? (
|
||||||
|
<Button type="primary" onClick={handleNext}>
|
||||||
|
{t('common.next')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleFinish}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logoSvg}
|
||||||
|
alt="SecScore"
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
filter: isDark ? 'brightness(0) invert(1)' : 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('oobe.steps.start.startButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface OOBEBackgroundProps {
|
||||||
|
primaryColor: string
|
||||||
|
mode: 'light' | 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OOBEBackground: React.FC<OOBEBackgroundProps> = ({ primaryColor, mode }) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const animationRef = useRef<number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
|
||||||
|
const rgb = hexToRgb(primaryColor)
|
||||||
|
|
||||||
|
const isDark = mode === 'dark'
|
||||||
|
|
||||||
|
const bgColor = isDark ? '#0a1628' : '#e8f4fc'
|
||||||
|
const transparentColor = isDark ? 'rgba(10, 22, 40, 0)' : 'rgba(232, 244, 252, 0)'
|
||||||
|
|
||||||
|
const blobs: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
radius: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
color: string
|
||||||
|
phase: number
|
||||||
|
}[] = []
|
||||||
|
|
||||||
|
const baseColors = isDark
|
||||||
|
? [
|
||||||
|
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.35)`,
|
||||||
|
`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(${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)`
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
blobs.push({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
radius: 150 + Math.random() * 200,
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = 0
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
time += 0.008
|
||||||
|
|
||||||
|
ctx.fillStyle = bgColor
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
blobs.forEach((blob, i) => {
|
||||||
|
const wobbleX = Math.sin(time + blob.phase) * 30
|
||||||
|
const wobbleY = Math.cos(time * 0.7 + blob.phase) * 30
|
||||||
|
|
||||||
|
blob.x += blob.vx + Math.sin(time * 0.5 + i) * 0.1
|
||||||
|
blob.y += blob.vy + Math.cos(time * 0.5 + i) * 0.1
|
||||||
|
|
||||||
|
if (blob.x < -blob.radius) blob.x = canvas.width + blob.radius
|
||||||
|
if (blob.x > canvas.width + blob.radius) blob.x = -blob.radius
|
||||||
|
if (blob.y < -blob.radius) blob.y = canvas.height + blob.radius
|
||||||
|
if (blob.y > canvas.height + blob.radius) blob.y = -blob.radius
|
||||||
|
|
||||||
|
const gradient = ctx.createRadialGradient(
|
||||||
|
blob.x + wobbleX,
|
||||||
|
blob.y + wobbleY,
|
||||||
|
0,
|
||||||
|
blob.x + wobbleX,
|
||||||
|
blob.y + wobbleY,
|
||||||
|
blob.radius
|
||||||
|
)
|
||||||
|
gradient.addColorStop(0, blob.color)
|
||||||
|
gradient.addColorStop(1, transparentColor)
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(blob.x + wobbleX, blob.y + wobbleY, blob.radius, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
})
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
animate()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
}
|
||||||
|
}, [primaryColor, mode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 0,
|
||||||
|
filter: 'blur(40px)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
|
import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface reason {
|
interface reason {
|
||||||
id: number
|
id: number
|
||||||
@@ -11,6 +12,7 @@ interface reason {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [data, setData] = useState<reason[]>([])
|
const [data, setData] = useState<reason[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
@@ -49,15 +51,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
const content = values.content?.trim()
|
const content = values.content?.trim()
|
||||||
const category = values.category?.trim() || '其他'
|
const category = values.category?.trim() || t('reasons.others')
|
||||||
|
|
||||||
if (data.some((r) => r.content === content && r.category === category)) {
|
if (data.some((r) => r.content === content && r.category === category)) {
|
||||||
messageApi.warning('该分类下已存在相同理由')
|
messageApi.warning(t('reasons.reasonExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,43 +70,43 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
delta: Number(values.delta)
|
delta: Number(values.delta)
|
||||||
})
|
})
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('添加成功')
|
messageApi.success(t('reasons.addSuccess'))
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
fetchReasons()
|
fetchReasons()
|
||||||
emitDataUpdated('reasons')
|
emitDataUpdated('reasons')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '添加失败')
|
messageApi.error(res.message || t('reasons.addFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await (window as any).api.deleteReason(id)
|
const res = await (window as any).api.deleteReason(id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('删除成功')
|
messageApi.success(t('reasons.deleteSuccess'))
|
||||||
fetchReasons()
|
fetchReasons()
|
||||||
emitDataUpdated('reasons')
|
emitDataUpdated('reasons')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '删除失败')
|
messageApi.error(res.message || t('reasons.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<reason> = [
|
const columns: ColumnsType<reason> = [
|
||||||
{
|
{
|
||||||
title: '分类',
|
title: t('reasons.category'),
|
||||||
dataIndex: 'category',
|
dataIndex: 'category',
|
||||||
key: 'category',
|
key: 'category',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (category: string) => <Tag>{category}</Tag>
|
render: (category: string) => <Tag>{category}</Tag>
|
||||||
},
|
},
|
||||||
{ title: '理由内容', dataIndex: 'content', key: 'content', width: 250 },
|
{ title: t('reasons.content'), dataIndex: 'content', key: 'content', width: 250 },
|
||||||
{
|
{
|
||||||
title: '预设分值',
|
title: t('reasons.presetPoints'),
|
||||||
dataIndex: 'delta',
|
dataIndex: 'delta',
|
||||||
key: 'delta',
|
key: 'delta',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -120,17 +122,17 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: t('common.operation'),
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认删除该理由?"
|
title={t('reasons.deleteConfirm')}
|
||||||
onConfirm={() => handleDelete(row.id)}
|
onConfirm={() => handleDelete(row.id)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<Button type="link" danger disabled={!canEdit}>
|
<Button type="link" danger disabled={!canEdit}>
|
||||||
删除
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)
|
)
|
||||||
@@ -141,9 +143,9 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>理由管理</h2>
|
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('reasons.title')}</h2>
|
||||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||||
添加预设理由
|
{t('reasons.addReason')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,31 +160,35 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="添加理由"
|
title={t('reasons.addTitle')}
|
||||||
open={visible}
|
open={visible}
|
||||||
onOk={handleAdd}
|
onOk={handleAdd}
|
||||||
onCancel={() => setVisible(false)}
|
onCancel={() => setVisible(false)}
|
||||||
okText="添加"
|
okText={t('reasons.addConfirm')}
|
||||||
cancelText="取消"
|
cancelText={t('common.cancel')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
|
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
|
||||||
<Form.Item label="分类" name="category" initialValue="其他">
|
<Form.Item
|
||||||
<Input placeholder="例如: 学习, 纪律" />
|
label={t('reasons.category')}
|
||||||
|
name="category"
|
||||||
|
initialValue={t('reasons.others')}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('reasons.categoryPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="理由内容"
|
label={t('reasons.content')}
|
||||||
name="content"
|
name="content"
|
||||||
rules={[{ required: true, message: '请输入理由内容' }]}
|
rules={[{ required: true, message: t('reasons.contentRequired') }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="请输入理由" />
|
<Input placeholder={t('reasons.contentPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="预设分值"
|
label={t('reasons.presetPoints')}
|
||||||
name="delta"
|
name="delta"
|
||||||
rules={[{ required: true, message: '请输入预设分值' }]}
|
rules={[{ required: true, message: t('reasons.pointsRequired') }]}
|
||||||
>
|
>
|
||||||
<InputNumber placeholder="例如: 2 或 -2" style={{ width: '100%' }} />
|
<InputNumber placeholder={t('reasons.pointsPlaceholder')} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Popconfirm
|
Popconfirm
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { UndoOutlined } from '@ant-design/icons'
|
import { UndoOutlined } from '@ant-design/icons'
|
||||||
import { match } from 'pinyin-pro'
|
import { match } from 'pinyin-pro'
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ interface scoreEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [students, setStudents] = useState<student[]>([])
|
const [students, setStudents] = useState<student[]>([])
|
||||||
const [reasons, setReasons] = useState<reason[]>([])
|
const [reasons, setReasons] = useState<reason[]>([])
|
||||||
const [events, setEvents] = useState<scoreEvent[]>([])
|
const [events, setEvents] = useState<scoreEvent[]>([])
|
||||||
@@ -133,7 +135,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const values = form.getFieldsValue(true) as any
|
const values = form.getFieldsValue(true) as any
|
||||||
@@ -142,7 +144,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
? values.student_name
|
? values.student_name
|
||||||
: [values.student_name]
|
: [values.student_name]
|
||||||
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
|
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
|
||||||
messageApi.warning('请填写完整信息')
|
messageApi.warning(t('score.pleaseEnterInfo'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
|
const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null
|
||||||
|
|
||||||
if (!hasDeltaInput && !selectedReason) {
|
if (!hasDeltaInput && !selectedReason) {
|
||||||
messageApi.warning('请填写分值或选择预设理由')
|
messageApi.warning(t('score.pleaseEnterPoints'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +180,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (successCount === studentNames.length) {
|
if (successCount === studentNames.length) {
|
||||||
messageApi.success(`已为 ${successCount} 名学生提交积分`)
|
messageApi.success(t('score.batchSuccess', { count: successCount }))
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
student_name: [],
|
student_name: [],
|
||||||
delta: undefined,
|
delta: undefined,
|
||||||
@@ -189,7 +191,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
emitDataUpdated('events')
|
emitDataUpdated('events')
|
||||||
} else {
|
} else {
|
||||||
messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
|
messageApi.warning(
|
||||||
|
t('score.batchPartial', { success: successCount, total: studentNames.length })
|
||||||
|
)
|
||||||
fetchData()
|
fetchData()
|
||||||
emitDataUpdated('events')
|
emitDataUpdated('events')
|
||||||
}
|
}
|
||||||
@@ -201,23 +205,23 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleUndo = async (uuid: string) => {
|
const handleUndo = async (uuid: string) => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await (window as any).api.deleteEvent(uuid)
|
const res = await (window as any).api.deleteEvent(uuid)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('已撤销操作')
|
messageApi.success(t('score.undoSuccess'))
|
||||||
fetchData()
|
fetchData()
|
||||||
emitDataUpdated('events')
|
emitDataUpdated('events')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '撤销失败')
|
messageApi.error(res.message || t('score.undoFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<scoreEvent> = [
|
const columns: ColumnsType<scoreEvent> = [
|
||||||
{ title: '学生', dataIndex: 'student_name', key: 'student_name', width: 100 },
|
{ title: t('score.student'), dataIndex: 'student_name', key: 'student_name', width: 100 },
|
||||||
{
|
{
|
||||||
title: '变动',
|
title: t('score.change'),
|
||||||
dataIndex: 'delta',
|
dataIndex: 'delta',
|
||||||
key: 'delta',
|
key: 'delta',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -225,25 +229,27 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
<Tag color={delta > 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ title: '理由', dataIndex: 'reason_content', key: 'reason_content', ellipsis: true },
|
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: t('score.reason'),
|
||||||
|
dataIndex: 'reason_content',
|
||||||
|
key: 'reason_content',
|
||||||
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('score.time'),
|
||||||
dataIndex: 'event_time',
|
dataIndex: 'event_time',
|
||||||
key: 'event_time',
|
key: 'event_time',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (time: string) => new Date(time).toLocaleString()
|
render: (time: string) => new Date(time).toLocaleString()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: t('common.operation'),
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Popconfirm
|
<Popconfirm title={t('score.undoConfirm')} onConfirm={() => handleUndo(row.uuid)}>
|
||||||
title="确定要撤销这条记录吗?学生积分将回滚。"
|
|
||||||
onConfirm={() => handleUndo(row.uuid)}
|
|
||||||
>
|
|
||||||
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
||||||
撤销
|
{t('score.undo')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)
|
)
|
||||||
@@ -253,38 +259,38 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}>积分管理</h2>
|
<h2 style={{ marginBottom: '24px', color: 'var(--ss-text-main)' }}>{t('score.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" initialValues={{ type: 'add' }}>
|
<Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||||
<Form.Item label="姓名" name="student_name">
|
<Form.Item label={t('score.student')} name="student_name">
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
showSearch
|
showSearch
|
||||||
placeholder="请选择或搜索学生"
|
placeholder={t('score.pleaseSelectStudent')}
|
||||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="分数">
|
<Form.Item label={t('score.points')}>
|
||||||
<Space>
|
<Space>
|
||||||
<Form.Item name="type" noStyle>
|
<Form.Item name="type" noStyle>
|
||||||
<Radio.Group optionType="button" buttonStyle="solid">
|
<Radio.Group optionType="button" buttonStyle="solid">
|
||||||
<Radio.Button value="add">加分</Radio.Button>
|
<Radio.Button value="add">{t('score.addPoints')}</Radio.Button>
|
||||||
<Radio.Button value="subtract">扣分</Radio.Button>
|
<Radio.Button value="subtract">{t('score.deductPoints')}</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="delta" noStyle>
|
<Form.Item name="delta" noStyle>
|
||||||
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
|
<InputNumber min={1} placeholder={t('score.points')} style={{ width: '120px' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="快捷理由" name="reason_id">
|
<Form.Item label={t('score.quickReason')} name="reason_id">
|
||||||
<Select
|
<Select
|
||||||
placeholder="选择预设理由"
|
placeholder={t('score.selectReason')}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
const id = Number(v)
|
const id = Number(v)
|
||||||
if (!Number.isFinite(id)) return
|
if (!Number.isFinite(id)) return
|
||||||
@@ -315,8 +321,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="理由内容" name="reason_content">
|
<Form.Item label={t('score.reasonContent')} name="reason_content">
|
||||||
<Input placeholder="手动输入或选择快捷理由" />
|
<Input placeholder={t('score.reasonPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -329,14 +335,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
loading={submitLoading}
|
loading={submitLoading}
|
||||||
style={{ width: '200px' }}
|
style={{ width: '200px' }}
|
||||||
>
|
>
|
||||||
确认提交
|
{t('score.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)' }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 16 }}>最近记录</div>
|
<div style={{ fontWeight: 600, marginBottom: 16 }}>{t('score.recentRecords')}</div>
|
||||||
<Table
|
<Table
|
||||||
dataSource={events}
|
dataSource={events}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd'
|
import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd'
|
||||||
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from '../i18n'
|
||||||
|
|
||||||
type permissionLevel = 'admin' | 'points' | 'view'
|
type permissionLevel = 'admin' | 'points' | 'view'
|
||||||
type appSettings = {
|
type appSettings = {
|
||||||
@@ -11,7 +13,9 @@ type appSettings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
|
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState('appearance')
|
const [activeTab, setActiveTab] = useState('appearance')
|
||||||
|
const [currentLanguage, setCurrentLanguage] = useState<AppLanguage>(getCurrentLanguage())
|
||||||
const [settings, setSettings] = useState<appSettings>({
|
const [settings, setSettings] = useState<appSettings>({
|
||||||
is_wizard_completed: false,
|
is_wizard_completed: false,
|
||||||
log_level: 'info',
|
log_level: 'info',
|
||||||
@@ -64,10 +68,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
<Tag
|
<Tag
|
||||||
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
|
color={permission === 'admin' ? 'success' : permission === 'points' ? 'warning' : 'default'}
|
||||||
>
|
>
|
||||||
{permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'}
|
{permission === 'admin'
|
||||||
|
? t('permissions.admin')
|
||||||
|
: permission === 'points'
|
||||||
|
? t('permissions.points')
|
||||||
|
: t('permissions.view')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
}, [permission])
|
}, [permission, t])
|
||||||
|
|
||||||
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
|
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
|
||||||
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
|
||||||
@@ -110,7 +118,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.queryLogs(200)
|
const res = await (window as any).api.queryLogs(200)
|
||||||
setLogsLoading(false)
|
setLogsLoading(false)
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
messageApi.error(res.message || '读取日志失败')
|
messageApi.error(res.message || t('settings.data.readLogsFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLogsText((res.data || []).join('\n'))
|
setLogsText((res.data || []).join('\n'))
|
||||||
@@ -121,12 +129,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.queryLogs(5000)
|
const res = await (window as any).api.queryLogs(5000)
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
messageApi.error(res.message || '读取日志失败')
|
messageApi.error(res.message || t('settings.data.readLogsFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const dateTime = new Date().toISOString().replace(/[:.]/g, '-')
|
const dateTime = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`)
|
downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`)
|
||||||
messageApi.success('日志已导出')
|
messageApi.success(t('settings.data.logsExported'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTextFile = (filename: string, text: string) => {
|
const downloadTextFile = (filename: string, text: string) => {
|
||||||
@@ -152,7 +160,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.exportDataJson()
|
const res = await (window as any).api.exportDataJson()
|
||||||
if (!res.success || !res.data) {
|
if (!res.success || !res.data) {
|
||||||
messageApi.error(res.message || '导出失败')
|
messageApi.error(res.message || t('settings.data.exportFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const blob = new Blob([res.data], { type: 'application/json;charset=utf-8' })
|
const blob = new Blob([res.data], { type: 'application/json;charset=utf-8' })
|
||||||
@@ -162,7 +170,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json`
|
a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
messageApi.success('导出成功')
|
messageApi.success(t('settings.data.exportSuccess'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const importJson = async (file: File) => {
|
const importJson = async (file: File) => {
|
||||||
@@ -170,10 +178,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const res = await (window as any).api.importDataJson(text)
|
const res = await (window as any).api.importDataJson(text)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('导入成功,正在刷新')
|
messageApi.success(t('settings.data.importSuccess'))
|
||||||
setTimeout(() => window.location.reload(), 300)
|
setTimeout(() => window.location.reload(), 300)
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '导入失败')
|
messageApi.error(res.message || t('settings.data.importFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,12 +196,15 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
setPointsPassword('')
|
setPointsPassword('')
|
||||||
await loadAll()
|
await loadAll()
|
||||||
if (res.data?.recoveryString) {
|
if (res.data?.recoveryString) {
|
||||||
showRecoveryDialog('找回字符串(请妥善保存)', res.data.recoveryString)
|
showRecoveryDialog(
|
||||||
|
t('settings.security.recoveryString') + ` (${t('recovery.hint')})`,
|
||||||
|
res.data.recoveryString
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
messageApi.success('密码已更新')
|
messageApi.success(t('settings.security.saved'))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '更新失败')
|
messageApi.error(res.message || t('settings.general.saveFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,23 +212,29 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.authGenerateRecovery()
|
const res = await (window as any).api.authGenerateRecovery()
|
||||||
if (!res.success || !res.data?.recoveryString) {
|
if (!res.success || !res.data?.recoveryString) {
|
||||||
messageApi.error(res.message || '生成失败')
|
messageApi.error(res.message || t('settings.security.generateFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await loadAll()
|
await loadAll()
|
||||||
showRecoveryDialog('新的找回字符串(请妥善保存)', res.data.recoveryString)
|
showRecoveryDialog(
|
||||||
|
t('settings.security.newRecoveryString', 'New recovery string (please save it)'),
|
||||||
|
res.data.recoveryString
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetByRecovery = async () => {
|
const resetByRecovery = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.authResetByRecovery(recoveryToReset)
|
const res = await (window as any).api.authResetByRecovery(recoveryToReset)
|
||||||
if (!res.success || !res.data?.recoveryString) {
|
if (!res.success || !res.data?.recoveryString) {
|
||||||
messageApi.error(res.message || '重置失败')
|
messageApi.error(res.message || t('settings.security.resetFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setRecoveryToReset('')
|
setRecoveryToReset('')
|
||||||
await loadAll()
|
await loadAll()
|
||||||
showRecoveryDialog('密码已清空,新的找回字符串', res.data.recoveryString)
|
showRecoveryDialog(
|
||||||
|
t('settings.security.passwordClearedNewRecovery', 'Password cleared, new recovery string'),
|
||||||
|
res.data.recoveryString
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAllPasswords = () => {
|
const clearAllPasswords = () => {
|
||||||
@@ -230,11 +247,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.authClearAll()
|
const res = await (window as any).api.authClearAll()
|
||||||
setClearLoading(false)
|
setClearLoading(false)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('已清空')
|
messageApi.success(t('settings.security.cleared'))
|
||||||
await loadAll()
|
await loadAll()
|
||||||
setClearDialogVisible(false)
|
setClearDialogVisible(false)
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '清空失败')
|
messageApi.error(res.message || t('settings.security.clearFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,19 +262,21 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const testPgConnection = async () => {
|
const testPgConnection = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!pgConnectionString) {
|
if (!pgConnectionString) {
|
||||||
messageApi.warning('请输入 PostgreSQL 连接字符串')
|
messageApi.warning(t('settings.database.enterConnectionString'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setPgTestLoading(true)
|
setPgTestLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await (window as any).api.dbTestConnection(pgConnectionString)
|
const res = await (window as any).api.dbTestConnection(pgConnectionString)
|
||||||
if (res.success && res.data?.success) {
|
if (res.success && res.data?.success) {
|
||||||
messageApi.success('连接测试成功')
|
messageApi.success(t('settings.database.connectionTestSuccess'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.data?.error || res.message || '连接测试失败')
|
messageApi.error(
|
||||||
|
res.data?.error || res.message || t('settings.database.connectionTestFailed')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '连接测试失败')
|
messageApi.error(e?.message || t('settings.database.connectionTestFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setPgTestLoading(false)
|
setPgTestLoading(false)
|
||||||
}
|
}
|
||||||
@@ -270,14 +289,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.dbSwitchConnection(pgConnectionString)
|
const res = await (window as any).api.dbSwitchConnection(pgConnectionString)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success(
|
messageApi.success(
|
||||||
`已切换到 ${res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'} 数据库`
|
t('settings.database.switchedTo', {
|
||||||
|
type: res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'
|
||||||
|
})
|
||||||
)
|
)
|
||||||
await loadAll()
|
await loadAll()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '切换失败')
|
messageApi.error(res.message || t('settings.database.switchFailed'))
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '切换失败')
|
messageApi.error(e?.message || t('settings.database.switchFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setPgSwitchLoading(false)
|
setPgSwitchLoading(false)
|
||||||
}
|
}
|
||||||
@@ -289,14 +310,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
try {
|
try {
|
||||||
const res = await (window as any).api.dbSwitchConnection('')
|
const res = await (window as any).api.dbSwitchConnection('')
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('已切换到本地 SQLite 数据库')
|
messageApi.success(t('settings.database.switchedToSQLite'))
|
||||||
setPgConnectionString('')
|
setPgConnectionString('')
|
||||||
await loadAll()
|
await loadAll()
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '切换失败')
|
messageApi.error(res.message || t('settings.database.switchFailed'))
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '切换失败')
|
messageApi.error(e?.message || t('settings.database.switchFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setPgSwitchLoading(false)
|
setPgSwitchLoading(false)
|
||||||
}
|
}
|
||||||
@@ -307,15 +328,40 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const tabItems = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
key: 'appearance',
|
key: 'appearance',
|
||||||
label: '外观',
|
label: t('settings.tabs.appearance'),
|
||||||
children: (
|
children: (
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||||
|
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||||
|
<Form.Item label={t('settings.language')}>
|
||||||
|
<Select
|
||||||
|
value={currentLanguage}
|
||||||
|
onChange={async (v: AppLanguage) => {
|
||||||
|
await changeLanguage(v)
|
||||||
|
setCurrentLanguage(v)
|
||||||
|
messageApi.success(t('common.success'))
|
||||||
|
}}
|
||||||
|
style={{ width: '320px' }}
|
||||||
|
options={languageOptions.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('settings.languageHint')}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
<ThemeQuickSettings />
|
<ThemeQuickSettings />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||||
<Form.Item label="界面缩放">
|
<Form.Item label={t('settings.interfaceZoom')}>
|
||||||
<Select
|
<Select
|
||||||
value={settings.window_zoom || '1.0'}
|
value={settings.window_zoom || '1.0'}
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
@@ -324,28 +370,28 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.setSetting('window_zoom', next)
|
const res = await (window as any).api.setSetting('window_zoom', next)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSettings((prev) => ({ ...prev, window_zoom: next }))
|
setSettings((prev) => ({ ...prev, window_zoom: next }))
|
||||||
messageApi.success('界面缩放已更新')
|
messageApi.success(t('settings.general.saved'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '更新失败')
|
messageApi.error(res.message || t('settings.general.saveFailed'))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ width: '320px' }}
|
style={{ width: '320px' }}
|
||||||
disabled={!canAdmin}
|
disabled={!canAdmin}
|
||||||
options={[
|
options={[
|
||||||
{ value: '0.7', label: '70% (较小)' },
|
{ value: '0.7', label: t('settings.zoomOptions.small70') },
|
||||||
{ value: '0.8', label: '80%' },
|
{ value: '0.8', label: '80%' },
|
||||||
{ value: '0.9', label: '90%' },
|
{ value: '0.9', label: '90%' },
|
||||||
{ value: '1.0', label: '100% (默认)' },
|
{ value: '1.0', label: t('settings.zoomOptions.default100') },
|
||||||
{ value: '1.1', label: '110%' },
|
{ value: '1.1', label: '110%' },
|
||||||
{ value: '1.2', label: '120%' },
|
{ value: '1.2', label: '120%' },
|
||||||
{ value: '1.3', label: '130%' },
|
{ value: '1.3', label: '130%' },
|
||||||
{ value: '1.5', label: '150% (较大)' }
|
{ value: '1.5', label: t('settings.zoomOptions.large150') }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||||
>
|
>
|
||||||
调节应用界面的整体大小。
|
{t('settings.zoomHint')}
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -354,7 +400,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'security',
|
key: 'security',
|
||||||
label: '安全',
|
label: t('settings.tabs.security'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -365,16 +411,25 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div style={{ fontWeight: 600 }}>密码保护系统</div>
|
<div style={{ fontWeight: 600 }}>{t('settings.security.title')}</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Tag color={securityStatus?.hasAdminPassword ? 'success' : 'default'}>
|
<Tag color={securityStatus?.hasAdminPassword ? 'success' : 'default'}>
|
||||||
管理密码 {securityStatus?.hasAdminPassword ? '已设置' : '未设置'}
|
{t('settings.security.adminPassword')}{' '}
|
||||||
|
{securityStatus?.hasAdminPassword
|
||||||
|
? t('settings.security.set')
|
||||||
|
: t('settings.security.notSet')}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color={securityStatus?.hasPointsPassword ? 'success' : 'default'}>
|
<Tag color={securityStatus?.hasPointsPassword ? 'success' : 'default'}>
|
||||||
积分密码 {securityStatus?.hasPointsPassword ? '已设置' : '未设置'}
|
{t('settings.security.pointsPassword')}{' '}
|
||||||
|
{securityStatus?.hasPointsPassword
|
||||||
|
? t('settings.security.set')
|
||||||
|
: t('settings.security.notSet')}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color={securityStatus?.hasRecoveryString ? 'success' : 'default'}>
|
<Tag color={securityStatus?.hasRecoveryString ? 'success' : 'default'}>
|
||||||
找回字符串 {securityStatus?.hasRecoveryString ? '已生成' : '未生成'}
|
{t('settings.security.recoveryString')}{' '}
|
||||||
|
{securityStatus?.hasRecoveryString
|
||||||
|
? t('settings.security.generated')
|
||||||
|
: t('settings.security.notGenerated')}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,43 +437,43 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||||
<Form.Item label="管理密码">
|
<Form.Item label={t('settings.security.adminPassword')}>
|
||||||
<Input
|
<Input
|
||||||
value={adminPassword}
|
value={adminPassword}
|
||||||
onChange={(e) => setAdminPassword(e.target.value)}
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
placeholder="输入6位数字(留空则不修改)"
|
placeholder={t('settings.security.adminPasswordPlaceholder')}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="积分密码">
|
<Form.Item label={t('settings.security.pointsPassword')}>
|
||||||
<Input
|
<Input
|
||||||
value={pointsPassword}
|
value={pointsPassword}
|
||||||
onChange={(e) => setPointsPassword(e.target.value)}
|
onChange={(e) => setPointsPassword(e.target.value)}
|
||||||
placeholder="输入6位数字(留空则不修改)"
|
placeholder={t('settings.security.pointsPasswordPlaceholder')}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="操作">
|
<Form.Item label={t('common.operation')}>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={savePasswords}
|
onClick={savePasswords}
|
||||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||||
>
|
>
|
||||||
保存密码
|
{t('settings.security.savePassword')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={generateRecovery}
|
onClick={generateRecovery}
|
||||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||||
>
|
>
|
||||||
生成找回字符串
|
{t('settings.security.generateRecovery')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button danger onClick={clearAllPasswords} disabled={!canAdmin}>
|
<Button danger onClick={clearAllPasswords} disabled={!canAdmin}>
|
||||||
清空所有密码
|
{t('settings.security.clearAllPasswords')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -426,20 +481,22 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>找回字符串重置</div>
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{t('settings.security.recoveryReset')}
|
||||||
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Input
|
<Input
|
||||||
value={recoveryToReset}
|
value={recoveryToReset}
|
||||||
onChange={(e) => setRecoveryToReset(e.target.value)}
|
onChange={(e) => setRecoveryToReset(e.target.value)}
|
||||||
placeholder="输入找回字符串"
|
placeholder={t('settings.security.recoveryPlaceholder')}
|
||||||
style={{ width: '420px' }}
|
style={{ width: '420px' }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={resetByRecovery}>
|
<Button type="primary" onClick={resetByRecovery}>
|
||||||
重置密码
|
{t('settings.security.resetPassword')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
重置会清空管理/积分密码,并生成新的找回字符串。
|
{t('settings.security.resetHint')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
@@ -447,7 +504,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'database',
|
key: 'database',
|
||||||
label: '数据库连接',
|
label: t('settings.database.title'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -457,15 +514,19 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>当前数据库状态</div>
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{t('settings.database.currentStatus')}
|
||||||
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Tag color={pgConnectionStatus.type === 'postgresql' ? 'blue' : 'green'}>
|
<Tag color={pgConnectionStatus.type === 'postgresql' ? 'blue' : 'green'}>
|
||||||
{pgConnectionStatus.type === 'postgresql'
|
{pgConnectionStatus.type === 'postgresql'
|
||||||
? 'PostgreSQL 远程数据库'
|
? t('settings.database.postgresqlRemote')
|
||||||
: 'SQLite 本地数据库'}
|
: t('settings.database.sqliteLocal')}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Tag color={pgConnectionStatus.connected ? 'success' : 'error'}>
|
<Tag color={pgConnectionStatus.connected ? 'success' : 'error'}>
|
||||||
{pgConnectionStatus.connected ? '已连接' : '未连接'}
|
{pgConnectionStatus.connected
|
||||||
|
? t('settings.database.connected')
|
||||||
|
: t('settings.database.disconnected')}
|
||||||
</Tag>
|
</Tag>
|
||||||
{pgConnectionStatus.error && (
|
{pgConnectionStatus.error && (
|
||||||
<span style={{ color: 'var(--ant-color-error, #ff4d4f)', fontSize: '12px' }}>
|
<span style={{ color: 'var(--ant-color-error, #ff4d4f)', fontSize: '12px' }}>
|
||||||
@@ -482,11 +543,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>PostgreSQL 远程连接</div>
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{t('settings.database.postgresqlConnection')}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||||
>
|
>
|
||||||
输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。
|
{t('settings.database.connectionHint')}
|
||||||
</div>
|
</div>
|
||||||
<Space.Compact style={{ width: '100%', marginBottom: '12px' }}>
|
<Space.Compact style={{ width: '100%', marginBottom: '12px' }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -501,13 +564,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
loading={pgTestLoading}
|
loading={pgTestLoading}
|
||||||
disabled={!canAdmin || !pgConnectionString}
|
disabled={!canAdmin || !pgConnectionString}
|
||||||
>
|
>
|
||||||
测试连接
|
{t('settings.database.testConnection')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
<div
|
<div
|
||||||
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||||
>
|
>
|
||||||
示例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require
|
{t('settings.database.connectionExample')}
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
@@ -516,25 +579,27 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
loading={pgSwitchLoading}
|
loading={pgSwitchLoading}
|
||||||
disabled={!canAdmin || !pgConnectionString}
|
disabled={!canAdmin || !pgConnectionString}
|
||||||
>
|
>
|
||||||
切换到 PostgreSQL
|
{t('settings.database.switchToPostgreSQL')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={switchToSQLite}
|
onClick={switchToSQLite}
|
||||||
loading={pgSwitchLoading}
|
loading={pgSwitchLoading}
|
||||||
disabled={!canAdmin || pgConnectionStatus.type === 'sqlite'}
|
disabled={!canAdmin || pgConnectionStatus.type === 'sqlite'}
|
||||||
>
|
>
|
||||||
切换到本地 SQLite
|
{t('settings.database.switchToSQLite')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>多端同步说明</div>
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{t('settings.database.syncDescription')}
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: '13px', color: 'var(--ss-text-secondary)', lineHeight: '1.8' }}>
|
<div style={{ fontSize: '13px', color: 'var(--ss-text-secondary)', lineHeight: '1.8' }}>
|
||||||
<p>• 使用 PostgreSQL 远程数据库可以实现多端数据同步。</p>
|
<p>{t('settings.database.syncPoint1')}</p>
|
||||||
<p>• 系统内置操作队列机制,确保多端同时操作时数据一致性。</p>
|
<p>{t('settings.database.syncPoint2')}</p>
|
||||||
<p>• 切换数据库后需要重启应用以生效。</p>
|
<p>{t('settings.database.syncPoint3')}</p>
|
||||||
<p>• 建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。</p>
|
<p>{t('settings.database.syncPoint4')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
@@ -542,11 +607,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'data',
|
key: 'data',
|
||||||
label: '数据管理',
|
label: t('settings.data.title'),
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
title="结算"
|
title={t('settings.data.settlement')}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--ss-card-bg)',
|
backgroundColor: 'var(--ss-card-bg)',
|
||||||
color: 'var(--ss-text-main)',
|
color: 'var(--ss-text-main)',
|
||||||
@@ -560,10 +625,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
loading={settleLoading}
|
loading={settleLoading}
|
||||||
onClick={confirmSettlement}
|
onClick={confirmSettlement}
|
||||||
>
|
>
|
||||||
结算并重新开始
|
{t('settings.data.settlementAndRestart')}
|
||||||
</Button>
|
</Button>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
将当前未结算记录划分为一个阶段,并将所有学生积分清零。
|
{t('settings.data.settlementHint')}
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -575,12 +640,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>导入 / 导出</div>
|
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||||
|
{t('settings.data.importExport')}
|
||||||
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={exportJson}>
|
<Button type="primary" onClick={exportJson}>
|
||||||
导出 JSON
|
{t('settings.data.exportJson')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => importInputRef.current?.click()}>
|
||||||
|
{t('settings.data.importJson')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => importInputRef.current?.click()}>导入 JSON</Button>
|
|
||||||
<input
|
<input
|
||||||
ref={importInputRef}
|
ref={importInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -594,16 +663,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。
|
{t('settings.data.importHint')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="日志"
|
title={t('settings.data.logs')}
|
||||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||||
>
|
>
|
||||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||||
<Form.Item label="日志级别">
|
<Form.Item label={t('settings.data.logLevel')}>
|
||||||
<Select
|
<Select
|
||||||
value={settings.log_level}
|
value={settings.log_level}
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
@@ -612,33 +681,33 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.setSetting('log_level', next)
|
const res = await (window as any).api.setSetting('log_level', next)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSettings((prev) => ({ ...prev, log_level: next }))
|
setSettings((prev) => ({ ...prev, log_level: next }))
|
||||||
messageApi.success('日志级别已更新')
|
messageApi.success(t('settings.general.saved'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '更新失败')
|
messageApi.error(res.message || '更新失败')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ width: '320px' }}
|
style={{ width: '320px' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'debug', label: 'DEBUG (调试)' },
|
{ value: 'debug', label: t('settings.data.logLevels.debug') },
|
||||||
{ value: 'info', label: 'INFO (信息)' },
|
{ value: 'info', label: t('settings.data.logLevels.info') },
|
||||||
{ value: 'warn', label: 'WARN (警告)' },
|
{ value: 'warn', label: t('settings.data.logLevels.warn') },
|
||||||
{ value: 'error', label: 'ERROR (错误)' }
|
{ value: 'error', label: t('settings.data.logLevels.error') }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="日志操作">
|
<Form.Item label={t('settings.data.logOperation')}>
|
||||||
<Space>
|
<Space>
|
||||||
<Button loading={logsLoading} onClick={showLogs}>
|
<Button loading={logsLoading} onClick={showLogs}>
|
||||||
查看日志
|
{t('settings.data.viewLogs')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={exportLogs}>导出日志</Button>
|
<Button onClick={exportLogs}>{t('settings.data.exportLogs')}</Button>
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.clearLogs()
|
const res = await (window as any).api.clearLogs()
|
||||||
if (res.success) messageApi.success('日志已清空')
|
if (res.success) messageApi.success(t('settings.data.logsCleared'))
|
||||||
else messageApi.error(res.message || '清空失败')
|
else messageApi.error(res.message || t('settings.data.clearFailed'))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
清空日志
|
清空日志
|
||||||
@@ -652,17 +721,17 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'url',
|
key: 'url',
|
||||||
label: 'URL 链接',
|
label: t('settings.url.title'),
|
||||||
children: (
|
children: (
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||||
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
||||||
URL 协议 (secscore://)
|
{t('settings.url.protocol')}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div
|
<div
|
||||||
style={{ marginBottom: '12px', fontSize: '13px', color: 'var(--ss-text-secondary)' }}
|
style={{ marginBottom: '12px', fontSize: '13px', color: 'var(--ss-text-secondary)' }}
|
||||||
>
|
>
|
||||||
可以通过 URL 链接唤起 SecScore 并执行操作,例如:
|
{t('settings.url.description')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -689,49 +758,53 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.registerUrlProtocol()
|
const res = await (window as any).api.registerUrlProtocol()
|
||||||
setUrlRegisterLoading(false)
|
setUrlRegisterLoading(false)
|
||||||
if (res && res.success) {
|
if (res && res.success) {
|
||||||
messageApi.success('URL 协议已注册')
|
messageApi.success(t('settings.url.registered'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res?.message || '注册失败')
|
messageApi.error(res?.message || t('settings.url.registerFailed'))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
注册 URL 协议
|
{t('settings.url.register')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
需要安装版 SecScore,开发模式下可能无效。
|
{t('settings.url.installerRequired')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'about',
|
key: 'about',
|
||||||
label: '关于',
|
label: t('settings.about.title'),
|
||||||
children: (
|
children: (
|
||||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||||
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>
|
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)', marginBottom: '16px' }}>
|
<div style={{ color: 'var(--ss-text-secondary)', marginBottom: '16px' }}>
|
||||||
教育积分管理
|
{t('settings.about.appName')}
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', rowGap: '10px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', rowGap: '10px' }}>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>版本</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('settings.about.version')}</div>
|
||||||
<div>v1.0.0</div>
|
<div>v1.0.0</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>版权</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('settings.about.copyright')}</div>
|
||||||
<div>{'SecScore遵循GPL3.0协议——' + 'CopyRight © 2025-' + currentYear + ' SECTL'}</div>
|
<div>{'CopyRight © 2025-' + currentYear + ' SECTL'}</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>Electron</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>Electron</div>
|
||||||
<div>{(window as any).electron?.process?.versions?.electron || '-'}</div>
|
<div>{(window as any).electron?.process?.versions?.electron || '-'}</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>Chromium</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>Chromium</div>
|
||||||
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
|
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>Node</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>Node</div>
|
||||||
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
|
<div>{(window as any).electron?.process?.versions?.node || '-'}</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>IPC 状态</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('settings.about.ipcStatus')}</div>
|
||||||
<div>
|
<div>
|
||||||
<Tag color={(window as any).api ? 'success' : 'error'}>
|
<Tag color={(window as any).api ? 'success' : 'error'}>
|
||||||
{(window as any).api ? '已连接' : '未连接 (Preload 失败)'}
|
{(window as any).api
|
||||||
|
? t('settings.about.ipcConnected')
|
||||||
|
: t('settings.about.ipcDisconnected')}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)' }}>环境</div>
|
<div style={{ color: 'var(--ss-text-secondary)' }}>
|
||||||
|
{t('settings.about.environment')}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Tag>{import.meta.env.DEV ? 'Development' : 'Production'}</Tag>
|
<Tag>{import.meta.env.DEV ? 'Development' : 'Production'}</Tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -743,7 +816,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
;(window as any).api?.toggleDevTools()
|
;(window as any).api?.toggleDevTools()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
切换开发者工具
|
{t('settings.about.toggleDevTools')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -769,7 +842,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: 0 }}>系统设置</h2>
|
<h2 style={{ margin: 0 }}>{t('settings.title')}</h2>
|
||||||
{permissionTag}
|
{permissionTag}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -781,7 +854,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
onCancel={() => setRecoveryDialogVisible(false)}
|
onCancel={() => setRecoveryDialogVisible(false)}
|
||||||
footer={
|
footer={
|
||||||
<Button type="primary" onClick={() => setRecoveryDialogVisible(false)}>
|
<Button type="primary" onClick={() => setRecoveryDialogVisible(false)}>
|
||||||
我已保存
|
{t('recovery.saved')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
width="70%"
|
width="70%"
|
||||||
@@ -811,16 +884,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
建议导出后离线保存,遗失将无法找回。
|
{t('recovery.exportHint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="系统日志 (最后200条)"
|
title={t('settings.data.systemLogs')}
|
||||||
open={logsDialogVisible}
|
open={logsDialogVisible}
|
||||||
onCancel={() => setLogsDialogVisible(false)}
|
onCancel={() => setLogsDialogVisible(false)}
|
||||||
footer={<Button onClick={() => setLogsDialogVisible(false)}>关闭</Button>}
|
footer={<Button onClick={() => setLogsDialogVisible(false)}>{t('common.close')}</Button>}
|
||||||
width="80%"
|
width="80%"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -836,12 +909,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
padding: '10px'
|
padding: '10px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logsText || '暂无日志'}
|
{logsText || t('settings.data.noLogs')}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="确认结算并重新开始?"
|
title={t('settings.data.confirmSettlement')}
|
||||||
open={settleDialogVisible}
|
open={settleDialogVisible}
|
||||||
onCancel={() => !settleLoading && setSettleDialogVisible(false)}
|
onCancel={() => !settleLoading && setSettleDialogVisible(false)}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
@@ -850,20 +923,20 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
|||||||
const res = await (window as any).api.createSettlement()
|
const res = await (window as any).api.createSettlement()
|
||||||
setSettleLoading(false)
|
setSettleLoading(false)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
messageApi.success('结算成功,已重新开始积分')
|
messageApi.success(t('settings.data.settlementSuccess'))
|
||||||
emitDataUpdated('all')
|
emitDataUpdated('all')
|
||||||
setSettleDialogVisible(false)
|
setSettleDialogVisible(false)
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '结算失败')
|
messageApi.error(res.message || t('settings.data.settlementFailed'))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
confirmLoading={settleLoading}
|
confirmLoading={settleLoading}
|
||||||
okText="结算"
|
okText={t('settings.data.settle')}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<div>将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。</div>
|
<div>{t('settings.data.settlementConfirm1')}</div>
|
||||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||||
学生名单不变;结算后的历史在“结算历史”页面查看。
|
{t('settings.data.settlementConfirm2')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Button, Card, message, Space, Table } from 'antd'
|
import { Button, Card, message, Space, Table } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface settlementSummary {
|
interface settlementSummary {
|
||||||
id: number
|
id: number
|
||||||
@@ -15,6 +16,7 @@ interface settlementLeaderboardRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SettlementHistory: React.FC = () => {
|
export const SettlementHistory: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [settlements, setSettlements] = useState<settlementSummary[]>([])
|
const [settlements, setSettlements] = useState<settlementSummary[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await (window as any).api.querySettlements()
|
const res = await (window as any).api.querySettlements()
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
messageApi.error(res.message || '查询失败')
|
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSettlements(res.data || [])
|
setSettlements(res.data || [])
|
||||||
@@ -71,7 +73,7 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
||||||
if (!res.success || !res.data) {
|
if (!res.success || !res.data) {
|
||||||
messageApi.error(res.message || '查询失败')
|
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSelectedSettlement(res.data.settlement)
|
setSelectedSettlement(res.data.settlement)
|
||||||
@@ -86,15 +88,15 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
|
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: '排名',
|
title: t('settlements.rank'),
|
||||||
key: 'rank',
|
key: 'rank',
|
||||||
width: 70,
|
width: 70,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, __, index) => <span style={{ fontWeight: 'bold' }}>{index + 1}</span>
|
render: (_, __, index) => <span style={{ fontWeight: 'bold' }}>{index + 1}</span>
|
||||||
},
|
},
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 160 },
|
{ title: t('settlements.name'), dataIndex: 'name', key: 'name', width: 160 },
|
||||||
{
|
{
|
||||||
title: '阶段积分',
|
title: t('settlements.phaseScore'),
|
||||||
dataIndex: 'score',
|
dataIndex: 'score',
|
||||||
key: 'score',
|
key: 'score',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -116,9 +118,11 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
setRows([])
|
setRows([])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
返回
|
{t('settlements.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<div style={{ color: 'var(--ss-text-main)', fontWeight: 700 }}>结算排行榜</div>
|
<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)}
|
{formatRange(selectedSettlement)}
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +146,9 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}>结算历史</h2>
|
<h2 style={{ marginBottom: '16px', color: 'var(--ss-text-main)' }}>
|
||||||
|
{t('settlements.title')}
|
||||||
|
</h2>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -155,7 +161,9 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
key={s.id}
|
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' }}>阶段 #{s.id}</div>
|
<div style={{ fontWeight: 700, marginBottom: '8px' }}>
|
||||||
|
{t('settlements.phase', { id: s.id })}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}
|
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}
|
||||||
>
|
>
|
||||||
@@ -163,17 +171,19 @@ export const SettlementHistory: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={() => openSettlement(s.id)}>
|
<Button type="primary" onClick={() => openSettlement(s.id)}>
|
||||||
查看排行榜
|
{t('settlements.viewLeaderboard')}
|
||||||
</Button>
|
</Button>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||||
记录数: {s.event_count}
|
{t('settlements.recordCount', { count: s.event_count })}
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!loading && settlements.length === 0 && (
|
{!loading && settlements.length === 0 && (
|
||||||
<div style={{ marginTop: '16px', color: 'var(--ss-text-secondary)' }}>暂无结算记录</div>
|
<div style={{ marginTop: '16px', color: 'var(--ss-text-secondary)' }}>
|
||||||
|
{t('settlements.noSettlements')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UploadOutlined
|
UploadOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import appLogo from '../assets/logoHD.svg'
|
import appLogo from '../assets/logoHD.svg'
|
||||||
|
|
||||||
const { Sider } = Layout
|
const { Sider } = Layout
|
||||||
@@ -28,6 +29,7 @@ interface DbStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
|
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 [syncLoading, setSyncLoading] = useState(false)
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
@@ -78,17 +80,17 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
|
|
||||||
const statusRes = await (window as any).api.dbGetStatus()
|
const statusRes = await (window as any).api.dbGetStatus()
|
||||||
if (!statusRes.success || !statusRes.data) {
|
if (!statusRes.success || !statusRes.data) {
|
||||||
messageApi.error('获取数据库状态失败')
|
messageApi.error(t('sidebar.getDbStatusFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusRes.data.type !== 'postgresql') {
|
if (statusRes.data.type !== 'postgresql') {
|
||||||
messageApi.error('当前不是远程数据库模式,请重启应用后重试')
|
messageApi.error(t('sidebar.notRemoteMode'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statusRes.data.connected) {
|
if (!statusRes.data.connected) {
|
||||||
messageApi.error('数据库未连接')
|
messageApi.error(t('sidebar.dbNotConnected'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +98,12 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
try {
|
try {
|
||||||
const res = await (window as any).api.dbSync()
|
const res = await (window as any).api.dbSync()
|
||||||
if (res.success && res.data?.success) {
|
if (res.success && res.data?.success) {
|
||||||
messageApi.success('同步成功')
|
messageApi.success(t('sidebar.syncSuccess'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.data?.message || res.message || '同步失败')
|
messageApi.error(res.data?.message || res.message || t('sidebar.syncFailed'))
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '同步失败')
|
messageApi.error(e?.message || t('sidebar.syncFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setForceSyncLoading(false)
|
setForceSyncLoading(false)
|
||||||
}
|
}
|
||||||
@@ -111,44 +113,44 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
{
|
{
|
||||||
key: 'home',
|
key: 'home',
|
||||||
icon: <HomeOutlined />,
|
icon: <HomeOutlined />,
|
||||||
label: '主页'
|
label: t('sidebar.home')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'students',
|
key: 'students',
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
label: '学生管理',
|
label: t('sidebar.students'),
|
||||||
disabled: permission !== 'admin'
|
disabled: permission !== 'admin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'score',
|
key: 'score',
|
||||||
icon: <HistoryOutlined />,
|
icon: <HistoryOutlined />,
|
||||||
label: '积分管理'
|
label: t('sidebar.score')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'auto-score',
|
key: 'auto-score',
|
||||||
icon: <SyncOutlined />,
|
icon: <SyncOutlined />,
|
||||||
label: '自动加分'
|
label: t('sidebar.autoScore')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'leaderboard',
|
key: 'leaderboard',
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
label: '排行榜'
|
label: t('sidebar.leaderboard')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'settlements',
|
key: 'settlements',
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined />,
|
||||||
label: '结算历史'
|
label: t('sidebar.settlements')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'reasons',
|
key: 'reasons',
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
label: '理由管理',
|
label: t('sidebar.reasons'),
|
||||||
disabled: permission !== 'admin'
|
disabled: permission !== 'admin'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
label: '系统设置',
|
label: t('sidebar.settings'),
|
||||||
disabled: permission !== 'admin'
|
disabled: permission !== 'admin'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -198,7 +200,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
marginTop: '4px'
|
marginTop: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
教育积分管理
|
{t('settings.about.appName')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,13 +233,15 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
|
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
|
||||||
<span style={{ fontSize: '12px', fontWeight: 500 }}>远程数据库</span>
|
<span style={{ fontSize: '12px', fontWeight: 500 }}>{t('sidebar.remoteDb')}</span>
|
||||||
</Space>
|
</Space>
|
||||||
<Tag
|
<Tag
|
||||||
color={dbStatus.connected ? 'success' : 'error'}
|
color={dbStatus.connected ? 'success' : 'error'}
|
||||||
style={{ margin: 0, fontSize: '10px' }}
|
style={{ margin: 0, fontSize: '10px' }}
|
||||||
>
|
>
|
||||||
{dbStatus.connected ? '已连接' : '未连接'}
|
{dbStatus.connected
|
||||||
|
? t('settings.database.connected')
|
||||||
|
: t('settings.database.disconnected')}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -254,7 +258,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
color: 'var(--ss-text-secondary)'
|
color: 'var(--ss-text-secondary)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
刷新状态
|
{t('sidebar.refreshStatus')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -270,7 +274,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
|||||||
padding: '0 8px'
|
padding: '0 8px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
立即同步
|
{t('sidebar.syncNow')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||||
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
|
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { TagEditorDialog } from './TagEditorDialog'
|
import { TagEditorDialog } from './TagEditorDialog'
|
||||||
|
|
||||||
const createXlsxWorker = () => {
|
const createXlsxWorker = () => {
|
||||||
@@ -18,6 +19,7 @@ interface student {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [data, setData] = useState<student[]>([])
|
const [data, setData] = useState<student[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
@@ -105,31 +107,31 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
if (!values.name) {
|
if (!values.name) {
|
||||||
messageApi.warning('请输入姓名')
|
messageApi.warning(t('students.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = values.name.trim()
|
const name = values.name.trim()
|
||||||
if (data.some((s) => s.name === name)) {
|
if (data.some((s) => s.name === name)) {
|
||||||
messageApi.warning('学生姓名已存在')
|
messageApi.warning(t('students.nameExists'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await (window as any).api.createStudent({ ...values, name })
|
const res = await (window as any).api.createStudent({ ...values, name })
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('添加成功')
|
messageApi.success(t('students.addSuccess'))
|
||||||
setVisible(false)
|
setVisible(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
fetchStudents()
|
fetchStudents()
|
||||||
emitDataUpdated('students')
|
emitDataUpdated('students')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '添加失败')
|
messageApi.error(res.message || t('students.addFailed'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try {
|
try {
|
||||||
@@ -149,22 +151,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await (window as any).api.deleteStudent(id)
|
const res = await (window as any).api.deleteStudent(id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
messageApi.success('删除成功')
|
messageApi.success(t('students.deleteSuccess'))
|
||||||
fetchStudents()
|
fetchStudents()
|
||||||
emitDataUpdated('students')
|
emitDataUpdated('students')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '删除失败')
|
messageApi.error(res.message || t('students.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenTagEditor = (student: student) => {
|
const handleOpenTagEditor = (student: student) => {
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEditingStudent(student)
|
setEditingStudent(student)
|
||||||
@@ -183,12 +185,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
fetchStudents()
|
fetchStudents()
|
||||||
emitDataUpdated('students')
|
emitDataUpdated('students')
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = res?.message || '保存标签失败'
|
const errorMsg = res?.message || t('students.tagSaveFailed')
|
||||||
messageApi.error(errorMsg)
|
messageApi.error(errorMsg)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
messageApi.error(`保存标签失败: ${errorMsg}`)
|
messageApi.error(`${t('students.tagSaveFailed')}: ${errorMsg}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +207,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
const parseXlsxFile = async (file: File) => {
|
const parseXlsxFile = async (file: File) => {
|
||||||
if (!xlsxWorkerRef.current) {
|
if (!xlsxWorkerRef.current) {
|
||||||
messageApi.error('Worker 未初始化')
|
messageApi.error(t('students.workerNotReady'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +229,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setImportVisible(false)
|
setImportVisible(false)
|
||||||
setXlsxLoading(false)
|
setXlsxLoading(false)
|
||||||
} else if (event.data.type === 'error') {
|
} else if (event.data.type === 'error') {
|
||||||
messageApi.error(event.data.error || '解析 xlsx 失败')
|
messageApi.error(event.data.error || t('students.parseXlsxFailed'))
|
||||||
setXlsxLoading(false)
|
setXlsxLoading(false)
|
||||||
}
|
}
|
||||||
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
|
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
|
||||||
@@ -235,7 +237,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
|
|
||||||
xlsxWorkerRef.current.addEventListener('message', handleMessage)
|
xlsxWorkerRef.current.addEventListener('message', handleMessage)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '解析 xlsx 失败')
|
messageApi.error(e?.message || t('students.parseXlsxFailed'))
|
||||||
setXlsxLoading(false)
|
setXlsxLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,7 +299,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const extractNamesFromAoa = (aoa: any[][], colIdx: number) => {
|
const extractNamesFromAoa = (aoa: any[][], colIdx: number) => {
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const banned = new Set(['姓名', 'name', '名字'])
|
const banned = new Set([t('students.name').toLowerCase(), 'name', t('students.name')])
|
||||||
for (const row of aoa) {
|
for (const row of aoa) {
|
||||||
const raw = row?.[colIdx]
|
const raw = row?.[colIdx]
|
||||||
const name = String(raw ?? '').trim()
|
const name = String(raw ?? '').trim()
|
||||||
@@ -313,17 +315,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
const handleConfirmXlsxImport = async () => {
|
const handleConfirmXlsxImport = async () => {
|
||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
messageApi.error('当前为只读权限')
|
messageApi.error(t('common.readOnly'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (xlsxSelectedCol == null) {
|
if (xlsxSelectedCol == null) {
|
||||||
messageApi.warning('请先点击选择"姓名列"')
|
messageApi.warning(t('students.selectNameColFirst'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
|
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
|
||||||
if (!names.length) {
|
if (!names.length) {
|
||||||
messageApi.error('所选列未解析到可导入的姓名')
|
messageApi.error(t('students.noNamesFound'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,12 +333,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
try {
|
try {
|
||||||
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
||||||
if (!res?.success) {
|
if (!res?.success) {
|
||||||
messageApi.error(res?.message || '导入失败')
|
messageApi.error(res?.message || t('students.importFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const inserted = Number(res?.data?.inserted ?? 0)
|
const inserted = Number(res?.data?.inserted ?? 0)
|
||||||
const skipped = Number(res?.data?.skipped ?? 0)
|
const skipped = Number(res?.data?.skipped ?? 0)
|
||||||
messageApi.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
|
messageApi.success(t('students.importComplete', { inserted, skipped }))
|
||||||
setXlsxVisible(false)
|
setXlsxVisible(false)
|
||||||
setXlsxAoa([])
|
setXlsxAoa([])
|
||||||
setXlsxFileName('')
|
setXlsxFileName('')
|
||||||
@@ -349,9 +351,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<student> = [
|
const columns: ColumnsType<student> = [
|
||||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
{ title: t('students.name'), dataIndex: 'name', key: 'name', width: 100 },
|
||||||
{
|
{
|
||||||
title: '当前积分',
|
title: t('students.currentScore'),
|
||||||
dataIndex: 'score',
|
dataIndex: 'score',
|
||||||
key: 'score',
|
key: 'score',
|
||||||
width: 160,
|
width: 160,
|
||||||
@@ -373,14 +375,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '标签',
|
title: t('students.tags'),
|
||||||
dataIndex: 'tags',
|
dataIndex: 'tags',
|
||||||
key: 'tags',
|
key: 'tags',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (tags: string[] = []) => (
|
render: (tags: string[] = []) => (
|
||||||
<Space>
|
<Space>
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<span style={{ color: 'var(--ss-text-secondary)' }}>无标签</span>
|
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('students.noTags')}</span>
|
||||||
) : (
|
) : (
|
||||||
tags.slice(0, 3).map((tag) => (
|
tags.slice(0, 3).map((tag) => (
|
||||||
<Tag key={tag} color="blue">
|
<Tag key={tag} color="blue">
|
||||||
@@ -393,16 +395,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: t('common.operation'),
|
||||||
key: 'operation',
|
key: 'operation',
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
|
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
|
||||||
编辑标签
|
{t('students.editTags')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
|
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
|
||||||
删除
|
{t('common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
@@ -415,13 +417,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>学生管理</h2>
|
<h2 style={{ margin: 0, color: 'var(--ss-text-main)' }}>{t('students.title')}</h2>
|
||||||
<Space>
|
<Space>
|
||||||
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
||||||
导入名单
|
{t('students.importList')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||||
添加学生
|
{t('students.addStudent')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,34 +446,38 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
setPageSize(size)
|
setPageSize(size)
|
||||||
}}
|
}}
|
||||||
showSizeChanger
|
showSizeChanger
|
||||||
showTotal={(total) => `共 ${total} 条`}
|
showTotal={(total) => t('common.total', { count: total })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="添加学生"
|
title={t('students.addTitle')}
|
||||||
open={visible}
|
open={visible}
|
||||||
onOk={handleAdd}
|
onOk={handleAdd}
|
||||||
onCancel={() => setVisible(false)}
|
onCancel={() => setVisible(false)}
|
||||||
okText="添加"
|
okText={t('students.addConfirm')}
|
||||||
cancelText="取消"
|
cancelText={t('common.cancel')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
|
<Form.Item
|
||||||
<Input placeholder="请输入学生姓名" />
|
label={t('students.name')}
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: t('students.nameRequired') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('students.namePlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="导入名单"
|
title={t('students.importTitle')}
|
||||||
open={importVisible}
|
open={importVisible}
|
||||||
onCancel={() => setImportVisible(false)}
|
onCancel={() => setImportVisible(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Button
|
<Button
|
||||||
loading={xlsxLoading}
|
loading={xlsxLoading}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -479,7 +485,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
xlsxInputRef.current?.click()
|
xlsxInputRef.current?.click()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
通过 xlsx 导入
|
{t('students.importByXlsx')}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={xlsxInputRef}
|
ref={xlsxInputRef}
|
||||||
@@ -496,21 +502,25 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="xlsx 预览与导入"
|
title={t('students.xlsxPreview')}
|
||||||
open={xlsxVisible}
|
open={xlsxVisible}
|
||||||
onCancel={() => setXlsxVisible(false)}
|
onCancel={() => setXlsxVisible(false)}
|
||||||
onOk={handleConfirmXlsxImport}
|
onOk={handleConfirmXlsxImport}
|
||||||
okText="导入"
|
okText={t('students.importConfirm')}
|
||||||
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
|
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
|
||||||
width="80%"
|
width="80%"
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||||
<div>文件:{xlsxFileName || '-'}</div>
|
|
||||||
<div>
|
<div>
|
||||||
点击表头选择姓名列:{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
|
{t('students.file')}
|
||||||
|
{xlsxFileName || '-'}
|
||||||
</div>
|
</div>
|
||||||
<div>预览前 50 行</div>
|
<div>
|
||||||
|
{t('students.selectNameCol')}
|
||||||
|
{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
|
||||||
|
</div>
|
||||||
|
<div>{t('students.previewRows')}</div>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
dataSource={xlsxPreviewRows}
|
dataSource={xlsxPreviewRows}
|
||||||
@@ -531,7 +541,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
|||||||
}}
|
}}
|
||||||
onConfirm={handleSaveTags}
|
onConfirm={handleSaveTags}
|
||||||
initialTagIds={editingStudent?.tagIds || []}
|
initialTagIds={editingStudent?.tagIds || []}
|
||||||
title={`编辑标签 - ${editingStudent?.name || ''}`}
|
title={t('students.editTagTitle', { name: editingStudent?.name || '' })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Modal, Input, Button, Space, Tag, message } from 'antd'
|
import { Modal, Input, Button, Space, Tag, message } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface TagItem {
|
interface TagItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -19,8 +20,9 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
initialTagIds = [],
|
initialTagIds = [],
|
||||||
title = '编辑标签'
|
title
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [allTags, setAllTags] = useState<TagItem[]>([])
|
const [allTags, setAllTags] = useState<TagItem[]>([])
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
|
||||||
@@ -35,7 +37,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch tags:', e)
|
console.error('Failed to fetch tags:', e)
|
||||||
messageApi.error('获取标签列表失败')
|
messageApi.error(t('tags.fetchFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
|
|
||||||
if (trimmed.length > 30) {
|
if (trimmed.length > 30) {
|
||||||
messageApi.error('标签名称不能超过 30 个字符')
|
messageApi.error(t('tags.nameTooLong'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +74,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
|
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
|
||||||
setInputValue('')
|
setInputValue('')
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '添加标签失败')
|
messageApi.error(res.message || t('tags.addFailed'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create tag:', e)
|
console.error('Failed to create tag:', e)
|
||||||
messageApi.error('添加标签失败')
|
messageApi.error(t('tags.addFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +104,13 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
newSet.delete(tagId)
|
newSet.delete(tagId)
|
||||||
return newSet
|
return newSet
|
||||||
})
|
})
|
||||||
messageApi.success('标签删除成功')
|
messageApi.success(t('tags.deleteSuccess'))
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(res.message || '删除标签失败')
|
messageApi.error(res.message || t('tags.deleteFailed'))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete tag:', e)
|
console.error('Failed to delete tag:', e)
|
||||||
messageApi.error('删除标签失败')
|
messageApi.error(t('tags.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +124,12 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={title}
|
title={title || t('tags.editTitle')}
|
||||||
open={visible}
|
open={visible}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
onOk={handleConfirm}
|
onOk={handleConfirm}
|
||||||
okText="保存"
|
okText={t('common.save')}
|
||||||
cancelText="取消"
|
cancelText={t('common.cancel')}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={500}
|
width={500}
|
||||||
>
|
>
|
||||||
@@ -135,20 +137,20 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="输入标签名称,按 Enter 添加"
|
placeholder={t('tags.inputPlaceholder')}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onPressEnter={handleAddTag}
|
onPressEnter={handleAddTag}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
|
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
|
||||||
添加
|
{t('common.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||||
已选标签(点击取消)
|
{t('tags.selectedTags')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -159,7 +161,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedTags.length === 0 ? (
|
{selectedTags.length === 0 ? (
|
||||||
<span style={{ color: 'var(--ss-text-secondary)' }}>未选择标签</span>
|
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noTagsSelected')}</span>
|
||||||
) : (
|
) : (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) => (
|
||||||
@@ -180,7 +182,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||||
可选标签(点击选择)
|
{t('tags.availableTags')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -191,7 +193,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{availableTags.length === 0 ? (
|
{availableTags.length === 0 ? (
|
||||||
<span style={{ color: 'var(--ss-text-secondary)' }}>无可用标签</span>
|
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noAvailableTags')}</span>
|
||||||
) : (
|
) : (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{availableTags.map((tag) => (
|
{availableTags.map((tag) => (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd'
|
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd'
|
||||||
import type { Color } from 'antd/es/color-picker'
|
import type { Color } from 'antd/es/color-picker'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
import type { themeConfig } from '../../../preload/types'
|
import type { themeConfig } from '../../../preload/types'
|
||||||
|
|
||||||
@@ -22,31 +23,31 @@ const presetPrimaryColors = [
|
|||||||
|
|
||||||
const presetGradients: {
|
const presetGradients: {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
labelKey: string
|
||||||
light: string
|
light: string
|
||||||
dark: string
|
dark: string
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
id: 'blue',
|
id: 'blue',
|
||||||
label: '清爽蓝',
|
labelKey: 'blue',
|
||||||
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
||||||
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
|
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'pink',
|
id: 'pink',
|
||||||
label: '柔和粉',
|
labelKey: 'pink',
|
||||||
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
||||||
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
|
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cyan',
|
id: 'cyan',
|
||||||
label: '青蓝',
|
labelKey: 'cyan',
|
||||||
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
|
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
|
||||||
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
|
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'purple',
|
id: 'purple',
|
||||||
label: '紫韵',
|
labelKey: 'purple',
|
||||||
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)',
|
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)',
|
||||||
dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)'
|
dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)'
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ const buildGradient = (a: string, b: string, dir: 'v' | 'h' | 'd'): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
||||||
const [messageApi, holder] = message.useMessage()
|
const [messageApi, holder] = message.useMessage()
|
||||||
|
|
||||||
@@ -92,12 +94,12 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
const editable =
|
const editable =
|
||||||
base.id === 'custom-default' || base.id.startsWith('custom-')
|
base.id === 'custom-default' || base.id.startsWith('custom-')
|
||||||
? base
|
? base
|
||||||
: { ...base, id: 'custom-default', name: '我的主题' }
|
: { ...base, id: 'custom-default', name: t('theme.myTheme') }
|
||||||
setWorkingTheme(editable)
|
setWorkingTheme(editable)
|
||||||
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
||||||
}, [currentTheme])
|
}, [currentTheme])
|
||||||
|
|
||||||
// 实时预览主题变化
|
// Real-time theme preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workingTheme) return
|
if (!workingTheme) return
|
||||||
applyTheme(workingTheme)
|
applyTheme(workingTheme)
|
||||||
@@ -116,14 +118,22 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchThemeConfig = (type: 'tdesign' | 'custom', key: string, value: string) => {
|
const saveThemeToDb = async (theme: themeConfig) => {
|
||||||
setWorkingTheme((prev) => {
|
if (!(window as any).api) return false
|
||||||
if (!prev) return prev
|
try {
|
||||||
const next = deepClone(prev)
|
const exists = themes.some((t) => t.id === theme.id)
|
||||||
next.config = next.config || ({} as any)
|
if (!exists) {
|
||||||
next.config[type] = { ...(next.config[type] || {}), [key]: value }
|
const res = await (window as any).api.saveTheme(theme)
|
||||||
return next
|
if (!res?.success) return false
|
||||||
})
|
}
|
||||||
|
if (currentTheme?.id !== theme.id) {
|
||||||
|
await setTheme(theme.id)
|
||||||
|
}
|
||||||
|
const res = await (window as any).api.saveTheme(theme)
|
||||||
|
return res?.success
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -135,30 +145,50 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
if (!ok) throw new Error('保存失败')
|
if (!ok) throw new Error('保存失败')
|
||||||
const res = await (window as any).api.saveTheme(workingTheme)
|
const res = await (window as any).api.saveTheme(workingTheme)
|
||||||
if (!res?.success) throw new Error(res?.message || '保存失败')
|
if (!res?.success) throw new Error(res?.message || '保存失败')
|
||||||
messageApi.success('已保存')
|
messageApi.success(t('theme.saved'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
messageApi.error(e?.message || '保存失败')
|
messageApi.error(e?.message || t('theme.saveFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPrimary = (hex: string) => {
|
const setPrimary = async (hex: string) => {
|
||||||
setPrimaryInput(hex)
|
setPrimaryInput(hex)
|
||||||
patchThemeConfig('tdesign', 'brandColor', hex)
|
setWorkingTheme((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = deepClone(prev)
|
||||||
|
next.config = next.config || ({} as any)
|
||||||
|
next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex }
|
||||||
|
saveThemeToDb(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setGradientPreset = (value: string) => {
|
const setGradientPreset = async (value: string) => {
|
||||||
patchThemeConfig('custom', '--ss-bg-color', value)
|
setWorkingTheme((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = deepClone(prev)
|
||||||
|
next.config = next.config || ({} as any)
|
||||||
|
next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': value }
|
||||||
|
saveThemeToDb(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setGradientFromPickers = () => {
|
const setGradientFromPickers = async () => {
|
||||||
const g = buildGradient(g1, g2, gradientDir)
|
const g = buildGradient(g1, g2, gradientDir)
|
||||||
patchThemeConfig('custom', '--ss-bg-color', g)
|
setWorkingTheme((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const next = deepClone(prev)
|
||||||
|
next.config = next.config || ({} as any)
|
||||||
|
next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': g }
|
||||||
|
saveThemeToDb(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setMode = async (mode: 'light' | 'dark') => {
|
const setMode = async (mode: 'light' | 'dark') => {
|
||||||
// 根据模式切换到对应的内置主题
|
|
||||||
const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
|
const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
|
||||||
await setTheme(targetThemeId)
|
await setTheme(targetThemeId)
|
||||||
}
|
}
|
||||||
@@ -170,28 +200,28 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
{holder}
|
{holder}
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Typography.Text strong>颜色与背景</Typography.Text>
|
<Typography.Text strong>{t('theme.colorAndBackground')}</Typography.Text>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" loading={saving} onClick={save}>
|
<Button type="primary" loading={saving} onClick={save}>
|
||||||
保存
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<Typography.Text>模式</Typography.Text>
|
<Typography.Text>{t('theme.mode')}</Typography.Text>
|
||||||
<Segmented
|
<Segmented
|
||||||
value={workingTheme.mode}
|
value={workingTheme.mode}
|
||||||
onChange={(v) => setMode(v as any)}
|
onChange={(v) => setMode(v as any)}
|
||||||
options={[
|
options={[
|
||||||
{ label: '浅色', value: 'light' },
|
{ label: t('settings.lightMode'), value: 'light' },
|
||||||
{ label: '深色', value: 'dark' }
|
{ label: t('settings.darkMode'), value: 'dark' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<Typography.Text>主色</Typography.Text>
|
<Typography.Text>{t('settings.primaryColor')}</Typography.Text>
|
||||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Input
|
<Input
|
||||||
value={primaryInput}
|
value={primaryInput}
|
||||||
@@ -228,7 +258,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<Typography.Text>背景渐变</Typography.Text>
|
<Typography.Text>{t('settings.backgroundGradient')}</Typography.Text>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -264,7 +294,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ss-text-secondary)' }}>
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ss-text-secondary)' }}>
|
||||||
{g.label}
|
{t(`theme.gradientLabels.${g.labelKey}`)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -276,15 +306,15 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
|||||||
value={gradientDir}
|
value={gradientDir}
|
||||||
onChange={(v) => setGradientDir(v as any)}
|
onChange={(v) => setGradientDir(v as any)}
|
||||||
options={[
|
options={[
|
||||||
{ label: '纵向', value: 'v' },
|
{ label: t('theme.gradientDirections.vertical'), value: 'v' },
|
||||||
{ label: '横向', value: 'h' },
|
{ label: t('theme.gradientDirections.horizontal'), value: 'h' },
|
||||||
{ label: '对角', value: 'd' }
|
{ label: t('theme.gradientDirections.diagonal'), value: 'd' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
|
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
|
||||||
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
|
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
|
||||||
<Button onClick={setGradientFromPickers}>生成</Button>
|
<Button onClick={setGradientFromPickers}>{t('theme.generate')}</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Modal, message, Typography } from 'antd'
|
import { Modal, message, Typography } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
||||||
|
|
||||||
interface wizardProps {
|
interface wizardProps {
|
||||||
@@ -8,6 +9,7 @@ interface wizardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [messageApi, contextHolder] = message.useMessage()
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
@@ -18,10 +20,10 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
|||||||
const res = await (window as any).api.setSetting('is_wizard_completed', true)
|
const res = await (window as any).api.setSetting('is_wizard_completed', true)
|
||||||
if (!res?.success) throw new Error(res?.message || 'failed')
|
if (!res?.success) throw new Error(res?.message || 'failed')
|
||||||
|
|
||||||
messageApi.success('配置完成!')
|
messageApi.success(t('wizard.configComplete'))
|
||||||
onComplete()
|
onComplete()
|
||||||
} catch {
|
} catch {
|
||||||
messageApi.error('配置保存失败')
|
messageApi.error(t('wizard.configFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -29,12 +31,12 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="欢迎使用 SecScore 积分管理"
|
title={t('wizard.welcomeTitle')}
|
||||||
open={visible}
|
open={visible}
|
||||||
onOk={handleFinish}
|
onOk={handleFinish}
|
||||||
onCancel={() => {}}
|
onCancel={() => {}}
|
||||||
confirmLoading={loading}
|
confirmLoading={loading}
|
||||||
okText="开启积分之旅"
|
okText={t('wizard.startJourney')}
|
||||||
cancelButtonProps={{ style: { display: 'none' } }}
|
cancelButtonProps={{ style: { display: 'none' } }}
|
||||||
closable={false}
|
closable={false}
|
||||||
mask={{ closable: false }}
|
mask={{ closable: false }}
|
||||||
@@ -43,7 +45,7 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
|||||||
>
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
|
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
|
||||||
感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。
|
{t('wizard.welcomeDesc')}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<ThemeQuickSettings compact />
|
<ThemeQuickSettings compact />
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import i18n from 'i18next'
|
||||||
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
import zhCN from './locales/zh-CN.json'
|
||||||
|
import enUS from './locales/en-US.json'
|
||||||
|
|
||||||
|
export const defaultNS = 'translation'
|
||||||
|
export const resources = {
|
||||||
|
'zh-CN': { translation: zhCN },
|
||||||
|
'en-US': { translation: enUS }
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type AppLanguage = 'zh-CN' | 'en-US'
|
||||||
|
|
||||||
|
export const languageNames: Record<AppLanguage, string> = {
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
'en-US': 'English'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageOptions: { value: AppLanguage; label: string }[] = [
|
||||||
|
{ value: 'zh-CN', label: '简体中文' },
|
||||||
|
{ value: 'en-US', label: 'English' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const savedLanguage = (() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('secscore_language')
|
||||||
|
if (stored && (stored === 'zh-CN' || stored === 'en-US')) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
void 0
|
||||||
|
}
|
||||||
|
const browserLang = navigator.language || (navigator as any).userLanguage
|
||||||
|
if (browserLang?.startsWith('zh')) return 'zh-CN'
|
||||||
|
return 'en-US'
|
||||||
|
})()
|
||||||
|
|
||||||
|
i18n.use(initReactI18next).init({
|
||||||
|
resources,
|
||||||
|
lng: savedLanguage,
|
||||||
|
fallbackLng: 'zh-CN',
|
||||||
|
defaultNS,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false
|
||||||
|
},
|
||||||
|
react: {
|
||||||
|
useSuspense: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changeLanguage = async (lang: AppLanguage): Promise<void> => {
|
||||||
|
await i18n.changeLanguage(lang)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('secscore_language', lang)
|
||||||
|
} catch {
|
||||||
|
void 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentLanguage = (): AppLanguage => {
|
||||||
|
return (i18n.language as AppLanguage) || 'zh-CN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export { i18n }
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"next": "Next",
|
||||||
|
"prev": "Previous",
|
||||||
|
"finish": "Finish",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"add": "Add",
|
||||||
|
"search": "Search",
|
||||||
|
"clear": "Clear",
|
||||||
|
"close": "Close",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"submit": "Submit",
|
||||||
|
"reset": "Reset",
|
||||||
|
"import": "Import",
|
||||||
|
"export": "Export",
|
||||||
|
"create": "Create",
|
||||||
|
"update": "Update",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"view": "View",
|
||||||
|
"name": "Name",
|
||||||
|
"status": "Status",
|
||||||
|
"operation": "Operation",
|
||||||
|
"description": "Description",
|
||||||
|
"total": "Total {{count}} items",
|
||||||
|
"noData": "No data",
|
||||||
|
"pleaseSelect": "Please select",
|
||||||
|
"pleaseEnter": "Please enter",
|
||||||
|
"readOnly": "Read-only mode",
|
||||||
|
"none": "None"
|
||||||
|
},
|
||||||
|
"oobe": {
|
||||||
|
"title": "Welcome to SecScore",
|
||||||
|
"subtitle": "Education Points Management Expert",
|
||||||
|
"step": "Step {{current}}/{{total}}",
|
||||||
|
"skip": "Skip",
|
||||||
|
"steps": {
|
||||||
|
"language": {
|
||||||
|
"title": "Select Language",
|
||||||
|
"description": "Choose your preferred interface language"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Set Theme",
|
||||||
|
"description": "Choose your preferred interface appearance",
|
||||||
|
"mode": "Mode",
|
||||||
|
"lightMode": "Light",
|
||||||
|
"darkMode": "Dark",
|
||||||
|
"primaryColor": "Primary Color",
|
||||||
|
"backgroundGradient": "Background Gradient"
|
||||||
|
},
|
||||||
|
"students": {
|
||||||
|
"title": "Import Student List",
|
||||||
|
"description": "Add student list to start points management",
|
||||||
|
"import": "Import List",
|
||||||
|
"manual": "Add Manually",
|
||||||
|
"importHint": "Supports Excel (.xlsx) or JSON files",
|
||||||
|
"manualHint": "Add student names one by one",
|
||||||
|
"dragDrop": "Drag and drop files here, or click to select",
|
||||||
|
"supportedFormats": "Supports .xlsx, .json formats",
|
||||||
|
"studentName": "Student Name",
|
||||||
|
"addStudent": "Add Student",
|
||||||
|
"noStudents": "No students yet, please add or import",
|
||||||
|
"studentCount": "{{count}} students added",
|
||||||
|
"studentExists": "Student already exists",
|
||||||
|
"importSuccess": "Successfully imported {{count}} students",
|
||||||
|
"noNewStudents": "No new students to import",
|
||||||
|
"parseFailed": "File parsing failed",
|
||||||
|
"unsupportedFormat": "Unsupported file format"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"title": "Set Reasons",
|
||||||
|
"description": "Add common point change reasons",
|
||||||
|
"addReason": "Add Reason",
|
||||||
|
"reasonName": "Reason Name",
|
||||||
|
"points": "Points",
|
||||||
|
"positive": "Add Points",
|
||||||
|
"negative": "Deduct Points",
|
||||||
|
"noReasons": "No reasons yet, please add common reasons",
|
||||||
|
"reasonCount": "{{count}} reasons added",
|
||||||
|
"reasonExists": "Reason already exists"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "Ready to Go",
|
||||||
|
"description": "Everything is ready. Let's start using SecScore!",
|
||||||
|
"features": {
|
||||||
|
"score": "Easily manage student points",
|
||||||
|
"history": "Complete point change history",
|
||||||
|
"settlement": "Support settlement and archiving"
|
||||||
|
},
|
||||||
|
"startButton": "Get Started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"tabs": {
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"security": "Security",
|
||||||
|
"database": "Database Connection",
|
||||||
|
"dataManagement": "Data Management",
|
||||||
|
"urlProtocol": "URL Protocol",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"language": "Language",
|
||||||
|
"languageHint": "Select interface display language",
|
||||||
|
"theme": "Theme",
|
||||||
|
"themeMode": "Mode",
|
||||||
|
"lightMode": "Light",
|
||||||
|
"darkMode": "Dark",
|
||||||
|
"primaryColor": "Primary Color",
|
||||||
|
"backgroundGradient": "Background Gradient",
|
||||||
|
"interfaceZoom": "Interface Zoom",
|
||||||
|
"zoomHint": "Adjust the overall size of the application interface",
|
||||||
|
"zoomUpdated": "Interface zoom updated",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"security": {
|
||||||
|
"title": "Password Protection System",
|
||||||
|
"adminPassword": "Admin Password",
|
||||||
|
"pointsPassword": "Points Password",
|
||||||
|
"recoveryString": "Recovery String",
|
||||||
|
"set": "Set",
|
||||||
|
"notSet": "Not Set",
|
||||||
|
"generated": "Generated",
|
||||||
|
"notGenerated": "Not Generated",
|
||||||
|
"enterPassword": "Enter 6-digit number (leave empty to skip)",
|
||||||
|
"adminPasswordPlaceholder": "Enter 6-digit admin password (leave empty to skip)",
|
||||||
|
"pointsPasswordPlaceholder": "Enter 6-digit points password (leave empty to skip)",
|
||||||
|
"recoveryPlaceholder": "Enter recovery string",
|
||||||
|
"savePassword": "Save Password",
|
||||||
|
"savePasswords": "Save Passwords",
|
||||||
|
"generateRecovery": "Generate Recovery String",
|
||||||
|
"clearAllPasswords": "Clear All Passwords",
|
||||||
|
"recoveryReset": "Recovery String Reset",
|
||||||
|
"enterRecovery": "Enter recovery string",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"resetHint": "Reset will clear admin/points passwords and generate a new recovery string.",
|
||||||
|
"confirmClear": "Confirm clearing all passwords?",
|
||||||
|
"clearHint": "After clearing, protection will be disabled (no password defaults to admin permission).",
|
||||||
|
"saved": "Password updated",
|
||||||
|
"saveFailed": "Update failed",
|
||||||
|
"generateFailed": "Generate failed",
|
||||||
|
"resetFailed": "Reset failed",
|
||||||
|
"cleared": "Cleared",
|
||||||
|
"clearFailed": "Clear failed",
|
||||||
|
"passwordClearedNewRecovery": "Password cleared, new recovery string",
|
||||||
|
"newRecoveryString": "New recovery string (please save it)"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"title": "Database Connection",
|
||||||
|
"currentStatus": "Current Database Status",
|
||||||
|
"sqliteLocal": "SQLite Local Database",
|
||||||
|
"postgresqlRemote": "PostgreSQL Remote Database",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"postgresqlConnection": "PostgreSQL Remote Connection",
|
||||||
|
"connectionHint": "Enter PostgreSQL connection string to connect to remote database for multi-device sync.",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"switchToPostgreSQL": "Switch to PostgreSQL",
|
||||||
|
"switchToSQLite": "Switch to Local SQLite",
|
||||||
|
"connectionTestSuccess": "Connection test successful",
|
||||||
|
"connectionTestFailed": "Connection test failed",
|
||||||
|
"switchedTo": "Switched to {{type}} database",
|
||||||
|
"switchedToSQLite": "Switched to SQLite local database",
|
||||||
|
"switchFailed": "Switch failed",
|
||||||
|
"syncDescription": "Multi-device Sync Info",
|
||||||
|
"syncPoint1": "Using PostgreSQL remote database enables multi-device data synchronization.",
|
||||||
|
"syncPoint2": "Built-in operation queue mechanism ensures data consistency during simultaneous operations.",
|
||||||
|
"syncPoint3": "Application restart required after switching database.",
|
||||||
|
"syncPoint4": "Recommended cloud database services: Neon, Supabase, AWS RDS, etc.",
|
||||||
|
"enterConnectionString": "Please enter PostgreSQL connection string",
|
||||||
|
"connectionExample": "Example: postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "Data Management",
|
||||||
|
"settlement": "Settlement",
|
||||||
|
"settlementAndRestart": "Settle and Restart",
|
||||||
|
"settlementHint": "Mark current records as a phase and reset all student points to zero.",
|
||||||
|
"importExport": "Import / Export",
|
||||||
|
"exportJson": "Export JSON",
|
||||||
|
"importJson": "Import JSON",
|
||||||
|
"importHint": "Import will overwrite existing students/reasons/records/settings (security settings excluded).",
|
||||||
|
"logs": "Logs",
|
||||||
|
"logLevel": "Log Level",
|
||||||
|
"logOperation": "Log Operation",
|
||||||
|
"viewLogs": "View Logs",
|
||||||
|
"exportLogs": "Export Logs",
|
||||||
|
"clearLogs": "Clear Logs",
|
||||||
|
"logsCleared": "Logs cleared",
|
||||||
|
"systemLogs": "System Logs (Last 200)",
|
||||||
|
"noLogs": "No logs",
|
||||||
|
"logLevelUpdated": "Log level updated",
|
||||||
|
"readLogsFailed": "Failed to read logs",
|
||||||
|
"logsExported": "Logs exported",
|
||||||
|
"exportFailed": "Export failed",
|
||||||
|
"exportSuccess": "Export successful",
|
||||||
|
"importSuccess": "Import successful, refreshing",
|
||||||
|
"importFailed": "Import failed",
|
||||||
|
"clearFailed": "Clear failed",
|
||||||
|
"confirmSettlement": "Confirm settlement and restart?",
|
||||||
|
"settlementConfirm1": "This will archive current unsettled records as a phase and reset all student points to zero.",
|
||||||
|
"settlementConfirm2": "Student list remains unchanged; settlement history available in 'Settlements' page.",
|
||||||
|
"settlementSuccess": "Settlement successful, points restarted",
|
||||||
|
"settlementFailed": "Settlement failed",
|
||||||
|
"settle": "Settle",
|
||||||
|
"logLevels": {
|
||||||
|
"debug": "DEBUG",
|
||||||
|
"info": "INFO",
|
||||||
|
"warn": "WARN",
|
||||||
|
"error": "ERROR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "URL Protocol (secscore://)",
|
||||||
|
"protocol": "URL Protocol",
|
||||||
|
"description": "Can invoke SecScore via URL to perform actions, for example:",
|
||||||
|
"register": "Register URL Protocol",
|
||||||
|
"registered": "URL protocol registered",
|
||||||
|
"registerFailed": "Registration failed",
|
||||||
|
"installerRequired": "Requires installed SecScore, may not work in development mode."
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"appName": "Education Points Management",
|
||||||
|
"version": "Version",
|
||||||
|
"copyright": "Copyright",
|
||||||
|
"ipcStatus": "IPC Status",
|
||||||
|
"ipcConnected": "Connected",
|
||||||
|
"ipcDisconnected": "Disconnected (Preload failed)",
|
||||||
|
"environment": "Environment",
|
||||||
|
"toggleDevTools": "Toggle Developer Tools",
|
||||||
|
"license": "SecScore is licensed under GPL3.0",
|
||||||
|
"copyRightText": "CopyRight © 2025-{{year}} SECTL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"home": "Home",
|
||||||
|
"students": "Students",
|
||||||
|
"score": "Score",
|
||||||
|
"leaderboard": "Leaderboard",
|
||||||
|
"settlements": "Settlements",
|
||||||
|
"reasons": "Reasons",
|
||||||
|
"autoScore": "Auto Score",
|
||||||
|
"settings": "Settings",
|
||||||
|
"remoteDb": "Remote Database",
|
||||||
|
"refreshStatus": "Refresh Status",
|
||||||
|
"syncNow": "Sync Now",
|
||||||
|
"syncSuccess": "Sync successful",
|
||||||
|
"syncFailed": "Sync failed",
|
||||||
|
"getDbStatusFailed": "Failed to get database status",
|
||||||
|
"notRemoteMode": "Not in remote database mode, please restart application",
|
||||||
|
"dbNotConnected": "Database not connected"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"unlock": "Unlock Permission",
|
||||||
|
"unlockHint": "Enter 6-digit password: Admin password = full access, Points password = points operations only.",
|
||||||
|
"unlockButton": "Unlock",
|
||||||
|
"unlocked": "Permission unlocked",
|
||||||
|
"logout": "Switched to read-only",
|
||||||
|
"passwordPlaceholder": "e.g. 123456",
|
||||||
|
"passwordError": "Incorrect password",
|
||||||
|
"enterPassword": "Enter Password",
|
||||||
|
"lock": "Lock"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"en-US": "English"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"colorAndBackground": "Color & Background",
|
||||||
|
"gradientLabels": {
|
||||||
|
"blue": "Fresh Blue",
|
||||||
|
"pink": "Soft Pink",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"purple": "Purple"
|
||||||
|
},
|
||||||
|
"gradientDirections": {
|
||||||
|
"vertical": "Vertical",
|
||||||
|
"horizontal": "Horizontal",
|
||||||
|
"diagonal": "Diagonal"
|
||||||
|
},
|
||||||
|
"generate": "Generate",
|
||||||
|
"saved": "Saved",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"mode": "Mode",
|
||||||
|
"myTheme": "My Theme"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"points": "Points",
|
||||||
|
"view": "Read-only"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Student Points Home",
|
||||||
|
"subtitle": "{{count}} students total, click card to operate",
|
||||||
|
"searchPlaceholder": "Search name/pinyin...",
|
||||||
|
"sortBy": {
|
||||||
|
"alphabet": "Name Sort",
|
||||||
|
"surname": "Surname Group",
|
||||||
|
"score": "Points Rank"
|
||||||
|
},
|
||||||
|
"noStudents": "No student data, please add in Student Management",
|
||||||
|
"noMatch": "No matching students found",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"points": "pts",
|
||||||
|
"currentScore": "Current Points",
|
||||||
|
"quickOptions": "Quick Options",
|
||||||
|
"adjustPoints": "Adjust Points",
|
||||||
|
"customPoints": "Custom Points",
|
||||||
|
"customPointsHint": "Enter any value in the input box",
|
||||||
|
"reason": "Reason",
|
||||||
|
"reasonPlaceholder": "Enter reason for points change (optional)",
|
||||||
|
"preview": "Change Preview",
|
||||||
|
"noReason": "(No reason)",
|
||||||
|
"category": {
|
||||||
|
"others": "Others"
|
||||||
|
},
|
||||||
|
"scoreAdded": "Added {{points}} points for {{name}}",
|
||||||
|
"scoreDeducted": "Deducted {{points}} points for {{name}}",
|
||||||
|
"submitFailed": "Submit failed",
|
||||||
|
"pleaseSelectPoints": "Please select or enter points",
|
||||||
|
"reasonDefault": "{{action}} {{points}} points",
|
||||||
|
"studentCount": "{{count}} students",
|
||||||
|
"operationTitle": "Points Operation: {{name}}",
|
||||||
|
"submitOperation": "Submit Operation",
|
||||||
|
"addPoints": "Add Points",
|
||||||
|
"deductPoints": "Deduct Points",
|
||||||
|
"pointsChange": "Points Change",
|
||||||
|
"reasonLabel": "Reason: "
|
||||||
|
},
|
||||||
|
"students": {
|
||||||
|
"title": "Student Management",
|
||||||
|
"importList": "Import List",
|
||||||
|
"addStudent": "Add Student",
|
||||||
|
"name": "Name",
|
||||||
|
"currentScore": "Current Points",
|
||||||
|
"tags": "Tags",
|
||||||
|
"noTags": "No tags",
|
||||||
|
"editTags": "Edit Tags",
|
||||||
|
"deleteConfirm": "Confirm delete this student?",
|
||||||
|
"addTitle": "Add Student",
|
||||||
|
"addConfirm": "Add",
|
||||||
|
"namePlaceholder": "Enter student name",
|
||||||
|
"nameRequired": "Please enter name",
|
||||||
|
"nameExists": "Student name already exists",
|
||||||
|
"addSuccess": "Added successfully",
|
||||||
|
"addFailed": "Add failed",
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"tagSaveSuccess": "Tags saved successfully",
|
||||||
|
"tagSaveFailed": "Failed to save tags",
|
||||||
|
"importTitle": "Import List",
|
||||||
|
"importByXlsx": "Import via xlsx",
|
||||||
|
"xlsxPreview": "XLSX Preview & Import",
|
||||||
|
"file": "File",
|
||||||
|
"selectNameCol": "Click header to select name column",
|
||||||
|
"previewRows": "Preview first 50 rows",
|
||||||
|
"importConfirm": "Import",
|
||||||
|
"importComplete": "Import complete: {{inserted}} added, {{skipped}} skipped",
|
||||||
|
"workerNotReady": "Worker not initialized",
|
||||||
|
"parseXlsxFailed": "Failed to parse xlsx",
|
||||||
|
"selectNameColFirst": "Please select \"Name Column\" first",
|
||||||
|
"noNamesFound": "No importable names found in selected column",
|
||||||
|
"importFailed": "Import failed",
|
||||||
|
"editTagTitle": "Edit Tags - {{name}}"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"title": "Points Management",
|
||||||
|
"student": "Student",
|
||||||
|
"change": "Change",
|
||||||
|
"reason": "Reason",
|
||||||
|
"time": "Time",
|
||||||
|
"undoConfirm": "Confirm undo this record? Student points will be rolled back.",
|
||||||
|
"undo": "Undo",
|
||||||
|
"undoSuccess": "Operation undone",
|
||||||
|
"undoFailed": "Undo failed",
|
||||||
|
"recentRecords": "Recent Records",
|
||||||
|
"pleaseSelectStudent": "Please select or search student",
|
||||||
|
"points": "Points",
|
||||||
|
"addPoints": "Add",
|
||||||
|
"deductPoints": "Deduct",
|
||||||
|
"quickReason": "Quick Reason",
|
||||||
|
"selectReason": "Select preset reason",
|
||||||
|
"reasonContent": "Reason Content",
|
||||||
|
"reasonPlaceholder": "Manual input or select quick reason",
|
||||||
|
"submit": "Confirm Submit",
|
||||||
|
"pleaseEnterInfo": "Please fill in complete information",
|
||||||
|
"pleaseEnterPoints": "Please enter points or select preset reason",
|
||||||
|
"batchSuccess": "Submitted points for {{count}} students",
|
||||||
|
"batchPartial": "Successfully submitted for {{success}}/{{total}} students"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"title": "Reason Management",
|
||||||
|
"addReason": "Add Preset Reason",
|
||||||
|
"category": "Category",
|
||||||
|
"content": "Reason Content",
|
||||||
|
"presetPoints": "Preset Points",
|
||||||
|
"deleteConfirm": "Confirm delete this reason?",
|
||||||
|
"addTitle": "Add Reason",
|
||||||
|
"addConfirm": "Add",
|
||||||
|
"categoryPlaceholder": "e.g. Study, Discipline",
|
||||||
|
"contentPlaceholder": "Enter reason",
|
||||||
|
"pointsPlaceholder": "e.g. 2 or -2",
|
||||||
|
"contentRequired": "Please enter reason content",
|
||||||
|
"pointsRequired": "Please enter preset points",
|
||||||
|
"reasonExists": "Same reason already exists in this category",
|
||||||
|
"addSuccess": "Added successfully",
|
||||||
|
"addFailed": "Add failed",
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"others": "Others"
|
||||||
|
},
|
||||||
|
"leaderboard": {
|
||||||
|
"title": "Points Leaderboard",
|
||||||
|
"rank": "Rank",
|
||||||
|
"name": "Name",
|
||||||
|
"totalScore": "Total Points",
|
||||||
|
"todayChange": "Today's Change",
|
||||||
|
"weekChange": "This Week's Change",
|
||||||
|
"monthChange": "This Month's Change",
|
||||||
|
"viewHistory": "View",
|
||||||
|
"exportXlsx": "Export XLSX",
|
||||||
|
"exportSuccess": "Export successful",
|
||||||
|
"queryFailed": "Query failed",
|
||||||
|
"historyTitle": "{{name}} - Operation History",
|
||||||
|
"close": "Close",
|
||||||
|
"today": "Today",
|
||||||
|
"week": "This Week",
|
||||||
|
"month": "This Month",
|
||||||
|
"operationRecord": "Operation Record",
|
||||||
|
"change": "Change"
|
||||||
|
},
|
||||||
|
"settlements": {
|
||||||
|
"title": "Settlement History",
|
||||||
|
"phase": "Phase #{{id}}",
|
||||||
|
"viewLeaderboard": "View Leaderboard",
|
||||||
|
"recordCount": "Records: {{count}}",
|
||||||
|
"noRecords": "No settlement records",
|
||||||
|
"noSettlements": "No settlement records",
|
||||||
|
"back": "Back",
|
||||||
|
"leaderboardTitle": "Settlement Leaderboard",
|
||||||
|
"phaseScore": "Phase Points",
|
||||||
|
"queryFailed": "Query failed",
|
||||||
|
"rank": "Rank",
|
||||||
|
"name": "Name"
|
||||||
|
},
|
||||||
|
"autoScore": {
|
||||||
|
"title": "Auto Score Management",
|
||||||
|
"name": "Automation Name",
|
||||||
|
"namePlaceholder": "e.g. Daily Check-in Points",
|
||||||
|
"nameRequired": "Please enter automation name",
|
||||||
|
"triggers": "Triggers",
|
||||||
|
"actions": "Actions",
|
||||||
|
"applicableStudents": "Applicable Students",
|
||||||
|
"allStudents": "All Students",
|
||||||
|
"lastExecuted": "Last Executed",
|
||||||
|
"notExecuted": "Not executed",
|
||||||
|
"invalidTime": "Invalid time",
|
||||||
|
"addTrigger": "Add Rule",
|
||||||
|
"addAction": "Add Action",
|
||||||
|
"whenTriggered": "When the following rules trigger",
|
||||||
|
"triggeredActions": "Actions triggered when rules are met",
|
||||||
|
"addAutomation": "Add Automation",
|
||||||
|
"updateAutomation": "Update Automation",
|
||||||
|
"cancelEdit": "Cancel Edit",
|
||||||
|
"resetForm": "Reset Form",
|
||||||
|
"deleteConfirm": "Confirm delete this automation?",
|
||||||
|
"adminRequired": "Admin permission required to view auto score automation",
|
||||||
|
"adminCreateRequired": "Admin permission required to create or update auto score automation",
|
||||||
|
"adminDeleteRequired": "Admin permission required to delete auto score automation",
|
||||||
|
"adminToggleRequired": "Admin permission required to enable/disable auto score automation",
|
||||||
|
"fetchFailed": "Failed to get automation",
|
||||||
|
"triggerRequired": "Please add at least one trigger",
|
||||||
|
"actionRequired": "Please add at least one action",
|
||||||
|
"createSuccess": "Automation created successfully",
|
||||||
|
"updateSuccess": "Automation updated successfully",
|
||||||
|
"createFailed": "Failed to create automation",
|
||||||
|
"updateFailed": "Failed to update automation",
|
||||||
|
"deleteSuccess": "Automation deleted successfully",
|
||||||
|
"deleteFailed": "Failed to delete automation",
|
||||||
|
"enabled": "Automation enabled",
|
||||||
|
"disabled": "Automation disabled",
|
||||||
|
"enableFailed": "Failed to enable automation",
|
||||||
|
"disableFailed": "Failed to disable automation",
|
||||||
|
"noTriggerAvailable": "No trigger types available, please check configuration",
|
||||||
|
"noActionAvailable": "No action types available, please check configuration",
|
||||||
|
"sort": "Sort",
|
||||||
|
"status": "Status",
|
||||||
|
"triggerCount": "{{count}} triggers",
|
||||||
|
"actionCount": "{{count}} actions",
|
||||||
|
"studentCount": "{{count}} students",
|
||||||
|
"studentPlaceholder": "Please select or search students (leave empty for all students)"
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"studentTag": {
|
||||||
|
"label": "When student matches tag",
|
||||||
|
"description": "Trigger automation when student matches specific tag",
|
||||||
|
"placeholder": "Please select tag",
|
||||||
|
"required": "Please enter tag name"
|
||||||
|
},
|
||||||
|
"randomTime": {
|
||||||
|
"label": "Random time trigger",
|
||||||
|
"description": "Trigger automation randomly within specified time range",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"hour": "h",
|
||||||
|
"minHourPlaceholder": "Min hour",
|
||||||
|
"maxHourPlaceholder": "Max hour",
|
||||||
|
"required": "Please enter valid time range (minutes)"
|
||||||
|
},
|
||||||
|
"intervalTime": {
|
||||||
|
"label": "Interval time trigger",
|
||||||
|
"description": "Trigger automation when interval time is reached",
|
||||||
|
"days": "Days",
|
||||||
|
"minutes": "Minutes",
|
||||||
|
"placeholderMinutes": "Enter interval (minutes)",
|
||||||
|
"placeholderDays": "Enter interval (days)",
|
||||||
|
"required": "Please enter valid time interval (minutes)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"sendNotification": {
|
||||||
|
"label": "Send Notification",
|
||||||
|
"description": "Send notification to student",
|
||||||
|
"placeholder": "Enter notification content"
|
||||||
|
},
|
||||||
|
"addTag": {
|
||||||
|
"label": "Add Tag",
|
||||||
|
"description": "Add tag to student",
|
||||||
|
"placeholder": "Enter tag"
|
||||||
|
},
|
||||||
|
"addScore": {
|
||||||
|
"label": "Add Score",
|
||||||
|
"description": "Add score to student",
|
||||||
|
"pointsPlaceholder": "Enter points",
|
||||||
|
"reasonPlaceholder": "Enter reason"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"welcomeTitle": "Welcome to SecScore Points Management",
|
||||||
|
"welcomeDesc": "Thanks for choosing SecScore. Before starting, please spend a minute to complete basic configuration.",
|
||||||
|
"configComplete": "Configuration complete!",
|
||||||
|
"configFailed": "Configuration save failed",
|
||||||
|
"startJourney": "Start Points Journey"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"editTitle": "Edit Tags",
|
||||||
|
"inputPlaceholder": "Enter tag name, press Enter to add",
|
||||||
|
"selectedTags": "Selected Tags (click to remove)",
|
||||||
|
"noTagsSelected": "No tags selected",
|
||||||
|
"availableTags": "Available Tags (click to select)",
|
||||||
|
"noAvailableTags": "No available tags",
|
||||||
|
"fetchFailed": "Failed to fetch tags",
|
||||||
|
"nameTooLong": "Tag name cannot exceed 30 characters",
|
||||||
|
"addFailed": "Failed to add tag",
|
||||||
|
"deleteSuccess": "Tag deleted successfully",
|
||||||
|
"deleteFailed": "Failed to delete tag"
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"title": "SecScore Recovery String",
|
||||||
|
"saved": "I have saved",
|
||||||
|
"export": "Export Text File",
|
||||||
|
"hint": "Recommended to save offline after export, cannot be recovered if lost."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"next": "下一步",
|
||||||
|
"prev": "上一步",
|
||||||
|
"finish": "完成",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"confirm": "确认",
|
||||||
|
"success": "成功",
|
||||||
|
"error": "错误",
|
||||||
|
"delete": "删除",
|
||||||
|
"edit": "编辑",
|
||||||
|
"add": "添加",
|
||||||
|
"search": "搜索",
|
||||||
|
"clear": "清空",
|
||||||
|
"close": "关闭",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"submit": "提交",
|
||||||
|
"reset": "重置",
|
||||||
|
"import": "导入",
|
||||||
|
"export": "导出",
|
||||||
|
"create": "创建",
|
||||||
|
"update": "更新",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"view": "查看",
|
||||||
|
"name": "姓名",
|
||||||
|
"status": "状态",
|
||||||
|
"operation": "操作",
|
||||||
|
"description": "描述",
|
||||||
|
"total": "共 {{count}} 条",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"pleaseSelect": "请选择",
|
||||||
|
"pleaseEnter": "请输入",
|
||||||
|
"readOnly": "当前为只读权限",
|
||||||
|
"none": "无"
|
||||||
|
},
|
||||||
|
"oobe": {
|
||||||
|
"title": "欢迎使用 SecScore",
|
||||||
|
"subtitle": "教育积分管理专家",
|
||||||
|
"step": "第 {{current}}/{{total}} 步",
|
||||||
|
"skip": "跳过",
|
||||||
|
"steps": {
|
||||||
|
"language": {
|
||||||
|
"title": "选择语言",
|
||||||
|
"description": "请选择您偏好的界面语言"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "设置主题",
|
||||||
|
"description": "选择您喜欢的界面外观",
|
||||||
|
"mode": "模式",
|
||||||
|
"lightMode": "浅色",
|
||||||
|
"darkMode": "深色",
|
||||||
|
"primaryColor": "主色",
|
||||||
|
"backgroundGradient": "背景渐变"
|
||||||
|
},
|
||||||
|
"students": {
|
||||||
|
"title": "导入名单",
|
||||||
|
"description": "添加学生名单以便开始积分管理",
|
||||||
|
"import": "导入名单",
|
||||||
|
"manual": "手动添加",
|
||||||
|
"importHint": "支持 Excel (.xlsx) 或 JSON 文件",
|
||||||
|
"manualHint": "逐个添加学生姓名",
|
||||||
|
"dragDrop": "拖拽文件到此处,或点击选择",
|
||||||
|
"supportedFormats": "支持 .xlsx, .json 格式",
|
||||||
|
"studentName": "学生姓名",
|
||||||
|
"addStudent": "添加学生",
|
||||||
|
"noStudents": "暂无学生,请添加或导入",
|
||||||
|
"studentCount": "已添加 {{count}} 名学生",
|
||||||
|
"studentExists": "学生已存在",
|
||||||
|
"importSuccess": "成功导入 {{count}} 名学生",
|
||||||
|
"noNewStudents": "没有新学生可导入",
|
||||||
|
"parseFailed": "文件解析失败",
|
||||||
|
"unsupportedFormat": "不支持的文件格式"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"title": "设置理由",
|
||||||
|
"description": "添加常用的积分变动理由",
|
||||||
|
"addReason": "添加理由",
|
||||||
|
"reasonName": "理由名称",
|
||||||
|
"points": "积分",
|
||||||
|
"positive": "加分",
|
||||||
|
"negative": "扣分",
|
||||||
|
"noReasons": "暂无理由,请添加常用理由",
|
||||||
|
"reasonCount": "已添加 {{count}} 个理由",
|
||||||
|
"reasonExists": "理由已存在"
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"title": "准备就绪",
|
||||||
|
"description": "一切准备就绪,开始使用 SecScore 吧!",
|
||||||
|
"features": {
|
||||||
|
"score": "轻松管理学生积分",
|
||||||
|
"history": "完整的积分变动历史",
|
||||||
|
"settlement": "支持结算与归档"
|
||||||
|
},
|
||||||
|
"startButton": "开始使用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "系统设置",
|
||||||
|
"tabs": {
|
||||||
|
"appearance": "外观",
|
||||||
|
"security": "安全",
|
||||||
|
"database": "数据库连接",
|
||||||
|
"dataManagement": "数据管理",
|
||||||
|
"urlProtocol": "URL 链接",
|
||||||
|
"about": "关于"
|
||||||
|
},
|
||||||
|
"language": "语言",
|
||||||
|
"languageHint": "选择界面显示语言",
|
||||||
|
"theme": "主题",
|
||||||
|
"themeMode": "模式",
|
||||||
|
"lightMode": "浅色",
|
||||||
|
"darkMode": "深色",
|
||||||
|
"primaryColor": "主色",
|
||||||
|
"backgroundGradient": "背景渐变",
|
||||||
|
"interfaceZoom": "界面缩放",
|
||||||
|
"zoomHint": "调节应用界面的整体大小",
|
||||||
|
"zoomUpdated": "界面缩放已更新",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"security": {
|
||||||
|
"title": "密码保护系统",
|
||||||
|
"adminPassword": "管理密码",
|
||||||
|
"pointsPassword": "积分密码",
|
||||||
|
"recoveryString": "找回字符串",
|
||||||
|
"set": "已设置",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"generated": "已生成",
|
||||||
|
"notGenerated": "未生成",
|
||||||
|
"enterPassword": "输入6位数字(留空则不修改)",
|
||||||
|
"adminPasswordPlaceholder": "输入6位数字管理密码(留空则不修改)",
|
||||||
|
"pointsPasswordPlaceholder": "输入6位数字积分密码(留空则不修改)",
|
||||||
|
"recoveryPlaceholder": "输入找回字符串",
|
||||||
|
"savePassword": "保存密码",
|
||||||
|
"savePasswords": "保存密码",
|
||||||
|
"generateRecovery": "生成找回字符串",
|
||||||
|
"clearAllPasswords": "清空所有密码",
|
||||||
|
"recoveryReset": "找回字符串重置",
|
||||||
|
"enterRecovery": "输入找回字符串",
|
||||||
|
"resetPassword": "重置密码",
|
||||||
|
"resetHint": "重置会清空管理/积分密码,并生成新的找回字符串。",
|
||||||
|
"confirmClear": "确认清空所有密码?",
|
||||||
|
"clearHint": "清空后将关闭保护(无密码时默认视为管理权限)。",
|
||||||
|
"saved": "密码已更新",
|
||||||
|
"saveFailed": "更新失败",
|
||||||
|
"generateFailed": "生成失败",
|
||||||
|
"resetFailed": "重置失败",
|
||||||
|
"cleared": "已清空",
|
||||||
|
"clearFailed": "清空失败",
|
||||||
|
"passwordClearedNewRecovery": "密码已清空,新的找回字符串",
|
||||||
|
"newRecoveryString": "新的找回字符串(请保存)"
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"title": "数据库连接",
|
||||||
|
"currentStatus": "当前数据库状态",
|
||||||
|
"sqliteLocal": "SQLite 本地数据库",
|
||||||
|
"postgresqlRemote": "PostgreSQL 远程数据库",
|
||||||
|
"connected": "已连接",
|
||||||
|
"disconnected": "未连接",
|
||||||
|
"postgresqlConnection": "PostgreSQL 远程连接",
|
||||||
|
"connectionHint": "输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"switchToPostgreSQL": "切换到 PostgreSQL",
|
||||||
|
"switchToSQLite": "切换到本地 SQLite",
|
||||||
|
"connectionTestSuccess": "连接测试成功",
|
||||||
|
"connectionTestFailed": "连接测试失败",
|
||||||
|
"switchedTo": "已切换到 {{type}} 数据库",
|
||||||
|
"switchedToSQLite": "已切换到 SQLite 本地数据库",
|
||||||
|
"switchFailed": "切换失败",
|
||||||
|
"syncDescription": "多端同步说明",
|
||||||
|
"syncPoint1": "使用 PostgreSQL 远程数据库可以实现多端数据同步。",
|
||||||
|
"syncPoint2": "系统内置操作队列机制,确保多端同时操作时数据一致性。",
|
||||||
|
"syncPoint3": "切换数据库后需要重启应用以生效。",
|
||||||
|
"syncPoint4": "建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。",
|
||||||
|
"enterConnectionString": "请输入 PostgreSQL 连接字符串",
|
||||||
|
"connectionExample": "示例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "数据管理",
|
||||||
|
"settlement": "结算",
|
||||||
|
"settlementAndRestart": "结算并重新开始",
|
||||||
|
"settlementHint": "将当前未结算记录划分为一个阶段,并将所有学生积分清零。",
|
||||||
|
"importExport": "导入 / 导出",
|
||||||
|
"exportJson": "导出 JSON",
|
||||||
|
"importJson": "导入 JSON",
|
||||||
|
"importHint": "导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。",
|
||||||
|
"logs": "日志",
|
||||||
|
"logLevel": "日志级别",
|
||||||
|
"logOperation": "日志操作",
|
||||||
|
"viewLogs": "查看日志",
|
||||||
|
"exportLogs": "导出日志",
|
||||||
|
"clearLogs": "清空日志",
|
||||||
|
"logsCleared": "日志已清空",
|
||||||
|
"systemLogs": "系统日志 (最后200条)",
|
||||||
|
"noLogs": "暂无日志",
|
||||||
|
"logLevelUpdated": "日志级别已更新",
|
||||||
|
"readLogsFailed": "读取日志失败",
|
||||||
|
"logsExported": "日志已导出",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"exportSuccess": "导出成功",
|
||||||
|
"importSuccess": "导入成功,正在刷新",
|
||||||
|
"importFailed": "导入失败",
|
||||||
|
"clearFailed": "清空失败",
|
||||||
|
"confirmSettlement": "确认结算并重新开始?",
|
||||||
|
"settlementConfirm1": "将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。",
|
||||||
|
"settlementConfirm2": "学生名单不变;结算后的历史在「结算历史」页面查看。",
|
||||||
|
"settlementSuccess": "结算成功,已重新开始积分",
|
||||||
|
"settlementFailed": "结算失败",
|
||||||
|
"settle": "结算",
|
||||||
|
"logLevels": {
|
||||||
|
"debug": "DEBUG (调试)",
|
||||||
|
"info": "INFO (信息)",
|
||||||
|
"warn": "WARN (警告)",
|
||||||
|
"error": "ERROR (错误)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"title": "URL 协议 (secscore://)",
|
||||||
|
"protocol": "URL 协议",
|
||||||
|
"description": "可以通过 URL 链接唤起 SecScore 并执行操作,例如:",
|
||||||
|
"register": "注册 URL 协议",
|
||||||
|
"registered": "URL 协议已注册",
|
||||||
|
"registerFailed": "注册失败",
|
||||||
|
"installerRequired": "需要安装版 SecScore,开发模式下可能无效。"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "关于",
|
||||||
|
"appName": "教育积分管理",
|
||||||
|
"version": "版本",
|
||||||
|
"copyright": "版权",
|
||||||
|
"ipcStatus": "IPC 状态",
|
||||||
|
"ipcConnected": "已连接",
|
||||||
|
"ipcDisconnected": "未连接 (Preload 失败)",
|
||||||
|
"environment": "环境",
|
||||||
|
"toggleDevTools": "切换开发者工具",
|
||||||
|
"license": "SecScore遵循GPL3.0协议",
|
||||||
|
"copyRightText": "CopyRight © 2025-{{year}} SECTL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"home": "首页",
|
||||||
|
"students": "学生管理",
|
||||||
|
"score": "积分操作",
|
||||||
|
"leaderboard": "排行榜",
|
||||||
|
"settlements": "结算历史",
|
||||||
|
"reasons": "理由管理",
|
||||||
|
"autoScore": "自动评分",
|
||||||
|
"settings": "系统设置",
|
||||||
|
"remoteDb": "远程数据库",
|
||||||
|
"refreshStatus": "刷新状态",
|
||||||
|
"syncNow": "立即同步",
|
||||||
|
"syncSuccess": "同步成功",
|
||||||
|
"syncFailed": "同步失败",
|
||||||
|
"getDbStatusFailed": "获取数据库状态失败",
|
||||||
|
"notRemoteMode": "当前不是远程数据库模式,请重启应用后重试",
|
||||||
|
"dbNotConnected": "数据库未连接"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"unlock": "权限解锁",
|
||||||
|
"unlockHint": "输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。",
|
||||||
|
"unlockButton": "解锁",
|
||||||
|
"unlocked": "权限已解锁",
|
||||||
|
"logout": "已切换为只读",
|
||||||
|
"passwordPlaceholder": "例如 123456",
|
||||||
|
"passwordError": "密码错误",
|
||||||
|
"enterPassword": "输入密码",
|
||||||
|
"lock": "锁定"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"en-US": "English"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"colorAndBackground": "颜色与背景",
|
||||||
|
"gradientLabels": {
|
||||||
|
"blue": "清爽蓝",
|
||||||
|
"pink": "柔和粉",
|
||||||
|
"cyan": "青蓝",
|
||||||
|
"purple": "紫韵"
|
||||||
|
},
|
||||||
|
"gradientDirections": {
|
||||||
|
"vertical": "纵向",
|
||||||
|
"horizontal": "横向",
|
||||||
|
"diagonal": "对角"
|
||||||
|
},
|
||||||
|
"generate": "生成",
|
||||||
|
"saved": "已保存",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"mode": "模式",
|
||||||
|
"myTheme": "我的主题"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"admin": "管理权限",
|
||||||
|
"points": "积分权限",
|
||||||
|
"view": "只读"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "学生积分主页",
|
||||||
|
"subtitle": "共 {{count}} 名学生,点击卡片进行积分操作",
|
||||||
|
"searchPlaceholder": "搜索姓名/拼音...",
|
||||||
|
"sortBy": {
|
||||||
|
"alphabet": "姓名排序",
|
||||||
|
"surname": "姓氏分组",
|
||||||
|
"score": "积分排行"
|
||||||
|
},
|
||||||
|
"noStudents": "暂无学生数据,请前往学生管理添加",
|
||||||
|
"noMatch": "未找到匹配的学生",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"points": "分",
|
||||||
|
"currentScore": "当前积分",
|
||||||
|
"quickOptions": "快捷选项",
|
||||||
|
"adjustPoints": "调整分值",
|
||||||
|
"customPoints": "自定义分值",
|
||||||
|
"customPointsHint": "可在输入框微调特输入任意分值",
|
||||||
|
"reason": "操作理由",
|
||||||
|
"reasonPlaceholder": "输入加分/扣分的原因(可选)",
|
||||||
|
"preview": "变更预览",
|
||||||
|
"noReason": "(无理由)",
|
||||||
|
"category": {
|
||||||
|
"others": "其他"
|
||||||
|
},
|
||||||
|
"scoreAdded": "已为 {{name}} 加{{points}}分",
|
||||||
|
"scoreDeducted": "已为 {{name}} 扣{{points}}分",
|
||||||
|
"submitFailed": "提交失败",
|
||||||
|
"pleaseSelectPoints": "请选择或输入分值",
|
||||||
|
"reasonDefault": "{{action}}{{points}}分",
|
||||||
|
"studentCount": "{{count}} 人",
|
||||||
|
"operationTitle": "积分操作:{{name}}",
|
||||||
|
"submitOperation": "提交操作",
|
||||||
|
"addPoints": "加分",
|
||||||
|
"deductPoints": "扣分",
|
||||||
|
"pointsChange": "积分变更",
|
||||||
|
"reasonLabel": "理由:"
|
||||||
|
},
|
||||||
|
"students": {
|
||||||
|
"title": "学生管理",
|
||||||
|
"importList": "导入名单",
|
||||||
|
"addStudent": "添加学生",
|
||||||
|
"name": "姓名",
|
||||||
|
"currentScore": "当前积分",
|
||||||
|
"tags": "标签",
|
||||||
|
"noTags": "无标签",
|
||||||
|
"editTags": "编辑标签",
|
||||||
|
"deleteConfirm": "确认删除该学生?",
|
||||||
|
"addTitle": "添加学生",
|
||||||
|
"addConfirm": "添加",
|
||||||
|
"namePlaceholder": "请输入学生姓名",
|
||||||
|
"nameRequired": "请输入姓名",
|
||||||
|
"nameExists": "学生姓名已存在",
|
||||||
|
"addSuccess": "添加成功",
|
||||||
|
"addFailed": "添加失败",
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"tagSaveSuccess": "标签保存成功",
|
||||||
|
"tagSaveFailed": "保存标签失败",
|
||||||
|
"importTitle": "导入名单",
|
||||||
|
"importByXlsx": "通过 xlsx 导入",
|
||||||
|
"xlsxPreview": "xlsx 预览与导入",
|
||||||
|
"file": "文件",
|
||||||
|
"selectNameCol": "点击表头选择姓名列",
|
||||||
|
"previewRows": "预览前 50 行",
|
||||||
|
"importConfirm": "导入",
|
||||||
|
"importComplete": "导入完成:新增 {{inserted}},跳过 {{skipped}}",
|
||||||
|
"workerNotReady": "Worker 未初始化",
|
||||||
|
"parseXlsxFailed": "解析 xlsx 失败",
|
||||||
|
"selectNameColFirst": "请先点击选择\"姓名列\"",
|
||||||
|
"noNamesFound": "所选列未解析到可导入的姓名",
|
||||||
|
"importFailed": "导入失败",
|
||||||
|
"editTagTitle": "编辑标签 - {{name}}"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"title": "积分管理",
|
||||||
|
"student": "学生",
|
||||||
|
"change": "变动",
|
||||||
|
"reason": "理由",
|
||||||
|
"time": "时间",
|
||||||
|
"undoConfirm": "确定要撤销这条记录吗?学生积分将回滚。",
|
||||||
|
"undo": "撤销",
|
||||||
|
"undoSuccess": "已撤销操作",
|
||||||
|
"undoFailed": "撤销失败",
|
||||||
|
"recentRecords": "最近记录",
|
||||||
|
"pleaseSelectStudent": "请选择或搜索学生",
|
||||||
|
"points": "分数",
|
||||||
|
"addPoints": "加分",
|
||||||
|
"deductPoints": "扣分",
|
||||||
|
"quickReason": "快捷理由",
|
||||||
|
"selectReason": "选择预设理由",
|
||||||
|
"reasonContent": "理由内容",
|
||||||
|
"reasonPlaceholder": "手动输入或选择快捷理由",
|
||||||
|
"submit": "确认提交",
|
||||||
|
"pleaseEnterInfo": "请填写完整信息",
|
||||||
|
"pleaseEnterPoints": "请填写分值或选择预设理由",
|
||||||
|
"batchSuccess": "已为 {{count}} 名学生提交积分",
|
||||||
|
"batchPartial": "成功提交 {{success}}/{{total}} 名学生的积分"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"title": "理由管理",
|
||||||
|
"addReason": "添加预设理由",
|
||||||
|
"category": "分类",
|
||||||
|
"content": "理由内容",
|
||||||
|
"presetPoints": "预设分值",
|
||||||
|
"deleteConfirm": "确认删除该理由?",
|
||||||
|
"addTitle": "添加理由",
|
||||||
|
"addConfirm": "添加",
|
||||||
|
"categoryPlaceholder": "例如: 学习, 纪律",
|
||||||
|
"contentPlaceholder": "请输入理由",
|
||||||
|
"pointsPlaceholder": "例如: 2 或 -2",
|
||||||
|
"contentRequired": "请输入理由内容",
|
||||||
|
"pointsRequired": "请输入预设分值",
|
||||||
|
"reasonExists": "该分类下已存在相同理由",
|
||||||
|
"addSuccess": "添加成功",
|
||||||
|
"addFailed": "添加失败",
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"others": "其他"
|
||||||
|
},
|
||||||
|
"leaderboard": {
|
||||||
|
"title": "积分排行榜",
|
||||||
|
"rank": "排名",
|
||||||
|
"name": "姓名",
|
||||||
|
"totalScore": "总积分",
|
||||||
|
"todayChange": "今日变化",
|
||||||
|
"weekChange": "本周变化",
|
||||||
|
"monthChange": "本月变化",
|
||||||
|
"viewHistory": "查看",
|
||||||
|
"exportXlsx": "导出 XLSX",
|
||||||
|
"exportSuccess": "导出成功",
|
||||||
|
"queryFailed": "查询失败",
|
||||||
|
"historyTitle": "{{name}} - 操作记录",
|
||||||
|
"close": "关闭",
|
||||||
|
"today": "今天",
|
||||||
|
"week": "本周",
|
||||||
|
"month": "本月",
|
||||||
|
"operationRecord": "操作记录",
|
||||||
|
"change": "变化"
|
||||||
|
},
|
||||||
|
"settlements": {
|
||||||
|
"title": "结算历史",
|
||||||
|
"phase": "阶段 #{{id}}",
|
||||||
|
"viewLeaderboard": "查看排行榜",
|
||||||
|
"recordCount": "记录数: {{count}}",
|
||||||
|
"noRecords": "暂无结算记录",
|
||||||
|
"noSettlements": "暂无结算记录",
|
||||||
|
"back": "返回",
|
||||||
|
"leaderboardTitle": "结算排行榜",
|
||||||
|
"phaseScore": "阶段积分",
|
||||||
|
"queryFailed": "查询失败",
|
||||||
|
"rank": "排名",
|
||||||
|
"name": "姓名"
|
||||||
|
},
|
||||||
|
"autoScore": {
|
||||||
|
"title": "自动化加分管理",
|
||||||
|
"name": "自动化名称",
|
||||||
|
"namePlaceholder": "例如:每日签到加分",
|
||||||
|
"nameRequired": "请输入自动化名称",
|
||||||
|
"triggers": "触发器",
|
||||||
|
"actions": "行动",
|
||||||
|
"applicableStudents": "适用学生",
|
||||||
|
"allStudents": "所有学生",
|
||||||
|
"lastExecuted": "最后执行",
|
||||||
|
"notExecuted": "未执行",
|
||||||
|
"invalidTime": "无效时间",
|
||||||
|
"addTrigger": "添加规则",
|
||||||
|
"addAction": "添加行动",
|
||||||
|
"whenTriggered": "当以下规则触发时",
|
||||||
|
"triggeredActions": "满足规则时触发的行动",
|
||||||
|
"addAutomation": "添加自动化",
|
||||||
|
"updateAutomation": "更新自动化",
|
||||||
|
"cancelEdit": "取消编辑",
|
||||||
|
"resetForm": "重置表单",
|
||||||
|
"deleteConfirm": "确定要删除这条自动化吗?",
|
||||||
|
"adminRequired": "需要管理员权限以查看自动加分自动化,请先登录管理员账号",
|
||||||
|
"adminCreateRequired": "需要管理员权限以创建或更新自动加分自动化",
|
||||||
|
"adminDeleteRequired": "需要管理员权限以删除自动加分自动化",
|
||||||
|
"adminToggleRequired": "需要管理员权限以启用/禁用自动加分自动化",
|
||||||
|
"fetchFailed": "获取自动化失败",
|
||||||
|
"triggerRequired": "请至少添加一个触发器",
|
||||||
|
"actionRequired": "请至少添加一个行动",
|
||||||
|
"createSuccess": "自动化创建成功",
|
||||||
|
"updateSuccess": "自动化更新成功",
|
||||||
|
"createFailed": "创建自动化失败",
|
||||||
|
"updateFailed": "更新自动化失败",
|
||||||
|
"deleteSuccess": "自动化删除成功",
|
||||||
|
"deleteFailed": "删除自动化失败",
|
||||||
|
"enabled": "自动化已启用",
|
||||||
|
"disabled": "自动化已禁用",
|
||||||
|
"enableFailed": "启用自动化失败",
|
||||||
|
"disableFailed": "禁用自动化失败",
|
||||||
|
"noTriggerAvailable": "没有可用的触发器类型,请检查配置",
|
||||||
|
"noActionAvailable": "没有可用的行动类型,请检查配置",
|
||||||
|
"sort": "排序",
|
||||||
|
"status": "状态",
|
||||||
|
"triggerCount": "{{count}} 个触发器",
|
||||||
|
"actionCount": "{{count}} 个行动",
|
||||||
|
"studentCount": "{{count}} 名学生",
|
||||||
|
"studentPlaceholder": "请选择或搜索学生(留空表示所有学生)"
|
||||||
|
},
|
||||||
|
"triggers": {
|
||||||
|
"studentTag": {
|
||||||
|
"label": "当学生匹配标签时触发",
|
||||||
|
"description": "当学生匹配特定标签时触发自动化",
|
||||||
|
"placeholder": "请选择标签",
|
||||||
|
"required": "请输入标签名称"
|
||||||
|
},
|
||||||
|
"randomTime": {
|
||||||
|
"label": "随机时间触发",
|
||||||
|
"description": "在指定时间范围内随机触发自动化",
|
||||||
|
"from": "从",
|
||||||
|
"to": "到",
|
||||||
|
"hour": "时",
|
||||||
|
"minHourPlaceholder": "最小小时",
|
||||||
|
"maxHourPlaceholder": "最大小时",
|
||||||
|
"required": "请输入有效的时间范围(分钟)"
|
||||||
|
},
|
||||||
|
"intervalTime": {
|
||||||
|
"label": "根据间隔时间触发",
|
||||||
|
"description": "当间隔时间到达时触发自动化",
|
||||||
|
"days": "天",
|
||||||
|
"minutes": "分钟",
|
||||||
|
"placeholderMinutes": "请输入时间间隔(分钟)",
|
||||||
|
"placeholderDays": "请输入时间间隔(天)",
|
||||||
|
"required": "请输入有效的时间间隔(分钟)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"sendNotification": {
|
||||||
|
"label": "发送通知",
|
||||||
|
"description": "向学生发送通知",
|
||||||
|
"placeholder": "请输入通知内容"
|
||||||
|
},
|
||||||
|
"addTag": {
|
||||||
|
"label": "添加标签",
|
||||||
|
"description": "为学生添加标签",
|
||||||
|
"placeholder": "请输入标签"
|
||||||
|
},
|
||||||
|
"addScore": {
|
||||||
|
"label": "添加分数",
|
||||||
|
"description": "为学生添加分数",
|
||||||
|
"pointsPlaceholder": "请输入分数",
|
||||||
|
"reasonPlaceholder": "请输入理由"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wizard": {
|
||||||
|
"welcomeTitle": "欢迎使用 SecScore 积分管理",
|
||||||
|
"welcomeDesc": "感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。",
|
||||||
|
"configComplete": "配置完成!",
|
||||||
|
"configFailed": "配置保存失败",
|
||||||
|
"startJourney": "开启积分之旅"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"editTitle": "编辑标签",
|
||||||
|
"inputPlaceholder": "输入标签名称,按 Enter 添加",
|
||||||
|
"selectedTags": "已选标签(点击取消)",
|
||||||
|
"noTagsSelected": "未选择标签",
|
||||||
|
"availableTags": "可选标签(点击选择)",
|
||||||
|
"noAvailableTags": "无可用标签",
|
||||||
|
"fetchFailed": "获取标签列表失败",
|
||||||
|
"nameTooLong": "标签名称不能超过 30 个字符",
|
||||||
|
"addFailed": "添加标签失败",
|
||||||
|
"deleteSuccess": "标签删除成功",
|
||||||
|
"deleteFailed": "删除标签失败"
|
||||||
|
},
|
||||||
|
"recovery": {
|
||||||
|
"title": "SecScore 找回字符串",
|
||||||
|
"saved": "我已保存",
|
||||||
|
"export": "导出文本文件",
|
||||||
|
"hint": "建议导出后离线保存,遗失将无法找回。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import './i18n'
|
||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|||||||
Reference in New Issue
Block a user