feat:i18n and OOBE

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