mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat:i18n and OOBE
This commit is contained in:
@@ -18,4 +18,3 @@ db.sqlite
|
||||
!.vscode/extensions.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/settings.json
|
||||
/.trae/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
该项目使用pnpm作为包管理器
|
||||
每次写完代码后请务必跑一遍格式化和lint,typecheck,一个warning都不可以有
|
||||
每次完成任务请详细告知用户“你干了什么”和要确认的问题(如有)。
|
||||
@@ -40,10 +40,12 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"express": "^5.2.1",
|
||||
"i18next": "^25.8.14",
|
||||
"mica-electron": "^1.5.16",
|
||||
"os": "^0.1.2",
|
||||
"pg": "^8.19.0",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.27",
|
||||
|
||||
Generated
+60
@@ -47,6 +47,9 @@ importers:
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
i18next:
|
||||
specifier: ^25.8.14
|
||||
version: 25.8.14(typescript@5.9.3)
|
||||
mica-electron:
|
||||
specifier: ^1.5.16
|
||||
version: 1.5.16
|
||||
@@ -57,6 +60,9 @@ importers:
|
||||
pinyin-pro:
|
||||
specifier: ^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:
|
||||
specifier: ^6.28.0
|
||||
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==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
@@ -2483,6 +2492,14 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
|
||||
engines: {node: ^8.11.2 || >=10}
|
||||
@@ -3313,6 +3330,22 @@ packages:
|
||||
react-fast-compare@3.2.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -3977,6 +4010,10 @@ packages:
|
||||
yaml:
|
||||
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:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
@@ -6873,6 +6910,10 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
http-errors@2.0.1:
|
||||
@@ -6902,6 +6943,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
dependencies:
|
||||
cli-truncate: 2.1.0
|
||||
@@ -7721,6 +7768,17 @@ snapshots:
|
||||
|
||||
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@18.3.1: {}
|
||||
@@ -8440,6 +8498,8 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
||||
+12
-10
@@ -1,12 +1,14 @@
|
||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { ContentArea } from './components/ContentArea'
|
||||
import { Wizard } from './components/Wizard'
|
||||
import { OOBE } from './components/OOBE'
|
||||
import { ThemeProvider, useTheme } from './contexts/ThemeContext'
|
||||
|
||||
function MainContent(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { currentTheme } = useTheme()
|
||||
@@ -80,9 +82,9 @@ function MainContent(): React.JSX.Element {
|
||||
setPermission(res.data.permission)
|
||||
setAuthVisible(false)
|
||||
setAuthPassword('')
|
||||
messageApi.success('权限已解锁')
|
||||
messageApi.success(t('auth.unlocked'))
|
||||
} 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()
|
||||
if (res?.success && res.data) {
|
||||
setPermission(res.data.permission)
|
||||
messageApi.success('已切换为只读')
|
||||
messageApi.success(t('auth.logout'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,25 +131,25 @@ function MainContent(): React.JSX.Element {
|
||||
onLogout={logout}
|
||||
/>
|
||||
|
||||
<Wizard visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||
|
||||
<Modal
|
||||
title="权限解锁"
|
||||
title={t('auth.unlock')}
|
||||
open={authVisible}
|
||||
onCancel={() => setAuthVisible(false)}
|
||||
onOk={login}
|
||||
confirmLoading={authLoading}
|
||||
okText="解锁"
|
||||
cancelText="取消"
|
||||
okText={t('auth.unlockButton')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。
|
||||
{t('auth.unlockHint')}
|
||||
</div>
|
||||
<Input
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder="例如 123456"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PlusOutlined, HolderOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically'
|
||||
import type { TriggerItem, ActionItem } from './com.automatically/types'
|
||||
import TriggerItemComponent from './com.automatically/TriggerItem'
|
||||
@@ -36,6 +37,7 @@ interface AutoScoreRuleFormValues {
|
||||
}
|
||||
|
||||
export const AutoScoreManager: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [rules, setRules] = useState<AutoScoreRule[]>([])
|
||||
const [students, setStudents] = useState<{ id: number; name: string }[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -55,7 +57,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号')
|
||||
messageApi.error(t('autoScore.adminRequired'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -70,14 +72,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
if (rulesRes.success) {
|
||||
setRules(rulesRes.data)
|
||||
} else {
|
||||
messageApi.error(rulesRes.message || '获取自动化失败')
|
||||
messageApi.error(rulesRes.message || t('autoScore.fetchFailed'))
|
||||
}
|
||||
if (studentsRes.success) {
|
||||
setStudents(studentsRes.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch auto score rules:', error)
|
||||
messageApi.error('获取自动化失败')
|
||||
messageApi.error(t('autoScore.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -93,17 +95,17 @@ export const AutoScoreManager: React.FC = () => {
|
||||
const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues
|
||||
|
||||
if (!values.name) {
|
||||
messageApi.warning('请填写自动化名称')
|
||||
messageApi.warning(t('autoScore.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (triggerList.length === 0) {
|
||||
messageApi.warning('请至少添加一个触发器')
|
||||
messageApi.warning(t('autoScore.triggerRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
if (actionList.length === 0) {
|
||||
messageApi.warning('请至少添加一个行动')
|
||||
messageApi.warning(t('autoScore.actionRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error('需要管理员权限以创建或更新自动加分自动化')
|
||||
messageApi.error(t('autoScore.adminCreateRequired'))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -150,7 +152,9 @@ export const AutoScoreManager: React.FC = () => {
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功')
|
||||
messageApi.success(
|
||||
editingRuleId !== null ? t('autoScore.updateSuccess') : t('autoScore.createSuccess')
|
||||
)
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
studentNames: ''
|
||||
@@ -161,12 +165,15 @@ export const AutoScoreManager: React.FC = () => {
|
||||
fetchRules()
|
||||
} else {
|
||||
messageApi.error(
|
||||
res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败')
|
||||
res.message ||
|
||||
(editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed'))
|
||||
)
|
||||
}
|
||||
} catch (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 {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error('需要管理员权限以删除自动加分自动化')
|
||||
messageApi.error(t('autoScore.adminDeleteRequired'))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -215,14 +222,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId)
|
||||
if (res.success) {
|
||||
messageApi.success('自动化删除成功')
|
||||
messageApi.success(t('autoScore.deleteSuccess'))
|
||||
fetchRules()
|
||||
} else {
|
||||
messageApi.error(res.message || '删除自动化失败')
|
||||
messageApi.error(res.message || t('autoScore.deleteFailed'))
|
||||
}
|
||||
} catch (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 {
|
||||
const authRes = await (window as any).api.authGetStatus()
|
||||
if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') {
|
||||
messageApi.error('需要管理员权限以启用/禁用自动加分自动化')
|
||||
messageApi.error(t('autoScore.adminToggleRequired'))
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -241,14 +248,16 @@ export const AutoScoreManager: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled })
|
||||
if (res.success) {
|
||||
messageApi.success(enabled ? '自动化已启用' : '自动化已禁用')
|
||||
messageApi.success(enabled ? t('autoScore.enabled') : t('autoScore.disabled'))
|
||||
fetchRules()
|
||||
} else {
|
||||
messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败'))
|
||||
messageApi.error(
|
||||
res.message || (enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed'))
|
||||
)
|
||||
}
|
||||
} catch (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 defaultTrigger = allTriggers.list[0]
|
||||
if (!defaultTrigger) {
|
||||
messageApi.error('没有可用的触发器类型,请检查配置')
|
||||
messageApi.error(t('autoScore.noTriggerAvailable'))
|
||||
return
|
||||
}
|
||||
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 defaultAction = allActions.list[0]
|
||||
if (!defaultAction) {
|
||||
messageApi.error('没有可用的行动类型,请检查配置')
|
||||
messageApi.error(t('autoScore.noActionAvailable'))
|
||||
return
|
||||
}
|
||||
setActionList((prev) => [
|
||||
@@ -333,12 +342,12 @@ export const AutoScoreManager: React.FC = () => {
|
||||
const columns: ColumnsType<AutoScoreRule> = [
|
||||
{
|
||||
key: 'drag',
|
||||
title: '排序',
|
||||
title: t('autoScore.sort'),
|
||||
width: 60,
|
||||
render: () => <HolderOutlined style={{ cursor: 'move' }} />
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: t('autoScore.status'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
@@ -346,15 +355,15 @@ export const AutoScoreManager: React.FC = () => {
|
||||
<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',
|
||||
key: 'triggers',
|
||||
width: 150,
|
||||
render: (triggers: AutoScoreRule['triggers']) => {
|
||||
if (!triggers || triggers.length === 0) {
|
||||
return <span>无</span>
|
||||
return <span>{t('common.none')}</span>
|
||||
}
|
||||
const triggerLabels = triggers.map((t) => {
|
||||
const def = triggerRegistry.get(t.event)
|
||||
@@ -362,19 +371,19 @@ export const AutoScoreManager: React.FC = () => {
|
||||
})
|
||||
return (
|
||||
<Tooltip title={triggerLabels.join(', ')}>
|
||||
<span>{triggers.length} 个触发器</span>
|
||||
<span>{t('autoScore.triggerCount', { count: triggers.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '行动',
|
||||
title: t('autoScore.actions'),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (actions: AutoScoreRule['actions']) => {
|
||||
if (!actions || actions.length === 0) {
|
||||
return <span>无</span>
|
||||
return <span>{t('common.none')}</span>
|
||||
}
|
||||
const actionLabels = actions.map((a) => {
|
||||
const def = actionRegistry.get(a.event)
|
||||
@@ -382,55 +391,55 @@ export const AutoScoreManager: React.FC = () => {
|
||||
})
|
||||
return (
|
||||
<Tooltip title={actionLabels.join(', ')}>
|
||||
<span>{actions.length} 个行动</span>
|
||||
<span>{t('autoScore.actionCount', { count: actions.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '适用学生',
|
||||
title: t('autoScore.applicableStudents'),
|
||||
dataIndex: 'studentNames',
|
||||
key: 'studentNames',
|
||||
width: 130,
|
||||
render: (studentNames: string[]) => {
|
||||
if (!studentNames || studentNames.length === 0) {
|
||||
return <span>所有学生</span>
|
||||
return <span>{t('autoScore.allStudents')}</span>
|
||||
}
|
||||
const studentList = studentNames.join(',\n')
|
||||
return (
|
||||
<Tooltip title={studentList}>
|
||||
<span>{studentNames.length} 名学生</span>
|
||||
<span>{t('autoScore.studentCount', { count: studentNames.length })}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '最后执行',
|
||||
title: t('autoScore.lastExecuted'),
|
||||
dataIndex: 'lastExecuted',
|
||||
key: 'lastExecuted',
|
||||
width: 180,
|
||||
render: (lastExecuted: string) => {
|
||||
if (!lastExecuted) return <span>未执行</span>
|
||||
if (!lastExecuted) return <span>{t('autoScore.notExecuted')}</span>
|
||||
try {
|
||||
const date = new Date(lastExecuted)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return <span>无效时间</span>
|
||||
return <span>{t('autoScore.invalidTime')}</span>
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleEdit(row)}>
|
||||
编辑
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm title="确定要删除这条自动化吗?" onConfirm={() => handleDelete(row.id)}>
|
||||
<Popconfirm title={t('autoScore.deleteConfirm')} onConfirm={() => handleDelete(row.id)}>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -468,24 +477,24 @@ export const AutoScoreManager: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{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)' }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
|
||||
<Form.Item
|
||||
label="自动化名称"
|
||||
label={t('autoScore.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 label="适用学生" name="studentNames">
|
||||
<Form.Item label={t('autoScore.applicableStudents')} name="studentNames">
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder="请选择或搜索学生(留空表示所有学生)"
|
||||
placeholder={t('autoScore.studentPlaceholder')}
|
||||
options={students.map((student) => ({ label: student.name, value: student.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -493,10 +502,12 @@ export const AutoScoreManager: React.FC = () => {
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', gap: '12px' }}>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
{editingRuleId !== null ? '更新自动化' : '添加自动化'}
|
||||
{editingRuleId !== null
|
||||
? t('autoScore.updateAutomation')
|
||||
: t('autoScore.addAutomation')}
|
||||
</Button>
|
||||
<Button onClick={handleResetForm}>
|
||||
{editingRuleId !== null ? '取消编辑' : '重置表单'}
|
||||
{editingRuleId !== null ? t('autoScore.cancelEdit') : t('autoScore.resetForm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -504,7 +515,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
|
||||
<Card
|
||||
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
||||
title="当以下规则触发时"
|
||||
title={t('autoScore.whenTriggered')}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{triggerItems}
|
||||
@@ -514,14 +525,14 @@ export const AutoScoreManager: React.FC = () => {
|
||||
onClick={handleAddTrigger}
|
||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||
>
|
||||
添加规则
|
||||
{t('autoScore.addTrigger')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{ marginBottom: '24px', backgroundColor: 'var(--ss-card-bg)' }}
|
||||
title="满足规则时触发的行动"
|
||||
title={t('autoScore.triggeredActions')}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{actionItems}
|
||||
@@ -531,7 +542,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
onClick={handleAddAction}
|
||||
style={{ fontWeight: 'bolder', fontSize: 15 }}
|
||||
>
|
||||
添加行动
|
||||
{t('autoScore.addAction')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -555,7 +566,7 @@ export const AutoScoreManager: React.FC = () => {
|
||||
setPageSize(size)
|
||||
}}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
showTotal={(total) => t('common.total', { count: total })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
import { Layout, Space, Button, Tag } from 'antd'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() =>
|
||||
@@ -34,11 +35,16 @@ export function ContentArea({
|
||||
onAuthClick,
|
||||
onLogout
|
||||
}: ContentAreaProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const permissionTag = (
|
||||
<Tag
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -79,10 +85,10 @@ export function ContentArea({
|
||||
{hasAnyPassword && (
|
||||
<>
|
||||
<Button size="small" onClick={onAuthClick}>
|
||||
输入密码
|
||||
{t('auth.enterPassword')}
|
||||
</Button>
|
||||
<Button size="small" danger onClick={onLogout}>
|
||||
锁定
|
||||
{t('auth.lock')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Card, Space, Button, Tag, Input, Select, Modal, message, InputNumber, Divider } from 'antd'
|
||||
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { match, pinyin } from 'pinyin-pro'
|
||||
|
||||
interface student {
|
||||
@@ -21,6 +22,7 @@ interface reason {
|
||||
type SortType = 'alphabet' | 'surname' | 'score'
|
||||
|
||||
export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -164,13 +166,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const groupedReasons = useMemo(() => {
|
||||
const groups: Record<string, reason[]> = {}
|
||||
reasons.forEach((r) => {
|
||||
const cat = r.category || '其他'
|
||||
const cat = r.category || t('home.category.others')
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(r)
|
||||
})
|
||||
return Object.entries(groups).sort(([a], [b]) => {
|
||||
if (a === '其他') return 1
|
||||
if (b === '其他') return -1
|
||||
if (a === t('home.category.others')) return 1
|
||||
if (b === t('home.category.others')) return -1
|
||||
return a.localeCompare(b, 'zh-CN')
|
||||
})
|
||||
}, [reasons])
|
||||
@@ -205,7 +207,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const openOperation = (student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
setSelectedStudent(student)
|
||||
@@ -217,7 +219,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const performSubmit = async (student: student, delta: number, content: string) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,7 +231,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
setStudents((prev) =>
|
||||
@@ -238,7 +244,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
emitDataUpdated('events')
|
||||
} else {
|
||||
messageApi.error(res.message || '提交失败')
|
||||
messageApi.error(res.message || t('home.submitFailed'))
|
||||
}
|
||||
setSubmitLoading(false)
|
||||
}
|
||||
@@ -248,11 +254,17 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
const delta = customScore
|
||||
if (delta === undefined || !Number.isFinite(delta)) {
|
||||
messageApi.warning('请选择或输入分值')
|
||||
messageApi.warning(t('home.pleaseSelectPoints'))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -374,7 +386,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<span
|
||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
|
||||
>
|
||||
({group.students.length} 人)
|
||||
({t('home.studentCount', { count: group.students.length })})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -524,10 +536,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}>
|
||||
学生积分主页
|
||||
{t('home.title')}
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||
共 {students.length} 名学生,点击卡片进行积分操作
|
||||
{t('home.subtitle', { count: students.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -535,7 +547,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<Input
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder="搜索姓名/拼音..."
|
||||
placeholder={t('home.searchPlaceholder')}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
style={{ width: '220px' }}
|
||||
@@ -546,9 +558,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
onChange={(v) => setSortType(v as SortType)}
|
||||
style={{ width: '140px' }}
|
||||
options={[
|
||||
{ value: 'alphabet', label: '姓名排序' },
|
||||
{ value: 'surname', label: '姓氏分组' },
|
||||
{ value: 'score', label: '积分排行' }
|
||||
{ value: 'alphabet', label: t('home.sortBy.alphabet') },
|
||||
{ value: 'surname', label: t('home.sortBy.surname') },
|
||||
{ value: 'score', label: t('home.sortBy.score') }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
@@ -559,7 +571,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
|
||||
{loading ? (
|
||||
<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>
|
||||
) : sortedStudents.length === 0 ? (
|
||||
<div
|
||||
@@ -572,11 +584,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
|
||||
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
|
||||
{searchKeyword ? t('home.noMatch') : t('home.noStudents')}
|
||||
</div>
|
||||
{searchKeyword && (
|
||||
<Button type="link" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
|
||||
清除搜索
|
||||
{t('home.clearSearch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -586,13 +598,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`积分操作:${selectedStudent?.name}`}
|
||||
title={t('home.operationTitle', { name: selectedStudent?.name })}
|
||||
open={operationVisible}
|
||||
onCancel={() => setOperationVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={submitLoading}
|
||||
okText="提交操作"
|
||||
cancelText="取消"
|
||||
okText={t('home.submitOperation')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -629,7 +641,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
|
||||
当前积分:
|
||||
{t('home.currentScore')}:
|
||||
</span>
|
||||
<Tag
|
||||
color={
|
||||
@@ -656,7 +668,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
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 }} />
|
||||
</div>
|
||||
<div
|
||||
@@ -724,7 +738,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div
|
||||
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 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
||||
@@ -752,10 +766,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
max={99}
|
||||
step={1}
|
||||
style={{ width: '140px' }}
|
||||
placeholder="自定义分值"
|
||||
placeholder={t('home.customPoints')}
|
||||
/>
|
||||
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
|
||||
可在输入框微调特输入任意分值
|
||||
{t('home.customPointsHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -764,13 +778,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div
|
||||
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 }} />
|
||||
</div>
|
||||
<Input
|
||||
value={reasonContent}
|
||||
onChange={(e) => setReasonContent(e.target.value)}
|
||||
placeholder="输入加分/扣分的原因(可选)"
|
||||
placeholder={t('home.reasonPlaceholder')}
|
||||
suffix={
|
||||
reasonContent ? (
|
||||
<DeleteOutlined
|
||||
@@ -805,7 +819,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
color: 'var(--ss-text-main)'
|
||||
}}
|
||||
>
|
||||
变更预览:
|
||||
{t('home.preview')}:
|
||||
</div>
|
||||
<div style={{ fontSize: '15px' }}>
|
||||
{selectedStudent.name}{' '}
|
||||
@@ -822,9 +836,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
>
|
||||
{customScore > 0 ? `+${customScore}` : customScore}
|
||||
</span>{' '}
|
||||
分
|
||||
{t('home.points')}
|
||||
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
|
||||
{reasonContent ? `理由:${reasonContent}` : '(无理由)'}
|
||||
{reasonContent
|
||||
? `${t('home.reasonLabel')}${reasonContent}`
|
||||
: t('home.noReason')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EyeOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
@@ -12,6 +13,7 @@ interface studentRank {
|
||||
}
|
||||
|
||||
export const Leaderboard: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<studentRank[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timeRange, setTimeRange] = useState('today')
|
||||
@@ -58,7 +60,7 @@ export const Leaderboard: React.FC = () => {
|
||||
startTime
|
||||
})
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || '查询失败')
|
||||
messageApi.error(res.message || t('leaderboard.queryFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,14 +70,19 @@ export const Leaderboard: React.FC = () => {
|
||||
return `${time} ${delta} ${e.reason_content}`
|
||||
})
|
||||
|
||||
setHistoryHeader(`${studentName} - 操作记录`)
|
||||
setHistoryText(lines.join('\n') || '暂无记录')
|
||||
setHistoryHeader(t('leaderboard.historyTitle', { name: studentName }))
|
||||
setHistoryText(lines.join('\n') || t('common.noData'))
|
||||
setHistoryVisible(true)
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
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) => {
|
||||
if (typeof v !== 'string') return v
|
||||
@@ -84,7 +91,12 @@ export const Leaderboard: React.FC = () => {
|
||||
}
|
||||
|
||||
const sheetData = [
|
||||
['排名', '姓名', '总积分', `${title}变化`],
|
||||
[
|
||||
t('leaderboard.rank'),
|
||||
t('leaderboard.name'),
|
||||
t('leaderboard.totalScore'),
|
||||
`${title}${t('leaderboard.change')}`
|
||||
],
|
||||
...data.map((item, index) => [
|
||||
index + 1,
|
||||
sanitizeCell(item.name),
|
||||
@@ -97,7 +109,7 @@ export const Leaderboard: React.FC = () => {
|
||||
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||
|
||||
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 blob = new Blob([xlsxBytes], {
|
||||
@@ -109,20 +121,20 @@ export const Leaderboard: React.FC = () => {
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
`${t('leaderboard.title')}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
messageApi.success('导出成功')
|
||||
messageApi.success(t('leaderboard.exportSuccess'))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const columns: ColumnsType<studentRank> = [
|
||||
{
|
||||
title: '排名',
|
||||
title: t('leaderboard.rank'),
|
||||
key: 'rank',
|
||||
width: 70,
|
||||
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',
|
||||
key: 'score',
|
||||
width: 100,
|
||||
@@ -149,7 +161,12 @@ export const Leaderboard: React.FC = () => {
|
||||
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',
|
||||
key: 'range_change',
|
||||
width: 100,
|
||||
@@ -161,13 +178,13 @@ export const Leaderboard: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作记录',
|
||||
title: t('leaderboard.operationRecord'),
|
||||
key: 'operation',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewHistory(row.name)}>
|
||||
查看
|
||||
{t('leaderboard.viewHistory')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -184,20 +201,20 @@ export const Leaderboard: React.FC = () => {
|
||||
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>
|
||||
<Select
|
||||
value={timeRange}
|
||||
onChange={(v) => setTimeRange(v)}
|
||||
style={{ width: '120px' }}
|
||||
options={[
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'week', label: '本周' },
|
||||
{ value: 'month', label: '本月' }
|
||||
{ value: 'today', label: t('leaderboard.today') },
|
||||
{ value: 'week', label: t('leaderboard.week') },
|
||||
{ value: 'month', label: t('leaderboard.month') }
|
||||
]}
|
||||
/>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
导出 XLSX
|
||||
{t('leaderboard.exportXlsx')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -218,7 +235,7 @@ export const Leaderboard: React.FC = () => {
|
||||
title={historyHeader}
|
||||
open={historyVisible}
|
||||
onCancel={() => setHistoryVisible(false)}
|
||||
footer={<Button onClick={() => setHistoryVisible(false)}>关闭</Button>}
|
||||
footer={<Button onClick={() => setHistoryVisible(false)}>{t('common.close')}</Button>}
|
||||
width="80%"
|
||||
>
|
||||
<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 { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface reason {
|
||||
id: number
|
||||
@@ -11,6 +12,7 @@ interface reason {
|
||||
}
|
||||
|
||||
export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<reason[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -49,15 +51,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleAdd = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
const values = await form.validateFields()
|
||||
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)) {
|
||||
messageApi.warning('该分类下已存在相同理由')
|
||||
messageApi.warning(t('reasons.reasonExists'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,43 +70,43 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
delta: Number(values.delta)
|
||||
})
|
||||
if (res.success) {
|
||||
messageApi.success('添加成功')
|
||||
messageApi.success(t('reasons.addSuccess'))
|
||||
setVisible(false)
|
||||
form.resetFields()
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
} else {
|
||||
messageApi.error(res.message || '添加失败')
|
||||
messageApi.error(res.message || t('reasons.addFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteReason(id)
|
||||
if (res.success) {
|
||||
messageApi.success('删除成功')
|
||||
messageApi.success(t('reasons.deleteSuccess'))
|
||||
fetchReasons()
|
||||
emitDataUpdated('reasons')
|
||||
} else {
|
||||
messageApi.error(res.message || '删除失败')
|
||||
messageApi.error(res.message || t('reasons.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<reason> = [
|
||||
{
|
||||
title: '分类',
|
||||
title: t('reasons.category'),
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
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',
|
||||
key: 'delta',
|
||||
width: 100,
|
||||
@@ -120,17 +122,17 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确认删除该理由?"
|
||||
title={t('reasons.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<Button type="link" danger disabled={!canEdit}>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
@@ -141,9 +143,9 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div style={{ padding: '24px' }}>
|
||||
{contextHolder}
|
||||
<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)}>
|
||||
添加预设理由
|
||||
{t('reasons.addReason')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -158,31 +160,35 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加理由"
|
||||
title={t('reasons.addTitle')}
|
||||
open={visible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setVisible(false)}
|
||||
okText="添加"
|
||||
cancelText="取消"
|
||||
okText={t('reasons.addConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="horizontal" labelCol={{ span: 6 }} wrapperCol={{ span: 18 }}>
|
||||
<Form.Item label="分类" name="category" initialValue="其他">
|
||||
<Input placeholder="例如: 学习, 纪律" />
|
||||
<Form.Item
|
||||
label={t('reasons.category')}
|
||||
name="category"
|
||||
initialValue={t('reasons.others')}
|
||||
>
|
||||
<Input placeholder={t('reasons.categoryPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="理由内容"
|
||||
label={t('reasons.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
|
||||
label="预设分值"
|
||||
label={t('reasons.presetPoints')}
|
||||
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>
|
||||
</Modal>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Popconfirm
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { UndoOutlined } from '@ant-design/icons'
|
||||
import { match } from 'pinyin-pro'
|
||||
|
||||
@@ -79,6 +80,7 @@ interface scoreEvent {
|
||||
}
|
||||
|
||||
export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [students, setStudents] = useState<student[]>([])
|
||||
const [reasons, setReasons] = useState<reason[]>([])
|
||||
const [events, setEvents] = useState<scoreEvent[]>([])
|
||||
@@ -133,7 +135,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleSubmit = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
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]
|
||||
if (!studentNames || studentNames.length === 0 || !values.reason_content) {
|
||||
messageApi.warning('请填写完整信息')
|
||||
messageApi.warning(t('score.pleaseEnterInfo'))
|
||||
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
|
||||
|
||||
if (!hasDeltaInput && !selectedReason) {
|
||||
messageApi.warning('请填写分值或选择预设理由')
|
||||
messageApi.warning(t('score.pleaseEnterPoints'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,7 +180,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
if (successCount === studentNames.length) {
|
||||
messageApi.success(`已为 ${successCount} 名学生提交积分`)
|
||||
messageApi.success(t('score.batchSuccess', { count: successCount }))
|
||||
form.setFieldsValue({
|
||||
student_name: [],
|
||||
delta: undefined,
|
||||
@@ -189,7 +191,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
} else {
|
||||
messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`)
|
||||
messageApi.warning(
|
||||
t('score.batchPartial', { success: successCount, total: studentNames.length })
|
||||
)
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
}
|
||||
@@ -201,23 +205,23 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleUndo = async (uuid: string) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteEvent(uuid)
|
||||
if (res.success) {
|
||||
messageApi.success('已撤销操作')
|
||||
messageApi.success(t('score.undoSuccess'))
|
||||
fetchData()
|
||||
emitDataUpdated('events')
|
||||
} else {
|
||||
messageApi.error(res.message || '撤销失败')
|
||||
messageApi.error(res.message || t('score.undoFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
key: 'delta',
|
||||
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>
|
||||
)
|
||||
},
|
||||
{ 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',
|
||||
key: 'event_time',
|
||||
width: 160,
|
||||
render: (time: string) => new Date(time).toLocaleString()
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title="确定要撤销这条记录吗?学生积分将回滚。"
|
||||
onConfirm={() => handleUndo(row.uuid)}
|
||||
>
|
||||
<Popconfirm title={t('score.undoConfirm')} onConfirm={() => handleUndo(row.uuid)}>
|
||||
<Button type="link" danger disabled={!canEdit} icon={<UndoOutlined />}>
|
||||
撤销
|
||||
{t('score.undo')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
@@ -253,38 +259,38 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{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)' }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ type: 'add' }}>
|
||||
<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
|
||||
mode="multiple"
|
||||
showSearch
|
||||
placeholder="请选择或搜索学生"
|
||||
placeholder={t('score.pleaseSelectStudent')}
|
||||
filterOption={(input, option) => matchStudentName(getOptionLabel(option), input)}
|
||||
options={students.map((s) => ({ label: s.name, value: s.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="分数">
|
||||
<Form.Item label={t('score.points')}>
|
||||
<Space>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Radio.Group optionType="button" buttonStyle="solid">
|
||||
<Radio.Button value="add">加分</Radio.Button>
|
||||
<Radio.Button value="subtract">扣分</Radio.Button>
|
||||
<Radio.Button value="add">{t('score.addPoints')}</Radio.Button>
|
||||
<Radio.Button value="subtract">{t('score.deductPoints')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="delta" noStyle>
|
||||
<InputNumber min={1} placeholder="分值" style={{ width: '120px' }} />
|
||||
<InputNumber min={1} placeholder={t('score.points')} style={{ width: '120px' }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快捷理由" name="reason_id">
|
||||
<Form.Item label={t('score.quickReason')} name="reason_id">
|
||||
<Select
|
||||
placeholder="选择预设理由"
|
||||
placeholder={t('score.selectReason')}
|
||||
onChange={(v) => {
|
||||
const id = Number(v)
|
||||
if (!Number.isFinite(id)) return
|
||||
@@ -315,8 +321,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="理由内容" name="reason_content">
|
||||
<Input placeholder="手动输入或选择快捷理由" />
|
||||
<Form.Item label={t('score.reasonContent')} name="reason_content">
|
||||
<Input placeholder={t('score.reasonPlaceholder')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
@@ -329,14 +335,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
loading={submitLoading}
|
||||
style={{ width: '200px' }}
|
||||
>
|
||||
确认提交
|
||||
{t('score.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<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
|
||||
dataSource={events}
|
||||
columns={columns}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd'
|
||||
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from '../i18n'
|
||||
|
||||
type permissionLevel = 'admin' | 'points' | 'view'
|
||||
type appSettings = {
|
||||
@@ -11,7 +13,9 @@ type appSettings = {
|
||||
}
|
||||
|
||||
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState('appearance')
|
||||
const [currentLanguage, setCurrentLanguage] = useState<AppLanguage>(getCurrentLanguage())
|
||||
const [settings, setSettings] = useState<appSettings>({
|
||||
is_wizard_completed: false,
|
||||
log_level: 'info',
|
||||
@@ -64,10 +68,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
<Tag
|
||||
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>
|
||||
)
|
||||
}, [permission])
|
||||
}, [permission, t])
|
||||
|
||||
const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => {
|
||||
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)
|
||||
setLogsLoading(false)
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || '读取日志失败')
|
||||
messageApi.error(res.message || t('settings.data.readLogsFailed'))
|
||||
return
|
||||
}
|
||||
setLogsText((res.data || []).join('\n'))
|
||||
@@ -121,12 +129,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.queryLogs(5000)
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || '读取日志失败')
|
||||
messageApi.error(res.message || t('settings.data.readLogsFailed'))
|
||||
return
|
||||
}
|
||||
const dateTime = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`)
|
||||
messageApi.success('日志已导出')
|
||||
messageApi.success(t('settings.data.logsExported'))
|
||||
}
|
||||
|
||||
const downloadTextFile = (filename: string, text: string) => {
|
||||
@@ -152,7 +160,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.exportDataJson()
|
||||
if (!res.success || !res.data) {
|
||||
messageApi.error(res.message || '导出失败')
|
||||
messageApi.error(res.message || t('settings.data.exportFailed'))
|
||||
return
|
||||
}
|
||||
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.click()
|
||||
URL.revokeObjectURL(url)
|
||||
messageApi.success('导出成功')
|
||||
messageApi.success(t('settings.data.exportSuccess'))
|
||||
}
|
||||
|
||||
const importJson = async (file: File) => {
|
||||
@@ -170,10 +178,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const text = await file.text()
|
||||
const res = await (window as any).api.importDataJson(text)
|
||||
if (res.success) {
|
||||
messageApi.success('导入成功,正在刷新')
|
||||
messageApi.success(t('settings.data.importSuccess'))
|
||||
setTimeout(() => window.location.reload(), 300)
|
||||
} 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('')
|
||||
await loadAll()
|
||||
if (res.data?.recoveryString) {
|
||||
showRecoveryDialog('找回字符串(请妥善保存)', res.data.recoveryString)
|
||||
showRecoveryDialog(
|
||||
t('settings.security.recoveryString') + ` (${t('recovery.hint')})`,
|
||||
res.data.recoveryString
|
||||
)
|
||||
} else {
|
||||
messageApi.success('密码已更新')
|
||||
messageApi.success(t('settings.security.saved'))
|
||||
}
|
||||
} 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
|
||||
const res = await (window as any).api.authGenerateRecovery()
|
||||
if (!res.success || !res.data?.recoveryString) {
|
||||
messageApi.error(res.message || '生成失败')
|
||||
messageApi.error(res.message || t('settings.security.generateFailed'))
|
||||
return
|
||||
}
|
||||
await loadAll()
|
||||
showRecoveryDialog('新的找回字符串(请妥善保存)', res.data.recoveryString)
|
||||
showRecoveryDialog(
|
||||
t('settings.security.newRecoveryString', 'New recovery string (please save it)'),
|
||||
res.data.recoveryString
|
||||
)
|
||||
}
|
||||
|
||||
const resetByRecovery = async () => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.authResetByRecovery(recoveryToReset)
|
||||
if (!res.success || !res.data?.recoveryString) {
|
||||
messageApi.error(res.message || '重置失败')
|
||||
messageApi.error(res.message || t('settings.security.resetFailed'))
|
||||
return
|
||||
}
|
||||
setRecoveryToReset('')
|
||||
await loadAll()
|
||||
showRecoveryDialog('密码已清空,新的找回字符串', res.data.recoveryString)
|
||||
showRecoveryDialog(
|
||||
t('settings.security.passwordClearedNewRecovery', 'Password cleared, new recovery string'),
|
||||
res.data.recoveryString
|
||||
)
|
||||
}
|
||||
|
||||
const clearAllPasswords = () => {
|
||||
@@ -230,11 +247,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const res = await (window as any).api.authClearAll()
|
||||
setClearLoading(false)
|
||||
if (res.success) {
|
||||
messageApi.success('已清空')
|
||||
messageApi.success(t('settings.security.cleared'))
|
||||
await loadAll()
|
||||
setClearDialogVisible(false)
|
||||
} 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 () => {
|
||||
if (!(window as any).api) return
|
||||
if (!pgConnectionString) {
|
||||
messageApi.warning('请输入 PostgreSQL 连接字符串')
|
||||
messageApi.warning(t('settings.database.enterConnectionString'))
|
||||
return
|
||||
}
|
||||
setPgTestLoading(true)
|
||||
try {
|
||||
const res = await (window as any).api.dbTestConnection(pgConnectionString)
|
||||
if (res.success && res.data?.success) {
|
||||
messageApi.success('连接测试成功')
|
||||
messageApi.success(t('settings.database.connectionTestSuccess'))
|
||||
} else {
|
||||
messageApi.error(res.data?.error || res.message || '连接测试失败')
|
||||
messageApi.error(
|
||||
res.data?.error || res.message || t('settings.database.connectionTestFailed')
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '连接测试失败')
|
||||
messageApi.error(e?.message || t('settings.database.connectionTestFailed'))
|
||||
} finally {
|
||||
setPgTestLoading(false)
|
||||
}
|
||||
@@ -270,14 +289,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const res = await (window as any).api.dbSwitchConnection(pgConnectionString)
|
||||
if (res.success) {
|
||||
messageApi.success(
|
||||
`已切换到 ${res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'} 数据库`
|
||||
t('settings.database.switchedTo', {
|
||||
type: res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'
|
||||
})
|
||||
)
|
||||
await loadAll()
|
||||
} else {
|
||||
messageApi.error(res.message || '切换失败')
|
||||
messageApi.error(res.message || t('settings.database.switchFailed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '切换失败')
|
||||
messageApi.error(e?.message || t('settings.database.switchFailed'))
|
||||
} finally {
|
||||
setPgSwitchLoading(false)
|
||||
}
|
||||
@@ -289,14 +310,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
try {
|
||||
const res = await (window as any).api.dbSwitchConnection('')
|
||||
if (res.success) {
|
||||
messageApi.success('已切换到本地 SQLite 数据库')
|
||||
messageApi.success(t('settings.database.switchedToSQLite'))
|
||||
setPgConnectionString('')
|
||||
await loadAll()
|
||||
} else {
|
||||
messageApi.error(res.message || '切换失败')
|
||||
messageApi.error(res.message || t('settings.database.switchFailed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '切换失败')
|
||||
messageApi.error(e?.message || t('settings.database.switchFailed'))
|
||||
} finally {
|
||||
setPgSwitchLoading(false)
|
||||
}
|
||||
@@ -307,15 +328,40 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'appearance',
|
||||
label: '外观',
|
||||
label: t('settings.tabs.appearance'),
|
||||
children: (
|
||||
<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 />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label="界面缩放">
|
||||
<Form.Item label={t('settings.interfaceZoom')}>
|
||||
<Select
|
||||
value={settings.window_zoom || '1.0'}
|
||||
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)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, window_zoom: next }))
|
||||
messageApi.success('界面缩放已更新')
|
||||
messageApi.success(t('settings.general.saved'))
|
||||
} else {
|
||||
messageApi.error(res.message || '更新失败')
|
||||
messageApi.error(res.message || t('settings.general.saveFailed'))
|
||||
}
|
||||
}}
|
||||
style={{ width: '320px' }}
|
||||
disabled={!canAdmin}
|
||||
options={[
|
||||
{ value: '0.7', label: '70% (较小)' },
|
||||
{ value: '0.7', label: t('settings.zoomOptions.small70') },
|
||||
{ value: '0.8', label: '80%' },
|
||||
{ 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.2', label: '120%' },
|
||||
{ value: '1.3', label: '130%' },
|
||||
{ value: '1.5', label: '150% (较大)' }
|
||||
{ value: '1.5', label: t('settings.zoomOptions.large150') }
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||
>
|
||||
调节应用界面的整体大小。
|
||||
{t('settings.zoomHint')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -354,7 +400,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: '安全',
|
||||
label: t('settings.tabs.security'),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
@@ -365,16 +411,25 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontWeight: 600 }}>密码保护系统</div>
|
||||
<div style={{ fontWeight: 600 }}>{t('settings.security.title')}</div>
|
||||
<Space>
|
||||
<Tag color={securityStatus?.hasAdminPassword ? 'success' : 'default'}>
|
||||
管理密码 {securityStatus?.hasAdminPassword ? '已设置' : '未设置'}
|
||||
{t('settings.security.adminPassword')}{' '}
|
||||
{securityStatus?.hasAdminPassword
|
||||
? t('settings.security.set')
|
||||
: t('settings.security.notSet')}
|
||||
</Tag>
|
||||
<Tag color={securityStatus?.hasPointsPassword ? 'success' : 'default'}>
|
||||
积分密码 {securityStatus?.hasPointsPassword ? '已设置' : '未设置'}
|
||||
{t('settings.security.pointsPassword')}{' '}
|
||||
{securityStatus?.hasPointsPassword
|
||||
? t('settings.security.set')
|
||||
: t('settings.security.notSet')}
|
||||
</Tag>
|
||||
<Tag color={securityStatus?.hasRecoveryString ? 'success' : 'default'}>
|
||||
找回字符串 {securityStatus?.hasRecoveryString ? '已生成' : '未生成'}
|
||||
{t('settings.security.recoveryString')}{' '}
|
||||
{securityStatus?.hasRecoveryString
|
||||
? t('settings.security.generated')
|
||||
: t('settings.security.notGenerated')}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -382,43 +437,43 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
<Divider />
|
||||
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label="管理密码">
|
||||
<Form.Item label={t('settings.security.adminPassword')}>
|
||||
<Input
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
placeholder="输入6位数字(留空则不修改)"
|
||||
placeholder={t('settings.security.adminPasswordPlaceholder')}
|
||||
maxLength={6}
|
||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="积分密码">
|
||||
<Form.Item label={t('settings.security.pointsPassword')}>
|
||||
<Input
|
||||
value={pointsPassword}
|
||||
onChange={(e) => setPointsPassword(e.target.value)}
|
||||
placeholder="输入6位数字(留空则不修改)"
|
||||
placeholder={t('settings.security.pointsPasswordPlaceholder')}
|
||||
maxLength={6}
|
||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="操作">
|
||||
<Form.Item label={t('common.operation')}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={savePasswords}
|
||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||
>
|
||||
保存密码
|
||||
{t('settings.security.savePassword')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={generateRecovery}
|
||||
disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)}
|
||||
>
|
||||
生成找回字符串
|
||||
{t('settings.security.generateRecovery')}
|
||||
</Button>
|
||||
<Button danger onClick={clearAllPasswords} disabled={!canAdmin}>
|
||||
清空所有密码
|
||||
{t('settings.security.clearAllPasswords')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
@@ -426,20 +481,22 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<Input
|
||||
value={recoveryToReset}
|
||||
onChange={(e) => setRecoveryToReset(e.target.value)}
|
||||
placeholder="输入找回字符串"
|
||||
placeholder={t('settings.security.recoveryPlaceholder')}
|
||||
style={{ width: '420px' }}
|
||||
/>
|
||||
<Button type="primary" onClick={resetByRecovery}>
|
||||
重置密码
|
||||
{t('settings.security.resetPassword')}
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
重置会清空管理/积分密码,并生成新的找回字符串。
|
||||
{t('settings.security.resetHint')}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
@@ -447,7 +504,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
},
|
||||
{
|
||||
key: 'database',
|
||||
label: '数据库连接',
|
||||
label: t('settings.database.title'),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
@@ -457,15 +514,19 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>当前数据库状态</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||
{t('settings.database.currentStatus')}
|
||||
</div>
|
||||
<Space>
|
||||
<Tag color={pgConnectionStatus.type === 'postgresql' ? 'blue' : 'green'}>
|
||||
{pgConnectionStatus.type === 'postgresql'
|
||||
? 'PostgreSQL 远程数据库'
|
||||
: 'SQLite 本地数据库'}
|
||||
? t('settings.database.postgresqlRemote')
|
||||
: t('settings.database.sqliteLocal')}
|
||||
</Tag>
|
||||
<Tag color={pgConnectionStatus.connected ? 'success' : 'error'}>
|
||||
{pgConnectionStatus.connected ? '已连接' : '未连接'}
|
||||
{pgConnectionStatus.connected
|
||||
? t('settings.database.connected')
|
||||
: t('settings.database.disconnected')}
|
||||
</Tag>
|
||||
{pgConnectionStatus.error && (
|
||||
<span style={{ color: 'var(--ant-color-error, #ff4d4f)', fontSize: '12px' }}>
|
||||
@@ -482,11 +543,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>PostgreSQL 远程连接</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||
{t('settings.database.postgresqlConnection')}
|
||||
</div>
|
||||
<div
|
||||
style={{ marginBottom: '12px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
|
||||
>
|
||||
输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。
|
||||
{t('settings.database.connectionHint')}
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: '12px' }}>
|
||||
<Input
|
||||
@@ -501,13 +564,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
loading={pgTestLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
>
|
||||
测试连接
|
||||
{t('settings.database.testConnection')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<div
|
||||
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>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -516,25 +579,27 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || !pgConnectionString}
|
||||
>
|
||||
切换到 PostgreSQL
|
||||
{t('settings.database.switchToPostgreSQL')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={switchToSQLite}
|
||||
loading={pgSwitchLoading}
|
||||
disabled={!canAdmin || pgConnectionStatus.type === 'sqlite'}
|
||||
>
|
||||
切换到本地 SQLite
|
||||
{t('settings.database.switchToSQLite')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<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' }}>
|
||||
<p>• 使用 PostgreSQL 远程数据库可以实现多端数据同步。</p>
|
||||
<p>• 系统内置操作队列机制,确保多端同时操作时数据一致性。</p>
|
||||
<p>• 切换数据库后需要重启应用以生效。</p>
|
||||
<p>• 建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。</p>
|
||||
<p>{t('settings.database.syncPoint1')}</p>
|
||||
<p>{t('settings.database.syncPoint2')}</p>
|
||||
<p>{t('settings.database.syncPoint3')}</p>
|
||||
<p>{t('settings.database.syncPoint4')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
@@ -542,11 +607,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
},
|
||||
{
|
||||
key: 'data',
|
||||
label: '数据管理',
|
||||
label: t('settings.data.title'),
|
||||
children: (
|
||||
<>
|
||||
<Card
|
||||
title="结算"
|
||||
title={t('settings.data.settlement')}
|
||||
style={{
|
||||
backgroundColor: 'var(--ss-card-bg)',
|
||||
color: 'var(--ss-text-main)',
|
||||
@@ -560,10 +625,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
loading={settleLoading}
|
||||
onClick={confirmSettlement}
|
||||
>
|
||||
结算并重新开始
|
||||
{t('settings.data.settlementAndRestart')}
|
||||
</Button>
|
||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
将当前未结算记录划分为一个阶段,并将所有学生积分清零。
|
||||
{t('settings.data.settlementHint')}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
@@ -575,12 +640,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>导入 / 导出</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>
|
||||
{t('settings.data.importExport')}
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={exportJson}>
|
||||
导出 JSON
|
||||
{t('settings.data.exportJson')}
|
||||
</Button>
|
||||
<Button onClick={() => importInputRef.current?.click()}>
|
||||
{t('settings.data.importJson')}
|
||||
</Button>
|
||||
<Button onClick={() => importInputRef.current?.click()}>导入 JSON</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
@@ -594,16 +663,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。
|
||||
{t('settings.data.importHint')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="日志"
|
||||
title={t('settings.data.logs')}
|
||||
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
|
||||
>
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label="日志级别">
|
||||
<Form.Item label={t('settings.data.logLevel')}>
|
||||
<Select
|
||||
value={settings.log_level}
|
||||
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)
|
||||
if (res.success) {
|
||||
setSettings((prev) => ({ ...prev, log_level: next }))
|
||||
messageApi.success('日志级别已更新')
|
||||
messageApi.success(t('settings.general.saved'))
|
||||
} else {
|
||||
messageApi.error(res.message || '更新失败')
|
||||
}
|
||||
}}
|
||||
style={{ width: '320px' }}
|
||||
options={[
|
||||
{ value: 'debug', label: 'DEBUG (调试)' },
|
||||
{ value: 'info', label: 'INFO (信息)' },
|
||||
{ value: 'warn', label: 'WARN (警告)' },
|
||||
{ value: 'error', label: 'ERROR (错误)' }
|
||||
{ value: 'debug', label: t('settings.data.logLevels.debug') },
|
||||
{ value: 'info', label: t('settings.data.logLevels.info') },
|
||||
{ value: 'warn', label: t('settings.data.logLevels.warn') },
|
||||
{ value: 'error', label: t('settings.data.logLevels.error') }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="日志操作">
|
||||
<Form.Item label={t('settings.data.logOperation')}>
|
||||
<Space>
|
||||
<Button loading={logsLoading} onClick={showLogs}>
|
||||
查看日志
|
||||
{t('settings.data.viewLogs')}
|
||||
</Button>
|
||||
<Button onClick={exportLogs}>导出日志</Button>
|
||||
<Button onClick={exportLogs}>{t('settings.data.exportLogs')}</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={async () => {
|
||||
if (!(window as any).api) return
|
||||
const res = await (window as any).api.clearLogs()
|
||||
if (res.success) messageApi.success('日志已清空')
|
||||
else messageApi.error(res.message || '清空失败')
|
||||
if (res.success) messageApi.success(t('settings.data.logsCleared'))
|
||||
else messageApi.error(res.message || t('settings.data.clearFailed'))
|
||||
}}
|
||||
>
|
||||
清空日志
|
||||
@@ -652,17 +721,17 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
label: 'URL 链接',
|
||||
label: t('settings.url.title'),
|
||||
children: (
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>
|
||||
URL 协议 (secscore://)
|
||||
{t('settings.url.protocol')}
|
||||
</div>
|
||||
<Divider />
|
||||
<div
|
||||
style={{ marginBottom: '12px', fontSize: '13px', color: 'var(--ss-text-secondary)' }}
|
||||
>
|
||||
可以通过 URL 链接唤起 SecScore 并执行操作,例如:
|
||||
{t('settings.url.description')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -689,49 +758,53 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const res = await (window as any).api.registerUrlProtocol()
|
||||
setUrlRegisterLoading(false)
|
||||
if (res && res.success) {
|
||||
messageApi.success('URL 协议已注册')
|
||||
messageApi.success(t('settings.url.registered'))
|
||||
} else {
|
||||
messageApi.error(res?.message || '注册失败')
|
||||
messageApi.error(res?.message || t('settings.url.registerFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
注册 URL 协议
|
||||
{t('settings.url.register')}
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ marginTop: '8px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
需要安装版 SecScore,开发模式下可能无效。
|
||||
{t('settings.url.installerRequired')}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
label: '关于',
|
||||
label: t('settings.about.title'),
|
||||
children: (
|
||||
<Card style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '8px' }}>SecScore</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)', marginBottom: '16px' }}>
|
||||
教育积分管理
|
||||
{t('settings.about.appName')}
|
||||
</div>
|
||||
<Divider />
|
||||
<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 style={{ color: 'var(--ss-text-secondary)' }}>版权</div>
|
||||
<div>{'SecScore遵循GPL3.0协议——' + 'CopyRight © 2025-' + currentYear + ' SECTL'}</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>{t('settings.about.copyright')}</div>
|
||||
<div>{'CopyRight © 2025-' + currentYear + ' SECTL'}</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>Electron</div>
|
||||
<div>{(window as any).electron?.process?.versions?.electron || '-'}</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>Chromium</div>
|
||||
<div>{(window as any).electron?.process?.versions?.chrome || '-'}</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>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>
|
||||
<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>
|
||||
</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>环境</div>
|
||||
<div style={{ color: 'var(--ss-text-secondary)' }}>
|
||||
{t('settings.about.environment')}
|
||||
</div>
|
||||
<div>
|
||||
<Tag>{import.meta.env.DEV ? 'Development' : 'Production'}</Tag>
|
||||
</div>
|
||||
@@ -743,7 +816,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
;(window as any).api?.toggleDevTools()
|
||||
}}
|
||||
>
|
||||
切换开发者工具
|
||||
{t('settings.about.toggleDevTools')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -769,7 +842,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
marginBottom: '16px'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>系统设置</h2>
|
||||
<h2 style={{ margin: 0 }}>{t('settings.title')}</h2>
|
||||
{permissionTag}
|
||||
</div>
|
||||
|
||||
@@ -781,7 +854,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
onCancel={() => setRecoveryDialogVisible(false)}
|
||||
footer={
|
||||
<Button type="primary" onClick={() => setRecoveryDialogVisible(false)}>
|
||||
我已保存
|
||||
{t('recovery.saved')}
|
||||
</Button>
|
||||
}
|
||||
width="70%"
|
||||
@@ -811,16 +884,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
建议导出后离线保存,遗失将无法找回。
|
||||
{t('recovery.exportHint')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="系统日志 (最后200条)"
|
||||
title={t('settings.data.systemLogs')}
|
||||
open={logsDialogVisible}
|
||||
onCancel={() => setLogsDialogVisible(false)}
|
||||
footer={<Button onClick={() => setLogsDialogVisible(false)}>关闭</Button>}
|
||||
footer={<Button onClick={() => setLogsDialogVisible(false)}>{t('common.close')}</Button>}
|
||||
width="80%"
|
||||
>
|
||||
<div
|
||||
@@ -836,12 +909,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
padding: '10px'
|
||||
}}
|
||||
>
|
||||
{logsText || '暂无日志'}
|
||||
{logsText || t('settings.data.noLogs')}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="确认结算并重新开始?"
|
||||
title={t('settings.data.confirmSettlement')}
|
||||
open={settleDialogVisible}
|
||||
onCancel={() => !settleLoading && setSettleDialogVisible(false)}
|
||||
onOk={async () => {
|
||||
@@ -850,20 +923,20 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
const res = await (window as any).api.createSettlement()
|
||||
setSettleLoading(false)
|
||||
if (res.success && res.data) {
|
||||
messageApi.success('结算成功,已重新开始积分')
|
||||
messageApi.success(t('settings.data.settlementSuccess'))
|
||||
emitDataUpdated('all')
|
||||
setSettleDialogVisible(false)
|
||||
} else {
|
||||
messageApi.error(res.message || '结算失败')
|
||||
messageApi.error(res.message || t('settings.data.settlementFailed'))
|
||||
}
|
||||
}}
|
||||
confirmLoading={settleLoading}
|
||||
okText="结算"
|
||||
okText={t('settings.data.settle')}
|
||||
>
|
||||
<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' }}>
|
||||
学生名单不变;结算后的历史在“结算历史”页面查看。
|
||||
{t('settings.data.settlementConfirm2')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, message, Space, Table } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface settlementSummary {
|
||||
id: number
|
||||
@@ -15,6 +16,7 @@ interface settlementLeaderboardRow {
|
||||
}
|
||||
|
||||
export const SettlementHistory: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [settlements, setSettlements] = useState<settlementSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -40,7 +42,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.querySettlements()
|
||||
if (!res.success) {
|
||||
messageApi.error(res.message || '查询失败')
|
||||
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||
return
|
||||
}
|
||||
setSettlements(res.data || [])
|
||||
@@ -71,7 +73,7 @@ export const SettlementHistory: React.FC = () => {
|
||||
try {
|
||||
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
|
||||
if (!res.success || !res.data) {
|
||||
messageApi.error(res.message || '查询失败')
|
||||
messageApi.error(res.message || t('settlements.queryFailed'))
|
||||
return
|
||||
}
|
||||
setSelectedSettlement(res.data.settlement)
|
||||
@@ -86,15 +88,15 @@ export const SettlementHistory: React.FC = () => {
|
||||
const columns: ColumnsType<settlementLeaderboardRow> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '排名',
|
||||
title: t('settlements.rank'),
|
||||
key: 'rank',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
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',
|
||||
key: 'score',
|
||||
width: 120,
|
||||
@@ -116,9 +118,11 @@ export const SettlementHistory: React.FC = () => {
|
||||
setRows([])
|
||||
}}
|
||||
>
|
||||
返回
|
||||
{t('settlements.back')}
|
||||
</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' }}>
|
||||
{formatRange(selectedSettlement)}
|
||||
</div>
|
||||
@@ -142,7 +146,9 @@ export const SettlementHistory: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
{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
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -155,7 +161,9 @@ export const SettlementHistory: React.FC = () => {
|
||||
key={s.id}
|
||||
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
|
||||
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', marginBottom: '12px' }}
|
||||
>
|
||||
@@ -163,17 +171,19 @@ export const SettlementHistory: React.FC = () => {
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => openSettlement(s.id)}>
|
||||
查看排行榜
|
||||
{t('settlements.viewLeaderboard')}
|
||||
</Button>
|
||||
<div style={{ fontSize: '12px', color: 'var(--ss-text-secondary)' }}>
|
||||
记录数: {s.event_count}
|
||||
{t('settlements.recordCount', { count: s.event_count })}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{!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>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UploadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import appLogo from '../assets/logoHD.svg'
|
||||
|
||||
const { Sider } = Layout
|
||||
@@ -28,6 +29,7 @@ interface DbStatus {
|
||||
}
|
||||
|
||||
export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const [dbStatus, setDbStatus] = useState<DbStatus>({ type: 'sqlite', connected: true })
|
||||
const [syncLoading, setSyncLoading] = useState(false)
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
@@ -78,17 +80,17 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
|
||||
const statusRes = await (window as any).api.dbGetStatus()
|
||||
if (!statusRes.success || !statusRes.data) {
|
||||
messageApi.error('获取数据库状态失败')
|
||||
messageApi.error(t('sidebar.getDbStatusFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
if (statusRes.data.type !== 'postgresql') {
|
||||
messageApi.error('当前不是远程数据库模式,请重启应用后重试')
|
||||
messageApi.error(t('sidebar.notRemoteMode'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!statusRes.data.connected) {
|
||||
messageApi.error('数据库未连接')
|
||||
messageApi.error(t('sidebar.dbNotConnected'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,12 +98,12 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
try {
|
||||
const res = await (window as any).api.dbSync()
|
||||
if (res.success && res.data?.success) {
|
||||
messageApi.success('同步成功')
|
||||
messageApi.success(t('sidebar.syncSuccess'))
|
||||
} else {
|
||||
messageApi.error(res.data?.message || res.message || '同步失败')
|
||||
messageApi.error(res.data?.message || res.message || t('sidebar.syncFailed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '同步失败')
|
||||
messageApi.error(e?.message || t('sidebar.syncFailed'))
|
||||
} finally {
|
||||
setForceSyncLoading(false)
|
||||
}
|
||||
@@ -111,44 +113,44 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
{
|
||||
key: 'home',
|
||||
icon: <HomeOutlined />,
|
||||
label: '主页'
|
||||
label: t('sidebar.home')
|
||||
},
|
||||
{
|
||||
key: 'students',
|
||||
icon: <UserOutlined />,
|
||||
label: '学生管理',
|
||||
label: t('sidebar.students'),
|
||||
disabled: permission !== 'admin'
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
icon: <HistoryOutlined />,
|
||||
label: '积分管理'
|
||||
label: t('sidebar.score')
|
||||
},
|
||||
{
|
||||
key: 'auto-score',
|
||||
icon: <SyncOutlined />,
|
||||
label: '自动加分'
|
||||
label: t('sidebar.autoScore')
|
||||
},
|
||||
{
|
||||
key: 'leaderboard',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '排行榜'
|
||||
label: t('sidebar.leaderboard')
|
||||
},
|
||||
{
|
||||
key: 'settlements',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '结算历史'
|
||||
label: t('sidebar.settlements')
|
||||
},
|
||||
{
|
||||
key: 'reasons',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: '理由管理',
|
||||
label: t('sidebar.reasons'),
|
||||
disabled: permission !== 'admin'
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
label: t('sidebar.settings'),
|
||||
disabled: permission !== 'admin'
|
||||
}
|
||||
]
|
||||
@@ -198,7 +200,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
marginTop: '4px'
|
||||
}}
|
||||
>
|
||||
教育积分管理
|
||||
{t('settings.about.appName')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,13 +233,15 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space size={4}>
|
||||
<CloudOutlined style={{ fontSize: '12px', color: '#1890ff' }} />
|
||||
<span style={{ fontSize: '12px', fontWeight: 500 }}>远程数据库</span>
|
||||
<span style={{ fontSize: '12px', fontWeight: 500 }}>{t('sidebar.remoteDb')}</span>
|
||||
</Space>
|
||||
<Tag
|
||||
color={dbStatus.connected ? 'success' : 'error'}
|
||||
style={{ margin: 0, fontSize: '10px' }}
|
||||
>
|
||||
{dbStatus.connected ? '已连接' : '未连接'}
|
||||
{dbStatus.connected
|
||||
? t('settings.database.connected')
|
||||
: t('settings.database.disconnected')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
@@ -254,7 +258,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
color: 'var(--ss-text-secondary)'
|
||||
}}
|
||||
>
|
||||
刷新状态
|
||||
{t('sidebar.refreshStatus')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -270,7 +274,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
|
||||
padding: '0 8px'
|
||||
}}
|
||||
>
|
||||
立即同步
|
||||
{t('sidebar.syncNow')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Table, Button, Space, message, Modal, Form, Input, Tag, Pagination } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TagEditorDialog } from './TagEditorDialog'
|
||||
|
||||
const createXlsxWorker = () => {
|
||||
@@ -18,6 +19,7 @@ interface student {
|
||||
}
|
||||
|
||||
export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<student[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -105,31 +107,31 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleAdd = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
if (!values.name) {
|
||||
messageApi.warning('请输入姓名')
|
||||
messageApi.warning(t('students.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
const name = values.name.trim()
|
||||
if (data.some((s) => s.name === name)) {
|
||||
messageApi.warning('学生姓名已存在')
|
||||
messageApi.warning(t('students.nameExists'))
|
||||
return
|
||||
}
|
||||
|
||||
const res = await (window as any).api.createStudent({ ...values, name })
|
||||
if (res.success) {
|
||||
messageApi.success('添加成功')
|
||||
messageApi.success(t('students.addSuccess'))
|
||||
setVisible(false)
|
||||
form.resetFields()
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
} else {
|
||||
messageApi.error(res.message || '添加失败')
|
||||
messageApi.error(res.message || t('students.addFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
@@ -149,22 +151,22 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
const res = await (window as any).api.deleteStudent(id)
|
||||
if (res.success) {
|
||||
messageApi.success('删除成功')
|
||||
messageApi.success(t('students.deleteSuccess'))
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
} else {
|
||||
messageApi.error(res.message || '删除失败')
|
||||
messageApi.error(res.message || t('students.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenTagEditor = (student: student) => {
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
setEditingStudent(student)
|
||||
@@ -183,12 +185,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
fetchStudents()
|
||||
emitDataUpdated('students')
|
||||
} else {
|
||||
const errorMsg = res?.message || '保存标签失败'
|
||||
const errorMsg = res?.message || t('students.tagSaveFailed')
|
||||
messageApi.error(errorMsg)
|
||||
}
|
||||
} catch (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) => {
|
||||
if (!xlsxWorkerRef.current) {
|
||||
messageApi.error('Worker 未初始化')
|
||||
messageApi.error(t('students.workerNotReady'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,7 +229,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setImportVisible(false)
|
||||
setXlsxLoading(false)
|
||||
} else if (event.data.type === 'error') {
|
||||
messageApi.error(event.data.error || '解析 xlsx 失败')
|
||||
messageApi.error(event.data.error || t('students.parseXlsxFailed'))
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
|
||||
@@ -235,7 +237,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
|
||||
xlsxWorkerRef.current.addEventListener('message', handleMessage)
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '解析 xlsx 失败')
|
||||
messageApi.error(e?.message || t('students.parseXlsxFailed'))
|
||||
setXlsxLoading(false)
|
||||
}
|
||||
}
|
||||
@@ -297,7 +299,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const extractNamesFromAoa = (aoa: any[][], colIdx: number) => {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const banned = new Set(['姓名', 'name', '名字'])
|
||||
const banned = new Set([t('students.name').toLowerCase(), 'name', t('students.name')])
|
||||
for (const row of aoa) {
|
||||
const raw = row?.[colIdx]
|
||||
const name = String(raw ?? '').trim()
|
||||
@@ -313,17 +315,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const handleConfirmXlsxImport = async () => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error('当前为只读权限')
|
||||
messageApi.error(t('common.readOnly'))
|
||||
return
|
||||
}
|
||||
if (xlsxSelectedCol == null) {
|
||||
messageApi.warning('请先点击选择"姓名列"')
|
||||
messageApi.warning(t('students.selectNameColFirst'))
|
||||
return
|
||||
}
|
||||
|
||||
const names = extractNamesFromAoa(xlsxAoa, xlsxSelectedCol)
|
||||
if (!names.length) {
|
||||
messageApi.error('所选列未解析到可导入的姓名')
|
||||
messageApi.error(t('students.noNamesFound'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -331,12 +333,12 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
try {
|
||||
const res = await (window as any).api.importStudentsFromXlsx({ names })
|
||||
if (!res?.success) {
|
||||
messageApi.error(res?.message || '导入失败')
|
||||
messageApi.error(res?.message || t('students.importFailed'))
|
||||
return
|
||||
}
|
||||
const inserted = Number(res?.data?.inserted ?? 0)
|
||||
const skipped = Number(res?.data?.skipped ?? 0)
|
||||
messageApi.success(`导入完成:新增 ${inserted},跳过 ${skipped}`)
|
||||
messageApi.success(t('students.importComplete', { inserted, skipped }))
|
||||
setXlsxVisible(false)
|
||||
setXlsxAoa([])
|
||||
setXlsxFileName('')
|
||||
@@ -349,9 +351,9 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}
|
||||
|
||||
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',
|
||||
key: 'score',
|
||||
width: 160,
|
||||
@@ -373,14 +375,14 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
title: t('students.tags'),
|
||||
dataIndex: 'tags',
|
||||
key: 'tags',
|
||||
width: 200,
|
||||
render: (tags: string[] = []) => (
|
||||
<Space>
|
||||
{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) => (
|
||||
<Tag key={tag} color="blue">
|
||||
@@ -393,16 +395,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('common.operation'),
|
||||
key: 'operation',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" disabled={!canEdit} onClick={() => handleOpenTagEditor(row)}>
|
||||
编辑标签
|
||||
{t('students.editTags')}
|
||||
</Button>
|
||||
<Button type="link" danger disabled={!canEdit} onClick={() => handleDelete(row.id)}>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
@@ -415,13 +417,13 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
<div style={{ padding: '24px' }}>
|
||||
{contextHolder}
|
||||
<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>
|
||||
<Button disabled={!canEdit} onClick={() => setImportVisible(true)}>
|
||||
导入名单
|
||||
{t('students.importList')}
|
||||
</Button>
|
||||
<Button type="primary" disabled={!canEdit} onClick={() => setVisible(true)}>
|
||||
添加学生
|
||||
{t('students.addStudent')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -444,34 +446,38 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
setPageSize(size)
|
||||
}}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条`}
|
||||
showTotal={(total) => t('common.total', { count: total })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="添加学生"
|
||||
title={t('students.addTitle')}
|
||||
open={visible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setVisible(false)}
|
||||
okText="添加"
|
||||
cancelText="取消"
|
||||
okText={t('students.addConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入学生姓名" />
|
||||
<Form.Item
|
||||
label={t('students.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('students.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder={t('students.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="导入名单"
|
||||
title={t('students.importTitle')}
|
||||
open={importVisible}
|
||||
onCancel={() => setImportVisible(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
loading={xlsxLoading}
|
||||
disabled={!canEdit}
|
||||
@@ -479,7 +485,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
xlsxInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
通过 xlsx 导入
|
||||
{t('students.importByXlsx')}
|
||||
</Button>
|
||||
<input
|
||||
ref={xlsxInputRef}
|
||||
@@ -496,21 +502,25 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="xlsx 预览与导入"
|
||||
title={t('students.xlsxPreview')}
|
||||
open={xlsxVisible}
|
||||
onCancel={() => setXlsxVisible(false)}
|
||||
onOk={handleConfirmXlsxImport}
|
||||
okText="导入"
|
||||
okText={t('students.importConfirm')}
|
||||
okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }}
|
||||
width="80%"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ marginBottom: '12px', color: 'var(--ss-text-secondary)', fontSize: '12px' }}>
|
||||
<div>文件:{xlsxFileName || '-'}</div>
|
||||
<div>
|
||||
点击表头选择姓名列:{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
|
||||
{t('students.file')}
|
||||
{xlsxFileName || '-'}
|
||||
</div>
|
||||
<div>预览前 50 行</div>
|
||||
<div>
|
||||
{t('students.selectNameCol')}
|
||||
{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)}
|
||||
</div>
|
||||
<div>{t('students.previewRows')}</div>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={xlsxPreviewRows}
|
||||
@@ -531,7 +541,7 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
}}
|
||||
onConfirm={handleSaveTags}
|
||||
initialTagIds={editingStudent?.tagIds || []}
|
||||
title={`编辑标签 - ${editingStudent?.name || ''}`}
|
||||
title={t('students.editTagTitle', { name: editingStudent?.name || '' })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Input, Button, Space, Tag, message } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface TagItem {
|
||||
id: number
|
||||
@@ -19,8 +20,9 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
initialTagIds = [],
|
||||
title = '编辑标签'
|
||||
title
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [allTags, setAllTags] = useState<TagItem[]>([])
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set(initialTagIds))
|
||||
@@ -35,7 +37,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
}
|
||||
} catch (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.length > 30) {
|
||||
messageApi.error('标签名称不能超过 30 个字符')
|
||||
messageApi.error(t('tags.nameTooLong'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,11 +74,11 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
setSelectedTagIds((prev) => new Set([...prev, res.data.id]))
|
||||
setInputValue('')
|
||||
} else {
|
||||
messageApi.error(res.message || '添加标签失败')
|
||||
messageApi.error(res.message || t('tags.addFailed'))
|
||||
}
|
||||
} catch (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)
|
||||
return newSet
|
||||
})
|
||||
messageApi.success('标签删除成功')
|
||||
messageApi.success(t('tags.deleteSuccess'))
|
||||
} else {
|
||||
messageApi.error(res.message || '删除标签失败')
|
||||
messageApi.error(res.message || t('tags.deleteFailed'))
|
||||
}
|
||||
} catch (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 (
|
||||
<Modal
|
||||
title={title}
|
||||
title={title || t('tags.editTitle')}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleConfirm}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
destroyOnHidden
|
||||
width={500}
|
||||
>
|
||||
@@ -135,20 +137,20 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Input
|
||||
placeholder="输入标签名称,按 Enter 添加"
|
||||
placeholder={t('tags.inputPlaceholder')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={handleAddTag}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleAddTag} disabled={!inputValue.trim()}>
|
||||
添加
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
已选标签(点击取消)
|
||||
{t('tags.selectedTags')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -159,7 +161,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
}}
|
||||
>
|
||||
{selectedTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>未选择标签</span>
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noTagsSelected')}</span>
|
||||
) : (
|
||||
<Space wrap>
|
||||
{selectedTags.map((tag) => (
|
||||
@@ -180,7 +182,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--ss-text-secondary)', marginBottom: '8px' }}>
|
||||
可选标签(点击选择)
|
||||
{t('tags.availableTags')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -191,7 +193,7 @@ export const TagEditorDialog: React.FC<TagEditorDialogProps> = ({
|
||||
}}
|
||||
>
|
||||
{availableTags.length === 0 ? (
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>无可用标签</span>
|
||||
<span style={{ color: 'var(--ss-text-secondary)' }}>{t('tags.noAvailableTags')}</span>
|
||||
) : (
|
||||
<Space wrap>
|
||||
{availableTags.map((tag) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd'
|
||||
import type { Color } from 'antd/es/color-picker'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import type { themeConfig } from '../../../preload/types'
|
||||
|
||||
@@ -22,31 +23,31 @@ const presetPrimaryColors = [
|
||||
|
||||
const presetGradients: {
|
||||
id: string
|
||||
label: string
|
||||
labelKey: string
|
||||
light: string
|
||||
dark: string
|
||||
}[] = [
|
||||
{
|
||||
id: 'blue',
|
||||
label: '清爽蓝',
|
||||
labelKey: 'blue',
|
||||
light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)',
|
||||
dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)'
|
||||
},
|
||||
{
|
||||
id: 'pink',
|
||||
label: '柔和粉',
|
||||
labelKey: 'pink',
|
||||
light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)',
|
||||
dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)'
|
||||
},
|
||||
{
|
||||
id: 'cyan',
|
||||
label: '青蓝',
|
||||
labelKey: 'cyan',
|
||||
light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)',
|
||||
dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)'
|
||||
},
|
||||
{
|
||||
id: 'purple',
|
||||
label: '紫韵',
|
||||
labelKey: 'purple',
|
||||
light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 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 }) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentTheme, setTheme, themes, applyTheme } = useTheme()
|
||||
const [messageApi, holder] = message.useMessage()
|
||||
|
||||
@@ -92,12 +94,12 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
const editable =
|
||||
base.id === 'custom-default' || base.id.startsWith('custom-')
|
||||
? base
|
||||
: { ...base, id: 'custom-default', name: '我的主题' }
|
||||
: { ...base, id: 'custom-default', name: t('theme.myTheme') }
|
||||
setWorkingTheme(editable)
|
||||
setPrimaryInput(editable.config?.tdesign?.brandColor || '')
|
||||
}, [currentTheme])
|
||||
|
||||
// 实时预览主题变化
|
||||
// Real-time theme preview
|
||||
useEffect(() => {
|
||||
if (!workingTheme) return
|
||||
applyTheme(workingTheme)
|
||||
@@ -116,14 +118,22 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const patchThemeConfig = (type: 'tdesign' | 'custom', key: string, value: string) => {
|
||||
setWorkingTheme((prev) => {
|
||||
if (!prev) return prev
|
||||
const next = deepClone(prev)
|
||||
next.config = next.config || ({} as any)
|
||||
next.config[type] = { ...(next.config[type] || {}), [key]: value }
|
||||
return next
|
||||
})
|
||||
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 save = async () => {
|
||||
@@ -135,30 +145,50 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
if (!ok) throw new Error('保存失败')
|
||||
const res = await (window as any).api.saveTheme(workingTheme)
|
||||
if (!res?.success) throw new Error(res?.message || '保存失败')
|
||||
messageApi.success('已保存')
|
||||
messageApi.success(t('theme.saved'))
|
||||
} catch (e: any) {
|
||||
messageApi.error(e?.message || '保存失败')
|
||||
messageApi.error(e?.message || t('theme.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setPrimary = (hex: string) => {
|
||||
const setPrimary = async (hex: string) => {
|
||||
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) => {
|
||||
patchThemeConfig('custom', '--ss-bg-color', value)
|
||||
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 setGradientFromPickers = () => {
|
||||
const setGradientFromPickers = async () => {
|
||||
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 targetThemeId = mode === 'light' ? 'light-default' : 'dark-default'
|
||||
await setTheme(targetThemeId)
|
||||
}
|
||||
@@ -170,28 +200,28 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
{holder}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong>颜色与背景</Typography.Text>
|
||||
<Typography.Text strong>{t('theme.colorAndBackground')}</Typography.Text>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={save}>
|
||||
保存
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>模式</Typography.Text>
|
||||
<Typography.Text>{t('theme.mode')}</Typography.Text>
|
||||
<Segmented
|
||||
value={workingTheme.mode}
|
||||
onChange={(v) => setMode(v as any)}
|
||||
options={[
|
||||
{ label: '浅色', value: 'light' },
|
||||
{ label: '深色', value: 'dark' }
|
||||
{ label: t('settings.lightMode'), value: 'light' },
|
||||
{ label: t('settings.darkMode'), value: 'dark' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>主色</Typography.Text>
|
||||
<Typography.Text>{t('settings.primaryColor')}</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
value={primaryInput}
|
||||
@@ -228,7 +258,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Typography.Text>背景渐变</Typography.Text>
|
||||
<Typography.Text>{t('settings.backgroundGradient')}</Typography.Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -264,7 +294,7 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ss-text-secondary)' }}>
|
||||
{g.label}
|
||||
{t(`theme.gradientLabels.${g.labelKey}`)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -276,15 +306,15 @@ export const ThemeQuickSettings: React.FC<props> = ({ compact }) => {
|
||||
value={gradientDir}
|
||||
onChange={(v) => setGradientDir(v as any)}
|
||||
options={[
|
||||
{ label: '纵向', value: 'v' },
|
||||
{ label: '横向', value: 'h' },
|
||||
{ label: '对角', value: 'd' }
|
||||
{ label: t('theme.gradientDirections.vertical'), value: 'v' },
|
||||
{ label: t('theme.gradientDirections.horizontal'), value: 'h' },
|
||||
{ label: t('theme.gradientDirections.diagonal'), value: 'd' }
|
||||
]}
|
||||
/>
|
||||
<Space size="small">
|
||||
<ColorPicker value={g1} onChange={(c: Color) => setG1(c.toHexString())} />
|
||||
<ColorPicker value={g2} onChange={(c: Color) => setG2(c.toHexString())} />
|
||||
<Button onClick={setGradientFromPickers}>生成</Button>
|
||||
<Button onClick={setGradientFromPickers}>{t('theme.generate')}</Button>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Modal, message, Typography } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ThemeQuickSettings } from './ThemeQuickSettings'
|
||||
|
||||
interface wizardProps {
|
||||
@@ -8,6 +9,7 @@ interface wizardProps {
|
||||
}
|
||||
|
||||
export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
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)
|
||||
if (!res?.success) throw new Error(res?.message || 'failed')
|
||||
|
||||
messageApi.success('配置完成!')
|
||||
messageApi.success(t('wizard.configComplete'))
|
||||
onComplete()
|
||||
} catch {
|
||||
messageApi.error('配置保存失败')
|
||||
messageApi.error(t('wizard.configFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -29,12 +31,12 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="欢迎使用 SecScore 积分管理"
|
||||
title={t('wizard.welcomeTitle')}
|
||||
open={visible}
|
||||
onOk={handleFinish}
|
||||
onCancel={() => {}}
|
||||
confirmLoading={loading}
|
||||
okText="开启积分之旅"
|
||||
okText={t('wizard.startJourney')}
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
closable={false}
|
||||
mask={{ closable: false }}
|
||||
@@ -43,7 +45,7 @@ export const Wizard: React.FC<wizardProps> = ({ visible, onComplete }) => {
|
||||
>
|
||||
{contextHolder}
|
||||
<Typography.Paragraph style={{ marginBottom: '24px', color: 'var(--ss-text-secondary)' }}>
|
||||
感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。
|
||||
{t('wizard.welcomeDesc')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<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 './i18n'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
Reference in New Issue
Block a user