diff --git a/.gitignore b/.gitignore index c5c97f7..1759106 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,3 @@ db.sqlite !.vscode/extensions.json !.vscode/launch.json !.vscode/settings.json -/.trae/ diff --git a/.trae/rules/coding-rules.md b/.trae/rules/coding-rules.md new file mode 100644 index 0000000..51ef9f7 --- /dev/null +++ b/.trae/rules/coding-rules.md @@ -0,0 +1,3 @@ +该项目使用pnpm作为包管理器 +每次写完代码后请务必跑一遍格式化和lint,typecheck,一个warning都不可以有 +每次完成任务请详细告知用户“你干了什么”和要确认的问题(如有)。 diff --git a/package.json b/package.json index 0932995..6fbfa07 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,12 @@ "chokidar": "^5.0.0", "es-object-atoms": "^1.1.1", "express": "^5.2.1", + "i18next": "^25.8.14", "mica-electron": "^1.5.16", "os": "^0.1.2", "pg": "^8.19.0", "pinyin-pro": "^3.27.0", + "react-i18next": "^16.5.6", "react-router-dom": "^6.28.0", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b2d514..0e9e4c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + i18next: + specifier: ^25.8.14 + version: 25.8.14(typescript@5.9.3) mica-electron: specifier: ^1.5.16 version: 1.5.16 @@ -57,6 +60,9 @@ importers: pinyin-pro: specifier: ^3.27.0 version: 3.27.0 + react-i18next: + specifier: ^16.5.6 + version: 16.5.6(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) react-router-dom: specifier: ^6.28.0 version: 6.30.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2464,6 +2470,9 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -2483,6 +2492,14 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + i18next@25.8.14: + resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -3313,6 +3330,22 @@ packages: react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + react-i18next@16.5.6: + resolution: {integrity: sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3977,6 +4010,10 @@ packages: yaml: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -6873,6 +6910,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -6902,6 +6943,12 @@ snapshots: transitivePeerDependencies: - supports-color + i18next@25.8.14(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -7721,6 +7768,17 @@ snapshots: react-fast-compare@3.2.2: {} + react-i18next@16.5.6(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.14(typescript@5.9.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@18.3.1: {} @@ -8440,6 +8498,8 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + void-elements@3.1.0: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6248069..39ee4d2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,12 +1,14 @@ import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from 'antd' import { useEffect, useMemo, useState } from 'react' import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { Sidebar } from './components/Sidebar' import { ContentArea } from './components/ContentArea' -import { Wizard } from './components/Wizard' +import { OOBE } from './components/OOBE' import { ThemeProvider, useTheme } from './contexts/ThemeContext' function MainContent(): React.JSX.Element { + const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() const { currentTheme } = useTheme() @@ -80,9 +82,9 @@ function MainContent(): React.JSX.Element { setPermission(res.data.permission) setAuthVisible(false) setAuthPassword('') - messageApi.success('权限已解锁') + messageApi.success(t('auth.unlocked')) } else { - messageApi.error(res.message || '密码错误') + messageApi.error(res.message || t('common.error')) } } @@ -91,7 +93,7 @@ function MainContent(): React.JSX.Element { const res = await (window as any).api.authLogout() if (res?.success && res.data) { setPermission(res.data.permission) - messageApi.success('已切换为只读') + messageApi.success(t('auth.logout')) } } @@ -129,25 +131,25 @@ function MainContent(): React.JSX.Element { onLogout={logout} /> - setWizardVisible(false)} /> + setWizardVisible(false)} /> setAuthVisible(false)} onOk={login} confirmLoading={authLoading} - okText="解锁" - cancelText="取消" + okText={t('auth.unlockButton')} + cancelText={t('common.cancel')} >
- 输入 6 位数字密码:管理密码=全功能,积分密码=仅积分操作。 + {t('auth.unlockHint')}
setAuthPassword(e.target.value)} - placeholder="例如 123456" + placeholder={t('auth.passwordPlaceholder')} maxLength={6} />
diff --git a/src/renderer/src/components/AutoScoreManager.tsx b/src/renderer/src/components/AutoScoreManager.tsx index 67a11ea..975f1a0 100644 --- a/src/renderer/src/components/AutoScoreManager.tsx +++ b/src/renderer/src/components/AutoScoreManager.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { PlusOutlined, HolderOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' import { allTriggers, allActions, triggerRegistry, actionRegistry } from './com.automatically' import type { TriggerItem, ActionItem } from './com.automatically/types' import TriggerItemComponent from './com.automatically/TriggerItem' @@ -36,6 +37,7 @@ interface AutoScoreRuleFormValues { } export const AutoScoreManager: React.FC = () => { + const { t } = useTranslation() const [rules, setRules] = useState([]) const [students, setStudents] = useState<{ id: number; name: string }[]>([]) const [loading, setLoading] = useState(false) @@ -55,7 +57,7 @@ export const AutoScoreManager: React.FC = () => { try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { - messageApi.error('需要管理员权限以查看自动加分自动化,请先登录管理员账号') + messageApi.error(t('autoScore.adminRequired')) setLoading(false) return } @@ -70,14 +72,14 @@ export const AutoScoreManager: React.FC = () => { if (rulesRes.success) { setRules(rulesRes.data) } else { - messageApi.error(rulesRes.message || '获取自动化失败') + messageApi.error(rulesRes.message || t('autoScore.fetchFailed')) } if (studentsRes.success) { setStudents(studentsRes.data) } } catch (error) { console.error('Failed to fetch auto score rules:', error) - messageApi.error('获取自动化失败') + messageApi.error(t('autoScore.fetchFailed')) } finally { setLoading(false) } @@ -93,17 +95,17 @@ export const AutoScoreManager: React.FC = () => { const values = form.getFieldsValue(true) as unknown as AutoScoreRuleFormValues if (!values.name) { - messageApi.warning('请填写自动化名称') + messageApi.warning(t('autoScore.nameRequired')) return } if (triggerList.length === 0) { - messageApi.warning('请至少添加一个触发器') + messageApi.warning(t('autoScore.triggerRequired')) return } if (actionList.length === 0) { - messageApi.warning('请至少添加一个行动') + messageApi.warning(t('autoScore.actionRequired')) return } @@ -131,7 +133,7 @@ export const AutoScoreManager: React.FC = () => { try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { - messageApi.error('需要管理员权限以创建或更新自动加分自动化') + messageApi.error(t('autoScore.adminCreateRequired')) return } } catch (e) { @@ -150,7 +152,9 @@ export const AutoScoreManager: React.FC = () => { } if (res.success) { - messageApi.success(editingRuleId !== null ? '自动化更新成功' : '自动化创建成功') + messageApi.success( + editingRuleId !== null ? t('autoScore.updateSuccess') : t('autoScore.createSuccess') + ) form.setFieldsValue({ name: '', studentNames: '' @@ -161,12 +165,15 @@ export const AutoScoreManager: React.FC = () => { fetchRules() } else { messageApi.error( - res.message || (editingRuleId !== null ? '更新自动化失败' : '创建自动化失败') + res.message || + (editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed')) ) } } catch (error) { console.error('Failed to submit auto score rule:', error) - messageApi.error(editingRuleId !== null ? '更新自动化失败' : '创建自动化失败') + messageApi.error( + editingRuleId !== null ? t('autoScore.updateFailed') : t('autoScore.createFailed') + ) } } @@ -205,7 +212,7 @@ export const AutoScoreManager: React.FC = () => { try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { - messageApi.error('需要管理员权限以删除自动加分自动化') + messageApi.error(t('autoScore.adminDeleteRequired')) return } } catch (e) { @@ -215,14 +222,14 @@ export const AutoScoreManager: React.FC = () => { try { const res = await (window as any).api.invoke('auto-score:deleteRule', ruleId) if (res.success) { - messageApi.success('自动化删除成功') + messageApi.success(t('autoScore.deleteSuccess')) fetchRules() } else { - messageApi.error(res.message || '删除自动化失败') + messageApi.error(res.message || t('autoScore.deleteFailed')) } } catch (error) { console.error('Failed to delete auto score rule:', error) - messageApi.error('删除自动化失败') + messageApi.error(t('autoScore.deleteFailed')) } } @@ -231,7 +238,7 @@ export const AutoScoreManager: React.FC = () => { try { const authRes = await (window as any).api.authGetStatus() if (!authRes || !authRes.success || authRes.data?.permission !== 'admin') { - messageApi.error('需要管理员权限以启用/禁用自动加分自动化') + messageApi.error(t('autoScore.adminToggleRequired')) return } } catch (e) { @@ -241,14 +248,16 @@ export const AutoScoreManager: React.FC = () => { try { const res = await (window as any).api.invoke('auto-score:toggleRule', { ruleId, enabled }) if (res.success) { - messageApi.success(enabled ? '自动化已启用' : '自动化已禁用') + messageApi.success(enabled ? t('autoScore.enabled') : t('autoScore.disabled')) fetchRules() } else { - messageApi.error(res.message || (enabled ? '启用自动化失败' : '禁用自动化失败')) + messageApi.error( + res.message || (enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed')) + ) } } catch (error) { console.error('Failed to toggle auto score rule:', error) - messageApi.error(enabled ? '启用自动化失败' : '禁用自动化失败') + messageApi.error(enabled ? t('autoScore.enableFailed') : t('autoScore.disableFailed')) } } @@ -266,7 +275,7 @@ export const AutoScoreManager: React.FC = () => { const nextId = triggerList.length ? Math.max(...triggerList.map((t) => t.id)) + 1 : 1 const defaultTrigger = allTriggers.list[0] if (!defaultTrigger) { - messageApi.error('没有可用的触发器类型,请检查配置') + messageApi.error(t('autoScore.noTriggerAvailable')) return } setTriggerList((prev) => [ @@ -300,7 +309,7 @@ export const AutoScoreManager: React.FC = () => { const nextId = actionList.length ? Math.max(...actionList.map((a) => a.id)) + 1 : 1 const defaultAction = allActions.list[0] if (!defaultAction) { - messageApi.error('没有可用的行动类型,请检查配置') + messageApi.error(t('autoScore.noActionAvailable')) return } setActionList((prev) => [ @@ -333,12 +342,12 @@ export const AutoScoreManager: React.FC = () => { const columns: ColumnsType = [ { key: 'drag', - title: '排序', + title: t('autoScore.sort'), width: 60, render: () => }, { - title: '状态', + title: t('autoScore.status'), dataIndex: 'enabled', key: 'enabled', width: 80, @@ -346,15 +355,15 @@ export const AutoScoreManager: React.FC = () => { handleToggle(row.id, value)} size="small" /> ) }, - { title: '自动化名称', dataIndex: 'name', key: 'name', width: 150 }, + { title: t('autoScore.name'), dataIndex: 'name', key: 'name', width: 150 }, { - title: '触发器', + title: t('autoScore.triggers'), dataIndex: 'triggers', key: 'triggers', width: 150, render: (triggers: AutoScoreRule['triggers']) => { if (!triggers || triggers.length === 0) { - return + return {t('common.none')} } const triggerLabels = triggers.map((t) => { const def = triggerRegistry.get(t.event) @@ -362,19 +371,19 @@ export const AutoScoreManager: React.FC = () => { }) return ( - {triggers.length} 个触发器 + {t('autoScore.triggerCount', { count: triggers.length })} ) } }, { - title: '行动', + title: t('autoScore.actions'), dataIndex: 'actions', key: 'actions', width: 150, render: (actions: AutoScoreRule['actions']) => { if (!actions || actions.length === 0) { - return + return {t('common.none')} } const actionLabels = actions.map((a) => { const def = actionRegistry.get(a.event) @@ -382,55 +391,55 @@ export const AutoScoreManager: React.FC = () => { }) return ( - {actions.length} 个行动 + {t('autoScore.actionCount', { count: actions.length })} ) } }, { - title: '适用学生', + title: t('autoScore.applicableStudents'), dataIndex: 'studentNames', key: 'studentNames', width: 130, render: (studentNames: string[]) => { if (!studentNames || studentNames.length === 0) { - return 所有学生 + return {t('autoScore.allStudents')} } const studentList = studentNames.join(',\n') return ( - {studentNames.length} 名学生 + {t('autoScore.studentCount', { count: studentNames.length })} ) } }, { - title: '最后执行', + title: t('autoScore.lastExecuted'), dataIndex: 'lastExecuted', key: 'lastExecuted', width: 180, render: (lastExecuted: string) => { - if (!lastExecuted) return 未执行 + if (!lastExecuted) return {t('autoScore.notExecuted')} try { const date = new Date(lastExecuted) return date.toLocaleString() } catch { - return 无效时间 + return {t('autoScore.invalidTime')} } } }, { - title: '操作', + title: t('common.operation'), key: 'operation', width: 150, render: (_, row) => ( - handleDelete(row.id)}> + handleDelete(row.id)}> @@ -468,24 +477,24 @@ export const AutoScoreManager: React.FC = () => { return (
{contextHolder} -

自动化加分管理

+

{t('autoScore.title')}

- + - + setSearchKeyword(e.target.value)} - placeholder="搜索姓名/拼音..." + placeholder={t('home.searchPlaceholder')} prefix={} allowClear style={{ width: '220px' }} @@ -546,9 +558,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { onChange={(v) => setSortType(v as SortType)} style={{ width: '140px' }} options={[ - { value: 'alphabet', label: '姓名排序' }, - { value: 'surname', label: '姓氏分组' }, - { value: 'score', label: '积分排行' } + { value: 'alphabet', label: t('home.sortBy.alphabet') }, + { value: 'surname', label: t('home.sortBy.surname') }, + { value: 'score', label: t('home.sortBy.score') } ]} /> @@ -559,7 +571,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{loading ? (
-
加载中...
+
{t('common.loading')}
) : sortedStudents.length === 0 ? (
= ({ canEdit }) => { }} >
- {searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'} + {searchKeyword ? t('home.noMatch') : t('home.noStudents')}
{searchKeyword && ( )}
@@ -586,13 +598,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
setOperationVisible(false)} onOk={handleSubmit} confirmLoading={submitLoading} - okText="提交操作" - cancelText="取消" + okText={t('home.submitOperation')} + cancelText={t('common.cancel')} width={560} destroyOnHidden > @@ -629,7 +641,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
- 当前积分: + {t('home.currentScore')}: = ({ canEdit }) => { gap: '8px' }} > - 快捷选项 + + {t('home.quickOptions')} +
= ({ canEdit }) => {
- 调整分值 + {t('home.adjustPoints')}
@@ -752,10 +766,10 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { max={99} step={1} style={{ width: '140px' }} - placeholder="自定义分值" + placeholder={t('home.customPoints')} /> - 可在输入框微调特输入任意分值 + {t('home.customPointsHint')}
@@ -764,13 +778,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
- 操作理由 + {t('home.reason')}
setReasonContent(e.target.value)} - placeholder="输入加分/扣分的原因(可选)" + placeholder={t('home.reasonPlaceholder')} suffix={ reasonContent ? ( = ({ canEdit }) => { color: 'var(--ss-text-main)' }} > - 变更预览: + {t('home.preview')}:
{selectedStudent.name}{' '} @@ -822,9 +836,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { > {customScore > 0 ? `+${customScore}` : customScore} {' '} - 分 + {t('home.points')} - {reasonContent ? `理由:${reasonContent}` : '(无理由)'} + {reasonContent + ? `${t('home.reasonLabel')}${reasonContent}` + : t('home.noReason')}
diff --git a/src/renderer/src/components/Leaderboard.tsx b/src/renderer/src/components/Leaderboard.tsx index 51ec844..90bba85 100644 --- a/src/renderer/src/components/Leaderboard.tsx +++ b/src/renderer/src/components/Leaderboard.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Table, Tag, Button, Select, Space, Card, message, Modal } from 'antd' import type { ColumnsType } from 'antd/es/table' +import { useTranslation } from 'react-i18next' import { EyeOutlined, DownloadOutlined } from '@ant-design/icons' import * as XLSX from 'xlsx' @@ -12,6 +13,7 @@ interface studentRank { } export const Leaderboard: React.FC = () => { + const { t } = useTranslation() const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [timeRange, setTimeRange] = useState('today') @@ -58,7 +60,7 @@ export const Leaderboard: React.FC = () => { startTime }) if (!res.success) { - messageApi.error(res.message || '查询失败') + messageApi.error(res.message || t('leaderboard.queryFailed')) return } @@ -68,14 +70,19 @@ export const Leaderboard: React.FC = () => { return `${time} ${delta} ${e.reason_content}` }) - setHistoryHeader(`${studentName} - 操作记录`) - setHistoryText(lines.join('\n') || '暂无记录') + setHistoryHeader(t('leaderboard.historyTitle', { name: studentName })) + setHistoryText(lines.join('\n') || t('common.noData')) setHistoryVisible(true) } const handleExport = () => { setTimeout(() => { - const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月' + const title = + timeRange === 'today' + ? t('leaderboard.today') + : timeRange === 'week' + ? t('leaderboard.week') + : t('leaderboard.month') const sanitizeCell = (v: unknown) => { if (typeof v !== 'string') return v @@ -84,7 +91,12 @@ export const Leaderboard: React.FC = () => { } const sheetData = [ - ['排名', '姓名', '总积分', `${title}变化`], + [ + t('leaderboard.rank'), + t('leaderboard.name'), + t('leaderboard.totalScore'), + `${title}${t('leaderboard.change')}` + ], ...data.map((item, index) => [ index + 1, sanitizeCell(item.name), @@ -97,7 +109,7 @@ export const Leaderboard: React.FC = () => { ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }] const wb = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(wb, ws, '排行榜') + XLSX.utils.book_append_sheet(wb, ws, t('leaderboard.title')) const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const blob = new Blob([xlsxBytes], { @@ -109,20 +121,20 @@ export const Leaderboard: React.FC = () => { link.setAttribute('href', url) link.setAttribute( 'download', - `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx` + `${t('leaderboard.title')}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx` ) link.style.visibility = 'hidden' document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) - messageApi.success('导出成功') + messageApi.success(t('leaderboard.exportSuccess')) }, 0) } const columns: ColumnsType = [ { - title: '排名', + title: t('leaderboard.rank'), key: 'rank', width: 70, align: 'center', @@ -139,9 +151,9 @@ export const Leaderboard: React.FC = () => { ) } }, - { title: '姓名', dataIndex: 'name', key: 'name', width: 120, align: 'center' }, + { title: t('leaderboard.name'), dataIndex: 'name', key: 'name', width: 120, align: 'center' }, { - title: '总积分', + title: t('leaderboard.totalScore'), dataIndex: 'score', key: 'score', width: 100, @@ -149,7 +161,12 @@ export const Leaderboard: React.FC = () => { render: (score: number) => {score} }, { - title: timeRange === 'today' ? '今日变化' : timeRange === 'week' ? '本周变化' : '本月变化', + title: + timeRange === 'today' + ? t('leaderboard.todayChange') + : timeRange === 'week' + ? t('leaderboard.weekChange') + : t('leaderboard.monthChange'), dataIndex: 'range_change', key: 'range_change', width: 100, @@ -161,13 +178,13 @@ export const Leaderboard: React.FC = () => { ) }, { - title: '操作记录', + title: t('leaderboard.operationRecord'), key: 'operation', width: 100, align: 'center', render: (_, row) => ( ) } @@ -184,20 +201,20 @@ export const Leaderboard: React.FC = () => { alignItems: 'center' }} > -

积分排行榜

+

{t('leaderboard.title')}

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) => ( + + { + const file = e.target.files?.[0] + if (file) handleFileImport(file) + if (fileInputRef.current) fileInputRef.current.value = '' + }} + /> + +
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)' + }} + > + +
{t('oobe.steps.students.dragDrop')}
+
+ {t('oobe.steps.students.supportedFormats')} +
+
+
+ setNewStudentName(e.target.value)} + placeholder={t('oobe.steps.students.studentName')} + onPressEnter={addStudent} + /> + +
+
+ {students.length === 0 ? ( + + {t('oobe.steps.students.noStudents')} + + ) : ( + students.map((s) => ( + removeStudent(s.name)} + style={{ margin: 2 }} + > + {s.name} + + )) + )} +
+ {students.length > 0 && ( + + {t('oobe.steps.students.studentCount', { count: students.length })} + + )} + + ) + + case 'reasons': + return ( +
+ + {t('oobe.steps.reasons.description')} + +
+ setNewReasonContent(e.target.value)} + placeholder={t('oobe.steps.reasons.reasonName')} + style={{ flex: 1 }} + /> + setNewReasonDelta(v || 0)} + min={-999} + max={999} + style={{ width: 80 }} + /> + +
+
+ {reasons.length === 0 ? ( + + {t('oobe.steps.reasons.noReasons')} + + ) : ( + reasons.map((r) => ( + removeReason(r.content)} + color={r.delta > 0 ? 'success' : 'error'} + style={{ margin: 2 }} + > + {r.content} ({r.delta > 0 ? '+' : ''} + {r.delta}) + + )) + )} +
+ {reasons.length > 0 && ( + + {t('oobe.steps.reasons.reasonCount', { count: reasons.length })} + + )} +
+ ) + + case 'start': + return ( +
+ + {t('oobe.steps.start.description')} + +
+
+ + {t('oobe.steps.start.features.score')} +
+
+ + {t('oobe.steps.start.features.history')} +
+
+ + {t('oobe.steps.start.features.settlement')} +
+
+
+ ) + } + } + + return ( +
+ + +
+ {contextHolder} + +
+ + {t('oobe.title')} + + + {t('oobe.subtitle')} + +
+ +
+ + {t(`oobe.steps.${currentStep}.title`)} + +
+ +
{renderStepContent()}
+ +
+
+ {currentStep !== 'language' && } + {currentStep !== 'start' && } +
+ +
+
+
+
+ {t('oobe.step', { current: stepIndex, total: totalSteps })} +
+ +
+ {currentStep !== 'start' ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/OOBEBackground.tsx b/src/renderer/src/components/OOBEBackground.tsx new file mode 100644 index 0000000..e643443 --- /dev/null +++ b/src/renderer/src/components/OOBEBackground.tsx @@ -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 = ({ primaryColor, mode }) => { + const canvasRef = useRef(null) + const animationRef = useRef(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 ( + + ) +} diff --git a/src/renderer/src/components/ReasonManager.tsx b/src/renderer/src/components/ReasonManager.tsx index a08e098..2d4dfa6 100644 --- a/src/renderer/src/components/ReasonManager.tsx +++ b/src/renderer/src/components/ReasonManager.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Table, Button, Modal, Form, Input, InputNumber, message, Tag, Popconfirm } from 'antd' import type { ColumnsType } from 'antd/es/table' +import { useTranslation } from 'react-i18next' interface reason { id: number @@ -11,6 +12,7 @@ interface reason { } export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { t } = useTranslation() const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [visible, setVisible] = useState(false) @@ -49,15 +51,15 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const handleAdd = async () => { if (!(window as any).api) return if (!canEdit) { - messageApi.error('当前为只读权限') + messageApi.error(t('common.readOnly')) return } const values = await form.validateFields() const content = values.content?.trim() - const category = values.category?.trim() || '其他' + const category = values.category?.trim() || t('reasons.others') if (data.some((r) => r.content === content && r.category === category)) { - messageApi.warning('该分类下已存在相同理由') + messageApi.warning(t('reasons.reasonExists')) return } @@ -68,43 +70,43 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { delta: Number(values.delta) }) if (res.success) { - messageApi.success('添加成功') + messageApi.success(t('reasons.addSuccess')) setVisible(false) form.resetFields() fetchReasons() emitDataUpdated('reasons') } else { - messageApi.error(res.message || '添加失败') + messageApi.error(res.message || t('reasons.addFailed')) } } const handleDelete = async (id: number) => { if (!(window as any).api) return if (!canEdit) { - messageApi.error('当前为只读权限') + messageApi.error(t('common.readOnly')) return } const res = await (window as any).api.deleteReason(id) if (res.success) { - messageApi.success('删除成功') + messageApi.success(t('reasons.deleteSuccess')) fetchReasons() emitDataUpdated('reasons') } else { - messageApi.error(res.message || '删除失败') + messageApi.error(res.message || t('reasons.deleteFailed')) } } const columns: ColumnsType = [ { - title: '分类', + title: t('reasons.category'), dataIndex: 'category', key: 'category', width: 120, render: (category: string) => {category} }, - { title: '理由内容', dataIndex: 'content', key: 'content', width: 250 }, + { title: t('reasons.content'), dataIndex: 'content', key: 'content', width: 250 }, { - title: '预设分值', + title: t('reasons.presetPoints'), dataIndex: 'delta', key: 'delta', width: 100, @@ -120,17 +122,17 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { ) }, { - title: '操作', + title: t('common.operation'), key: 'operation', width: 150, render: (_, row) => ( handleDelete(row.id)} disabled={!canEdit} > ) @@ -141,9 +143,9 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{contextHolder}
-

理由管理

+

{t('reasons.title')}

@@ -158,31 +160,35 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { /> setVisible(false)} - okText="添加" - cancelText="取消" + okText={t('reasons.addConfirm')} + cancelText={t('common.cancel')} destroyOnHidden > - - + + - + - + diff --git a/src/renderer/src/components/ScoreManager.tsx b/src/renderer/src/components/ScoreManager.tsx index 57fdb84..6a2d841 100644 --- a/src/renderer/src/components/ScoreManager.tsx +++ b/src/renderer/src/components/ScoreManager.tsx @@ -14,6 +14,7 @@ import { Popconfirm } from 'antd' import type { ColumnsType } from 'antd/es/table' +import { useTranslation } from 'react-i18next' import { UndoOutlined } from '@ant-design/icons' import { match } from 'pinyin-pro' @@ -79,6 +80,7 @@ interface scoreEvent { } export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const { t } = useTranslation() const [students, setStudents] = useState([]) const [reasons, setReasons] = useState([]) const [events, setEvents] = useState([]) @@ -133,7 +135,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const handleSubmit = async () => { if (!(window as any).api) return if (!canEdit) { - messageApi.error('当前为只读权限') + messageApi.error(t('common.readOnly')) return } const values = form.getFieldsValue(true) as any @@ -142,7 +144,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { ? values.student_name : [values.student_name] if (!studentNames || studentNames.length === 0 || !values.reason_content) { - messageApi.warning('请填写完整信息') + messageApi.warning(t('score.pleaseEnterInfo')) return } @@ -153,7 +155,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const selectedReason = Number.isFinite(reasonId) ? reasons.find((r) => r.id === reasonId) : null if (!hasDeltaInput && !selectedReason) { - messageApi.warning('请填写分值或选择预设理由') + messageApi.warning(t('score.pleaseEnterPoints')) return } @@ -178,7 +180,7 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } if (successCount === studentNames.length) { - messageApi.success(`已为 ${successCount} 名学生提交积分`) + messageApi.success(t('score.batchSuccess', { count: successCount })) form.setFieldsValue({ student_name: [], delta: undefined, @@ -189,7 +191,9 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { fetchData() emitDataUpdated('events') } else { - messageApi.warning(`成功提交 ${successCount}/${studentNames.length} 名学生的积分`) + messageApi.warning( + t('score.batchPartial', { success: successCount, total: studentNames.length }) + ) fetchData() emitDataUpdated('events') } @@ -201,23 +205,23 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const handleUndo = async (uuid: string) => { if (!(window as any).api) return if (!canEdit) { - messageApi.error('当前为只读权限') + messageApi.error(t('common.readOnly')) return } const res = await (window as any).api.deleteEvent(uuid) if (res.success) { - messageApi.success('已撤销操作') + messageApi.success(t('score.undoSuccess')) fetchData() emitDataUpdated('events') } else { - messageApi.error(res.message || '撤销失败') + messageApi.error(res.message || t('score.undoFailed')) } } const columns: ColumnsType = [ - { title: '学生', dataIndex: 'student_name', key: 'student_name', width: 100 }, + { title: t('score.student'), dataIndex: 'student_name', key: 'student_name', width: 100 }, { - title: '变动', + title: t('score.change'), dataIndex: 'delta', key: 'delta', width: 80, @@ -225,25 +229,27 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { 0 ? 'success' : 'error'}>{delta > 0 ? `+${delta}` : delta} ) }, - { title: '理由', dataIndex: 'reason_content', key: 'reason_content', ellipsis: true }, { - title: '时间', + title: t('score.reason'), + dataIndex: 'reason_content', + key: 'reason_content', + ellipsis: true + }, + { + title: t('score.time'), dataIndex: 'event_time', key: 'event_time', width: 160, render: (time: string) => new Date(time).toLocaleString() }, { - title: '操作', + title: t('common.operation'), key: 'operation', width: 80, render: (_, row) => ( - handleUndo(row.uuid)} - > + handleUndo(row.uuid)}> ) @@ -253,38 +259,38 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { return (
{contextHolder} -

积分管理

+

{t('score.title')}

- + { const id = Number(v) if (!Number.isFinite(id)) return @@ -315,8 +321,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { /> - - + +
@@ -329,14 +335,14 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { loading={submitLoading} style={{ width: '200px' }} > - 确认提交 + {t('score.submit')}
-
最近记录
+
{t('score.recentRecords')}
= ({ permission }) => { + const { t } = useTranslation() const [activeTab, setActiveTab] = useState('appearance') + const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()) const [settings, setSettings] = useState({ is_wizard_completed: false, log_level: 'info', @@ -64,10 +68,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission - {permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'} + {permission === 'admin' + ? t('permissions.admin') + : permission === 'points' + ? t('permissions.points') + : t('permissions.view')} ) - }, [permission]) + }, [permission, t]) const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => { window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) @@ -110,7 +118,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const res = await (window as any).api.queryLogs(200) setLogsLoading(false) if (!res.success) { - messageApi.error(res.message || '读取日志失败') + messageApi.error(res.message || t('settings.data.readLogsFailed')) return } setLogsText((res.data || []).join('\n')) @@ -121,12 +129,12 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission if (!(window as any).api) return const res = await (window as any).api.queryLogs(5000) if (!res.success) { - messageApi.error(res.message || '读取日志失败') + messageApi.error(res.message || t('settings.data.readLogsFailed')) return } const dateTime = new Date().toISOString().replace(/[:.]/g, '-') downloadTextFile(`secscore_logs_${dateTime}.txt`, `${(res.data || []).join('\n')}\n`) - messageApi.success('日志已导出') + messageApi.success(t('settings.data.logsExported')) } const downloadTextFile = (filename: string, text: string) => { @@ -152,7 +160,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission if (!(window as any).api) return const res = await (window as any).api.exportDataJson() if (!res.success || !res.data) { - messageApi.error(res.message || '导出失败') + messageApi.error(res.message || t('settings.data.exportFailed')) return } const blob = new Blob([res.data], { type: 'application/json;charset=utf-8' }) @@ -162,7 +170,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission a.download = `secscore_export_${new Date().toISOString().slice(0, 10)}.json` a.click() URL.revokeObjectURL(url) - messageApi.success('导出成功') + messageApi.success(t('settings.data.exportSuccess')) } const importJson = async (file: File) => { @@ -170,10 +178,10 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const text = await file.text() const res = await (window as any).api.importDataJson(text) if (res.success) { - messageApi.success('导入成功,正在刷新') + messageApi.success(t('settings.data.importSuccess')) setTimeout(() => window.location.reload(), 300) } else { - messageApi.error(res.message || '导入失败') + messageApi.error(res.message || t('settings.data.importFailed')) } } @@ -188,12 +196,15 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission setPointsPassword('') await loadAll() if (res.data?.recoveryString) { - showRecoveryDialog('找回字符串(请妥善保存)', res.data.recoveryString) + showRecoveryDialog( + t('settings.security.recoveryString') + ` (${t('recovery.hint')})`, + res.data.recoveryString + ) } else { - messageApi.success('密码已更新') + messageApi.success(t('settings.security.saved')) } } else { - messageApi.error(res.message || '更新失败') + messageApi.error(res.message || t('settings.general.saveFailed')) } } @@ -201,23 +212,29 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission if (!(window as any).api) return const res = await (window as any).api.authGenerateRecovery() if (!res.success || !res.data?.recoveryString) { - messageApi.error(res.message || '生成失败') + messageApi.error(res.message || t('settings.security.generateFailed')) return } await loadAll() - showRecoveryDialog('新的找回字符串(请妥善保存)', res.data.recoveryString) + showRecoveryDialog( + t('settings.security.newRecoveryString', 'New recovery string (please save it)'), + res.data.recoveryString + ) } const resetByRecovery = async () => { if (!(window as any).api) return const res = await (window as any).api.authResetByRecovery(recoveryToReset) if (!res.success || !res.data?.recoveryString) { - messageApi.error(res.message || '重置失败') + messageApi.error(res.message || t('settings.security.resetFailed')) return } setRecoveryToReset('') await loadAll() - showRecoveryDialog('密码已清空,新的找回字符串', res.data.recoveryString) + showRecoveryDialog( + t('settings.security.passwordClearedNewRecovery', 'Password cleared, new recovery string'), + res.data.recoveryString + ) } const clearAllPasswords = () => { @@ -230,11 +247,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const res = await (window as any).api.authClearAll() setClearLoading(false) if (res.success) { - messageApi.success('已清空') + messageApi.success(t('settings.security.cleared')) await loadAll() setClearDialogVisible(false) } else { - messageApi.error(res.message || '清空失败') + messageApi.error(res.message || t('settings.security.clearFailed')) } } @@ -245,19 +262,21 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const testPgConnection = async () => { if (!(window as any).api) return if (!pgConnectionString) { - messageApi.warning('请输入 PostgreSQL 连接字符串') + messageApi.warning(t('settings.database.enterConnectionString')) return } setPgTestLoading(true) try { const res = await (window as any).api.dbTestConnection(pgConnectionString) if (res.success && res.data?.success) { - messageApi.success('连接测试成功') + messageApi.success(t('settings.database.connectionTestSuccess')) } else { - messageApi.error(res.data?.error || res.message || '连接测试失败') + messageApi.error( + res.data?.error || res.message || t('settings.database.connectionTestFailed') + ) } } catch (e: any) { - messageApi.error(e?.message || '连接测试失败') + messageApi.error(e?.message || t('settings.database.connectionTestFailed')) } finally { setPgTestLoading(false) } @@ -270,14 +289,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const res = await (window as any).api.dbSwitchConnection(pgConnectionString) if (res.success) { messageApi.success( - `已切换到 ${res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite'} 数据库` + t('settings.database.switchedTo', { + type: res.data?.type === 'postgresql' ? 'PostgreSQL' : 'SQLite' + }) ) await loadAll() } else { - messageApi.error(res.message || '切换失败') + messageApi.error(res.message || t('settings.database.switchFailed')) } } catch (e: any) { - messageApi.error(e?.message || '切换失败') + messageApi.error(e?.message || t('settings.database.switchFailed')) } finally { setPgSwitchLoading(false) } @@ -289,14 +310,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission try { const res = await (window as any).api.dbSwitchConnection('') if (res.success) { - messageApi.success('已切换到本地 SQLite 数据库') + messageApi.success(t('settings.database.switchedToSQLite')) setPgConnectionString('') await loadAll() } else { - messageApi.error(res.message || '切换失败') + messageApi.error(res.message || t('settings.database.switchFailed')) } } catch (e: any) { - messageApi.error(e?.message || '切换失败') + messageApi.error(e?.message || t('settings.database.switchFailed')) } finally { setPgSwitchLoading(false) } @@ -307,15 +328,40 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const tabItems = [ { key: 'appearance', - label: '外观', + label: t('settings.tabs.appearance'), children: ( +
+ + { @@ -324,28 +370,28 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const res = await (window as any).api.setSetting('window_zoom', next) if (res.success) { setSettings((prev) => ({ ...prev, window_zoom: next })) - messageApi.success('界面缩放已更新') + messageApi.success(t('settings.general.saved')) } else { - messageApi.error(res.message || '更新失败') + messageApi.error(res.message || t('settings.general.saveFailed')) } }} style={{ width: '320px' }} disabled={!canAdmin} options={[ - { value: '0.7', label: '70% (较小)' }, + { value: '0.7', label: t('settings.zoomOptions.small70') }, { value: '0.8', label: '80%' }, { value: '0.9', label: '90%' }, - { value: '1.0', label: '100% (默认)' }, + { value: '1.0', label: t('settings.zoomOptions.default100') }, { value: '1.1', label: '110%' }, { value: '1.2', label: '120%' }, { value: '1.3', label: '130%' }, - { value: '1.5', label: '150% (较大)' } + { value: '1.5', label: t('settings.zoomOptions.large150') } ]} />
- 调节应用界面的整体大小。 + {t('settings.zoomHint')}
@@ -354,7 +400,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }, { key: 'security', - label: '安全', + label: t('settings.tabs.security'), children: ( <> = ({ permission }} >
-
密码保护系统
+
{t('settings.security.title')}
- 管理密码 {securityStatus?.hasAdminPassword ? '已设置' : '未设置'} + {t('settings.security.adminPassword')}{' '} + {securityStatus?.hasAdminPassword + ? t('settings.security.set') + : t('settings.security.notSet')} - 积分密码 {securityStatus?.hasPointsPassword ? '已设置' : '未设置'} + {t('settings.security.pointsPassword')}{' '} + {securityStatus?.hasPointsPassword + ? t('settings.security.set') + : t('settings.security.notSet')} - 找回字符串 {securityStatus?.hasRecoveryString ? '已生成' : '未生成'} + {t('settings.security.recoveryString')}{' '} + {securityStatus?.hasRecoveryString + ? t('settings.security.generated') + : t('settings.security.notGenerated')}
@@ -382,43 +437,43 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
- + setAdminPassword(e.target.value)} - placeholder="输入6位数字(留空则不修改)" + placeholder={t('settings.security.adminPasswordPlaceholder')} maxLength={6} disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} /> - + setPointsPassword(e.target.value)} - placeholder="输入6位数字(留空则不修改)" + placeholder={t('settings.security.pointsPasswordPlaceholder')} maxLength={6} disabled={!canAdmin && Boolean(securityStatus?.hasAdminPassword)} /> - + @@ -426,20 +481,22 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
-
找回字符串重置
+
+ {t('settings.security.recoveryReset')} +
setRecoveryToReset(e.target.value)} - placeholder="输入找回字符串" + placeholder={t('settings.security.recoveryPlaceholder')} style={{ width: '420px' }} />
- 重置会清空管理/积分密码,并生成新的找回字符串。 + {t('settings.security.resetHint')}
@@ -447,7 +504,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }, { key: 'database', - label: '数据库连接', + label: t('settings.database.title'), children: ( <> = ({ permission marginBottom: '16px' }} > -
当前数据库状态
+
+ {t('settings.database.currentStatus')} +
{pgConnectionStatus.type === 'postgresql' - ? 'PostgreSQL 远程数据库' - : 'SQLite 本地数据库'} + ? t('settings.database.postgresqlRemote') + : t('settings.database.sqliteLocal')} - {pgConnectionStatus.connected ? '已连接' : '未连接'} + {pgConnectionStatus.connected + ? t('settings.database.connected') + : t('settings.database.disconnected')} {pgConnectionStatus.error && ( @@ -482,11 +543,13 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission marginBottom: '16px' }} > -
PostgreSQL 远程连接
+
+ {t('settings.database.postgresqlConnection')} +
- 输入 PostgreSQL 连接字符串以连接远程数据库,支持多端同步操作。 + {t('settings.database.connectionHint')}
= ({ permission loading={pgTestLoading} disabled={!canAdmin || !pgConnectionString} > - 测试连接 + {t('settings.database.testConnection')}
- 示例:postgresql://xxxxxx_xxxxx:xxxxxxxx@xx-xxx.xxx.neon.xxxx/xxxxdxx?sslmode=require + {t('settings.database.connectionExample')}
-
多端同步说明
+
+ {t('settings.database.syncDescription')} +
-

• 使用 PostgreSQL 远程数据库可以实现多端数据同步。

-

• 系统内置操作队列机制,确保多端同时操作时数据一致性。

-

• 切换数据库后需要重启应用以生效。

-

• 建议使用云数据库服务(如 Neon、Supabase、AWS RDS 等)。

+

{t('settings.database.syncPoint1')}

+

{t('settings.database.syncPoint2')}

+

{t('settings.database.syncPoint3')}

+

{t('settings.database.syncPoint4')}

@@ -542,11 +607,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }, { key: 'data', - label: '数据管理', + label: t('settings.data.title'), children: ( <> = ({ permission loading={settleLoading} onClick={confirmSettlement} > - 结算并重新开始 + {t('settings.data.settlementAndRestart')}
- 将当前未结算记录划分为一个阶段,并将所有学生积分清零。 + {t('settings.data.settlementHint')}
@@ -575,12 +640,16 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission marginBottom: '16px' }} > -
导入 / 导出
+
+ {t('settings.data.importExport')} +
+ - = ({ permission />
- 导入会覆盖现有学生/理由/积分记录/设置(安全相关设置不会导入)。 + {t('settings.data.importHint')}
- + + + setImportVisible(false)} footer={null} destroyOnHidden > - + = ({ canEdit }) => { setXlsxVisible(false)} onOk={handleConfirmXlsxImport} - okText="导入" + okText={t('students.importConfirm')} okButtonProps={{ loading: xlsxLoading, disabled: xlsxSelectedCol == null }} width="80%" destroyOnHidden >
-
文件:{xlsxFileName || '-'}
- 点击表头选择姓名列:{xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)} + {t('students.file')} + {xlsxFileName || '-'}
-
预览前 50 行
+
+ {t('students.selectNameCol')} + {xlsxSelectedCol == null ? '-' : excelColName(xlsxSelectedCol)} +
+
{t('students.previewRows')}
= ({ canEdit }) => { }} onConfirm={handleSaveTags} initialTagIds={editingStudent?.tagIds || []} - title={`编辑标签 - ${editingStudent?.name || ''}`} + title={t('students.editTagTitle', { name: editingStudent?.name || '' })} /> ) diff --git a/src/renderer/src/components/TagEditorDialog.tsx b/src/renderer/src/components/TagEditorDialog.tsx index f0a7564..134abae 100644 --- a/src/renderer/src/components/TagEditorDialog.tsx +++ b/src/renderer/src/components/TagEditorDialog.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react' import { Modal, Input, Button, Space, Tag, message } from 'antd' +import { useTranslation } from 'react-i18next' interface TagItem { id: number @@ -19,8 +20,9 @@ export const TagEditorDialog: React.FC = ({ onClose, onConfirm, initialTagIds = [], - title = '编辑标签' + title }) => { + const { t } = useTranslation() const [inputValue, setInputValue] = useState('') const [allTags, setAllTags] = useState([]) const [selectedTagIds, setSelectedTagIds] = useState>(new Set(initialTagIds)) @@ -35,7 +37,7 @@ export const TagEditorDialog: React.FC = ({ } } catch (e) { console.error('Failed to fetch tags:', e) - messageApi.error('获取标签列表失败') + messageApi.error(t('tags.fetchFailed')) } } @@ -52,7 +54,7 @@ export const TagEditorDialog: React.FC = ({ if (!trimmed) return if (trimmed.length > 30) { - messageApi.error('标签名称不能超过 30 个字符') + messageApi.error(t('tags.nameTooLong')) return } @@ -72,11 +74,11 @@ export const TagEditorDialog: React.FC = ({ setSelectedTagIds((prev) => new Set([...prev, res.data.id])) setInputValue('') } else { - messageApi.error(res.message || '添加标签失败') + messageApi.error(res.message || t('tags.addFailed')) } } catch (e) { console.error('Failed to create tag:', e) - messageApi.error('添加标签失败') + messageApi.error(t('tags.addFailed')) } } @@ -102,13 +104,13 @@ export const TagEditorDialog: React.FC = ({ newSet.delete(tagId) return newSet }) - messageApi.success('标签删除成功') + messageApi.success(t('tags.deleteSuccess')) } else { - messageApi.error(res.message || '删除标签失败') + messageApi.error(res.message || t('tags.deleteFailed')) } } catch (e) { console.error('Failed to delete tag:', e) - messageApi.error('删除标签失败') + messageApi.error(t('tags.deleteFailed')) } } @@ -122,12 +124,12 @@ export const TagEditorDialog: React.FC = ({ return ( @@ -135,20 +137,20 @@ export const TagEditorDialog: React.FC = ({
setInputValue(e.target.value)} onPressEnter={handleAddTag} style={{ flex: 1 }} />
- 已选标签(点击取消) + {t('tags.selectedTags')}
= ({ }} > {selectedTags.length === 0 ? ( - 未选择标签 + {t('tags.noTagsSelected')} ) : ( {selectedTags.map((tag) => ( @@ -180,7 +182,7 @@ export const TagEditorDialog: React.FC = ({
- 可选标签(点击选择) + {t('tags.availableTags')}
= ({ }} > {availableTags.length === 0 ? ( - 无可用标签 + {t('tags.noAvailableTags')} ) : ( {availableTags.map((tag) => ( diff --git a/src/renderer/src/components/ThemeQuickSettings.tsx b/src/renderer/src/components/ThemeQuickSettings.tsx index b785d06..27a0635 100644 --- a/src/renderer/src/components/ThemeQuickSettings.tsx +++ b/src/renderer/src/components/ThemeQuickSettings.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd' import type { Color } from 'antd/es/color-picker' +import { useTranslation } from 'react-i18next' import { useTheme } from '../contexts/ThemeContext' import type { themeConfig } from '../../../preload/types' @@ -22,31 +23,31 @@ const presetPrimaryColors = [ const presetGradients: { id: string - label: string + labelKey: string light: string dark: string }[] = [ { id: 'blue', - label: '清爽蓝', + labelKey: 'blue', light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)', dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)' }, { id: 'pink', - label: '柔和粉', + labelKey: 'pink', light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)', dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)' }, { id: 'cyan', - label: '青蓝', + labelKey: 'cyan', light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)', dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)' }, { id: 'purple', - label: '紫韵', + labelKey: 'purple', light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)', dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)' } @@ -68,6 +69,7 @@ const buildGradient = (a: string, b: string, dir: 'v' | 'h' | 'd'): string => { } export const ThemeQuickSettings: React.FC = ({ compact }) => { + const { t } = useTranslation() const { currentTheme, setTheme, themes, applyTheme } = useTheme() const [messageApi, holder] = message.useMessage() @@ -92,12 +94,12 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { const editable = base.id === 'custom-default' || base.id.startsWith('custom-') ? base - : { ...base, id: 'custom-default', name: '我的主题' } + : { ...base, id: 'custom-default', name: t('theme.myTheme') } setWorkingTheme(editable) setPrimaryInput(editable.config?.tdesign?.brandColor || '') }, [currentTheme]) - // 实时预览主题变化 + // Real-time theme preview useEffect(() => { if (!workingTheme) return applyTheme(workingTheme) @@ -116,14 +118,22 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { return true } - const patchThemeConfig = (type: 'tdesign' | 'custom', key: string, value: string) => { - setWorkingTheme((prev) => { - if (!prev) return prev - const next = deepClone(prev) - next.config = next.config || ({} as any) - next.config[type] = { ...(next.config[type] || {}), [key]: value } - return next - }) + const saveThemeToDb = async (theme: themeConfig) => { + if (!(window as any).api) return false + try { + const exists = themes.some((t) => t.id === theme.id) + if (!exists) { + const res = await (window as any).api.saveTheme(theme) + if (!res?.success) return false + } + if (currentTheme?.id !== theme.id) { + await setTheme(theme.id) + } + const res = await (window as any).api.saveTheme(theme) + return res?.success + } catch { + return false + } } const save = async () => { @@ -135,30 +145,50 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { if (!ok) throw new Error('保存失败') const res = await (window as any).api.saveTheme(workingTheme) if (!res?.success) throw new Error(res?.message || '保存失败') - messageApi.success('已保存') + messageApi.success(t('theme.saved')) } catch (e: any) { - messageApi.error(e?.message || '保存失败') + messageApi.error(e?.message || t('theme.saveFailed')) } finally { setSaving(false) } } - const setPrimary = (hex: string) => { + const setPrimary = async (hex: string) => { setPrimaryInput(hex) - patchThemeConfig('tdesign', 'brandColor', hex) + setWorkingTheme((prev) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config.tdesign = { ...(next.config.tdesign || {}), brandColor: hex } + saveThemeToDb(next) + return next + }) } - const setGradientPreset = (value: string) => { - patchThemeConfig('custom', '--ss-bg-color', value) + const setGradientPreset = async (value: string) => { + setWorkingTheme((prev) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': value } + saveThemeToDb(next) + return next + }) } - const setGradientFromPickers = () => { + const setGradientFromPickers = async () => { const g = buildGradient(g1, g2, gradientDir) - patchThemeConfig('custom', '--ss-bg-color', g) + setWorkingTheme((prev) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config.custom = { ...(next.config.custom || {}), '--ss-bg-color': g } + saveThemeToDb(next) + return next + }) } const setMode = async (mode: 'light' | 'dark') => { - // 根据模式切换到对应的内置主题 const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default' await setTheme(targetThemeId) } @@ -170,28 +200,28 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { {holder}
- 颜色与背景 + {t('theme.colorAndBackground')}
- 模式 + {t('theme.mode')} setMode(v as any)} options={[ - { label: '浅色', value: 'light' }, - { label: '深色', value: 'dark' } + { label: t('settings.lightMode'), value: 'light' }, + { label: t('settings.darkMode'), value: 'dark' } ]} />
- 主色 + {t('settings.primaryColor')}
= ({ compact }) => {
- 背景渐变 + {t('settings.backgroundGradient')}
= ({ compact }) => { }} />
- {g.label} + {t(`theme.gradientLabels.${g.labelKey}`)}
) @@ -276,15 +306,15 @@ export const ThemeQuickSettings: React.FC = ({ compact }) => { value={gradientDir} onChange={(v) => setGradientDir(v as any)} options={[ - { label: '纵向', value: 'v' }, - { label: '横向', value: 'h' }, - { label: '对角', value: 'd' } + { label: t('theme.gradientDirections.vertical'), value: 'v' }, + { label: t('theme.gradientDirections.horizontal'), value: 'h' }, + { label: t('theme.gradientDirections.diagonal'), value: 'd' } ]} /> setG1(c.toHexString())} /> setG2(c.toHexString())} /> - +
= ({ visible, onComplete }) => { + const { t } = useTranslation() const [loading, setLoading] = useState(false) const [messageApi, contextHolder] = message.useMessage() @@ -18,10 +20,10 @@ export const Wizard: React.FC = ({ visible, onComplete }) => { const res = await (window as any).api.setSetting('is_wizard_completed', true) if (!res?.success) throw new Error(res?.message || 'failed') - messageApi.success('配置完成!') + messageApi.success(t('wizard.configComplete')) onComplete() } catch { - messageApi.error('配置保存失败') + messageApi.error(t('wizard.configFailed')) } finally { setLoading(false) } @@ -29,12 +31,12 @@ export const Wizard: React.FC = ({ visible, onComplete }) => { return ( {}} confirmLoading={loading} - okText="开启积分之旅" + okText={t('wizard.startJourney')} cancelButtonProps={{ style: { display: 'none' } }} closable={false} mask={{ closable: false }} @@ -43,7 +45,7 @@ export const Wizard: React.FC = ({ visible, onComplete }) => { > {contextHolder} - 感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。 + {t('wizard.welcomeDesc')} diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts new file mode 100644 index 0000000..a77c5d1 --- /dev/null +++ b/src/renderer/src/i18n/index.ts @@ -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 = { + '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 => { + 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 diff --git a/src/renderer/src/i18n/locales/en-US.json b/src/renderer/src/i18n/locales/en-US.json new file mode 100644 index 0000000..6708507 --- /dev/null +++ b/src/renderer/src/i18n/locales/en-US.json @@ -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." + } +} diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..9fd4ee3 --- /dev/null +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -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": "建议导出后离线保存,遗失将无法找回。" + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 5e7b0fd..f52f9c1 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,4 +1,5 @@ import './assets/main.css' +import './i18n' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client'