diff --git a/.eslintignore b/.eslintignore index f313c245..62600dbf 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ .eslintrc.js do not edit these files +generated diff --git a/.eslintrc.js b/.eslintrc.js index 08342795..a80ab083 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,7 @@ module.exports = { 'plugin:import/recommended', 'plugin:import/typescript', 'plugin:react/jsx-runtime', - "plugin:react-hooks/recommended" + 'plugin:react-hooks/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { @@ -25,8 +25,8 @@ module.exports = { ecmaVersion: 'latest', sourceType: 'module', - project: './tsconfig.json', - tsconfigRootDir: './', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, }, plugins: ['react', '@typescript-eslint', 'prettier', 'import'], rules: { @@ -36,7 +36,7 @@ module.exports = { 'react/self-closing-comp': 'error', 'no-unused-vars': 'off', eqeqeq: 'error', - "react-hooks/exhaustive-deps": "error", + 'react-hooks/exhaustive-deps': 'error', }, settings: { react: { diff --git a/.gitignore b/.gitignore index 8a8880d9..031035d8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dist-ssr *.sw? build/ +public/locales/*.json +src/i18n/generated diff --git a/CHANGELOG.md b/CHANGELOG.md index f53c8e75..6c8e0113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2025-05-11 + +- 添加 i18n 及英文翻译 [@Constrat](https://github.com/Constrat) [@guansss](https://github.com/guansss) + ## 2025-05-02 - 添加用户名重复校验 [@dragove](https://github.com/dragove) diff --git a/README.md b/README.md index 6419d4bf..3d6e13d3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# maa-copilot-frontend +# zoot-plus-frontend -MAA 作业站前端! +ZOOT Plus 前端! ## 文档 -- ~~后端接口文档~~ (暂无,请参考 [maa-copilot-client](https://github.com/MaaAssistantArknights/maa-copilot-client-ts) 的 TS 类型,或者从后端 [Actions](https://github.com/MaaAssistantArknights/MaaBackendCenter/actions/workflows/openapi.yml) 的 Artifacts 里下载最新的 OpenAPI 文档) -- 作业格式:[MAA 战斗流程协议](https://maa.plus/docs/zh-cn/protocol/copilot-schema.html) +- ~~后端接口文档~~ (暂无,请参考 [zoot-plus-client](https://github.com/ZOOT-Plus/zoot-plus-client-ts) 的 TS 类型,或者从后端 [Actions](https://github.com/ZOOT-Plus/ZootPlusBackend/actions/workflows/openapi.yml) 的 Artifacts 里下载最新的 OpenAPI 文档) +- 作业格式:[战斗流程协议](https://maa.plus/docs/zh-cn/protocol/copilot-schema.html) -更新 maa-copilot-client 时,需要在 [Tags](https://github.com/MaaAssistantArknights/maa-copilot-client-ts/tags) 中复制版本号,然后替换掉 `package.json` 中的 `maa-copilot-client` 版本号,再运行 `yarn` 安装依赖 +更新 zoot-plus-client 时,需要在 [Tags](https://github.com/ZOOT-Plus/zoot-plus-client-ts/tags) 中复制版本号,然后替换掉 `package.json` 中的 `maa-copilot-client` 版本号,再运行 `yarn` 安装依赖 ## 开发流程 diff --git a/package.json b/package.json index 26e307aa..fdcca45b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "linkifyjs": "^3.0.5", "lodash-es": "^4.17.21", "maa-copilot-client": "https://github.com/MaaAssistantArknights/maa-copilot-client-ts.git#0.1.0-SNAPSHOT.824.f8ad839", + "mitt": "^3.0.1", "normalize.css": "^8.0.1", "prettier": "^3.2.5", "react": "^18.0.0", diff --git a/scripts/generate-translations.ts b/scripts/generate-translations.ts new file mode 100644 index 00000000..c08dbb5e --- /dev/null +++ b/scripts/generate-translations.ts @@ -0,0 +1,87 @@ +import { isObject } from 'lodash' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { inspect } from 'node:util' +import { Plugin } from 'vite' + +const translationsFile = fileURLToPath( + new URL('../src/i18n/translations.json', import.meta.url), +) +const outputDir = fileURLToPath( + new URL('../src/i18n/generated', import.meta.url), +) + +export function generateTranslations(): Plugin { + splitTranslations() + + return { + name: 'generate-translations', + apply: 'serve', + configureServer(server) { + server.watcher.on('change', (filePath) => { + if (filePath === translationsFile) { + try { + splitTranslations() + } catch (e) { + console.error('Failed to generate translations:', e) + } + } + }) + }, + } +} + +function splitTranslations() { + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }) + } + + const translationsJson = readFileSync(translationsFile, 'utf-8') + const languages = Object.keys( + JSON.parse(translationsJson).essentials.language, + ) + const essentials: Record> = {} + + for (const language of languages) { + const languageTranslations = JSON.parse(translationsJson, (key, value) => { + if ( + isObject(value) && + Object.keys(value).some((key) => languages.includes(key)) + ) { + return value[language] || '__NOT_TRANSLATED__' + } + return value + }) + + essentials[language] = languageTranslations.essentials + delete languageTranslations.essentials + + writeTsFile(`${language}.ts`, languageTranslations) + } + + writeTsFile(`essentials.ts`, essentials) + + console.log(`Translations generated for ${languages.join(', ')}`) +} + +function writeTsFile(filename: string, jsonObject: Record) { + const filePath = join(outputDir, filename) + + const literalObject = inspect(jsonObject, { + depth: 100, + maxStringLength: Infinity, + breakLength: Infinity, + sorted: false, + compact: false, + }) + + const content = `// This file is auto-generated by generate-translations.ts, do not edit it directly. +export default ${literalObject} as const` + + if (existsSync(filePath) && readFileSync(filePath, 'utf-8') === content) { + // skip writing to avoid unnecessary hot reloads + return + } + writeFileSync(filePath, content, 'utf-8') +} diff --git a/src/App.tsx b/src/App.tsx index 65b0f1a2..0401ddbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { authAtom } from 'store/auth' import { TokenManager } from 'utils/token-manager' import { GlobalErrorBoundary } from './components/GlobalErrorBoundary' +import { I18NProvider } from './i18n/I18NProvider' import { FCC } from './types' // jotai 在没有 Provider 时会使用默认的 store @@ -14,18 +15,18 @@ TokenManager.setAuthSetter((v) => getDefaultStore().set(authAtom, v)) export const App: FCC = ({ children }) => { return ( - <> - - + + + {children} - - - + + + ) } diff --git a/src/apis/announcement.ts b/src/apis/announcement.ts index 7d23801c..7a930602 100644 --- a/src/apis/announcement.ts +++ b/src/apis/announcement.ts @@ -1,5 +1,6 @@ import useSWR from 'swr' +import { i18n } from '../i18n/i18n' import mockFile from './mock/announcements.md?url' const isMock = process.env.NODE_ENV === 'development' @@ -20,7 +21,7 @@ export function useAnnouncement() { .then((res) => res.text()) .catch((e) => { if ((e as Error).message === 'Failed to fetch') { - throw new Error('网络错误') + throw new Error(i18n.apis.announcement.network_error) } throw e diff --git a/src/apis/comment.ts b/src/apis/comment.ts index 437ec1b5..5b7326ae 100644 --- a/src/apis/comment.ts +++ b/src/apis/comment.ts @@ -6,6 +6,7 @@ import useSWRInfinite from 'swr/infinite' import { CommentApi } from 'utils/maa-copilot-client' +import { i18n } from '../i18n/i18n' import { CommentRating } from '../models/comment' import { Operation } from '../models/operation' @@ -35,7 +36,7 @@ export function useComments({ } if (!isFinite(+operationId)) { - throw new Error('operationId is not a valid number') + throw new Error(i18n.apis.comment.invalid_operation_id) } return [ @@ -86,6 +87,7 @@ export async function sendComment(req: { copilotId: req.operationId, fromCommentId: req.fromCommentId, notification: false, + commentStatus: 'ENABLED', }, }) } diff --git a/src/apis/mock/announcements.md b/src/apis/mock/announcements.md index 2a595c43..fc4a80f2 100644 --- a/src/apis/mock/announcements.md +++ b/src/apis/mock/announcements.md @@ -1,4 +1,4 @@ - diff --git a/src/components/AccountManager.tsx b/src/components/AccountManager.tsx index c29641d1..051a8304 100644 --- a/src/components/AccountManager.tsx +++ b/src/components/AccountManager.tsx @@ -21,6 +21,7 @@ import { LoginPanel } from 'components/account/LoginPanel' import { authAtom } from 'store/auth' import { useCurrentSize } from 'utils/useCurrenSize' +import { useTranslation } from '../i18n/i18n' import { GlobalErrorBoundary, withGlobalErrorBoundary, @@ -30,6 +31,7 @@ import { EditDialog } from './account/EditDialog' import { RegisterPanel } from './account/RegisterPanel' const AccountMenu: FC = () => { + const t = useTranslation() const [authState, setAuthState] = useAtom(authAtom) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -39,7 +41,7 @@ const AccountMenu: FC = () => { setAuthState({}) AppToaster.show({ intent: 'success', - message: '已退出登录', + message: t.components.AccountManager.logout_success, }) } @@ -47,16 +49,16 @@ const AccountMenu: FC = () => { <> setLogoutDialogOpen(false)} onConfirm={handleLogout} > -

退出登录

-

确定要退出登录吗?

+

{t.components.AccountManager.logout}

+

{t.components.AccountManager.logout_confirm}

{ )} setEditDialogOpen(true)} /> @@ -90,7 +95,7 @@ const AccountMenu: FC = () => { shouldDismissPopover={false} intent="danger" icon="log-out" - text="退出登录" + text={t.components.AccountManager.logout} onClick={() => setLogoutDialogOpen(true)} /> @@ -102,11 +107,12 @@ export const AccountAuthDialog: ComponentType<{ open?: boolean onClose?: () => void }> = withGlobalErrorBoundary(({ open, onClose }) => { + const t = useTranslation() const [activeTab, setActiveTab] = useState('login') return ( - 登录 + + {t.components.AccountManager.login} + } panel={ @@ -142,7 +150,9 @@ export const AccountAuthDialog: ComponentType<{ title={
- 注册 + + {t.components.AccountManager.register} +
} panel={ setActiveTab('login')} />} @@ -155,6 +165,7 @@ export const AccountAuthDialog: ComponentType<{ }) export const AccountManager: ComponentType = withGlobalErrorBoundary(() => { + const t = useTranslation() const [open, setOpen] = useState(false) const [authState] = useAtom(authAtom) const { isSM } = useCurrentSize() @@ -173,7 +184,7 @@ export const AccountManager: ComponentType = withGlobalErrorBoundary(() => { ) : ( )} diff --git a/src/components/ActionCard.tsx b/src/components/ActionCard.tsx index 00458cd6..a68ef708 100644 --- a/src/components/ActionCard.tsx +++ b/src/components/ActionCard.tsx @@ -9,6 +9,7 @@ import { FactItem } from 'components/FactItem' import { CopilotDocV1 } from 'models/copilot.schema' import { findActionType } from 'models/types' +import { useTranslation } from '../i18n/i18n' import { findOperatorDirection, getSkillUsageTitle } from '../models/operator' import { formatDuration } from '../utils/times' @@ -23,12 +24,13 @@ export const ActionCard: FC = ({ action, title, }) => { + const t = useTranslation() const type = findActionType(action.type) title ??= (
- {type.title} + {type.title()}
) @@ -60,21 +62,33 @@ export const ActionCard: FC = ({ )} {'location' in action && action.location && ( - + {action.location.join(', ')} )} {'direction' in action && ( - + - {findOperatorDirection(action.direction).title} + {findOperatorDirection(action.direction).title()} )} {'distance' in action && action.distance && ( - + {action.distance.join(', ')} )} @@ -83,18 +97,22 @@ export const ActionCard: FC = ({ {/* direction:rtl is for the grid to place columns from right to left; need to set it back to ltr for the children */}
- {action.kills || '-'} - + + {action.kills || '-'} + + {action.cooling || '-'} - {action.costs || '-'} - + + {action.costs || '-'} + + {action.costChanges || '-'} - + {action.preDelay ? formatDuration(action.preDelay) : '-'} - + {action.rearDelay ? formatDuration(action.rearDelay) : '-'}
diff --git a/src/components/Confirm.tsx b/src/components/Confirm.tsx index 9db09dc8..0842fb8d 100644 --- a/src/components/Confirm.tsx +++ b/src/components/Confirm.tsx @@ -2,6 +2,7 @@ import { Alert, AlertProps } from '@blueprintjs/core' import { useEffect, useState } from 'react' +import { useTranslation } from '../i18n/i18n' import { FCC } from '../types' interface ConfirmProps extends Omit { @@ -12,16 +13,20 @@ interface ConfirmProps extends Omit { export const Confirm: FCC = ({ repeats = 0, - confirmButtonText = '确定', + confirmButtonText, intent = 'primary', trigger, onConfirm, ...props }) => { + const t = useTranslation() const [isOpen, setIsOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [remainingRepeats, setRemainingRepeats] = useState(repeats) + // Set default confirmButtonText if not provided + confirmButtonText = confirmButtonText || t.components.Confirm.confirm + useEffect(() => { if (isOpen) { setRemainingRepeats(repeats) @@ -49,7 +54,7 @@ export const Confirm: FCC = ({ <> {trigger({ handleClick: () => setIsOpen(true) })} { return ( window.location.reload()} > - 刷新页面 + {i18n.essentials.refresh_page} } /> diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 00000000..854008a3 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,41 @@ +import { Button } from '@blueprintjs/core' + +import { useAtom } from 'jotai' +import { ComponentType } from 'react' + +import { useCurrentSize } from 'utils/useCurrenSize' + +import { allEssentials, languageAtom, languages } from '../i18n/i18n' +import { withGlobalErrorBoundary } from './GlobalErrorBoundary' +import { DetailedSelect } from './editor/DetailedSelect' + +const options = languages + .map((lang) => ({ + type: 'choice' as const, + title: allEssentials[lang].language, + value: lang, + })) + .sort((a, b) => a.title.localeCompare(b.title)) + +export const LanguageSwitcher: ComponentType = withGlobalErrorBoundary(() => { + const { isSM } = useCurrentSize() + const [language, setLanguage] = useAtom(languageAtom) + + return ( + + setLanguage(item.value as (typeof options)[number]['value']) + } + popoverProps={{ + matchTargetWidth: !isSM, + }} + > + } diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index f8979ce2..d269435f 100644 --- a/src/components/OperationCard.tsx +++ b/src/components/OperationCard.tsx @@ -11,6 +11,7 @@ import { OperationRating } from 'components/viewer/OperationRating' import { OpDifficulty, Operation } from 'models/operation' import { useLevels } from '../apis/level' +import { useTranslation } from '../i18n/i18n' import { createCustomLevel, findLevelByStageName } from '../models/level' import { Paragraphs } from './Paragraphs' import { ReLinkDiv } from './ReLinkDiv' @@ -29,6 +30,7 @@ export const NeoOperationCard = ({ selected?: boolean onSelect?: (operation: Operation, selected: boolean) => void }) => { + const t = useTranslation() const { data: levels } = useLevels() return ( @@ -48,7 +50,7 @@ export const NeoOperationCard = ({ {operation.status === CopilotInfoStatusEnum.Private && ( - 私有 + {t.components.OperationCard.private} )} @@ -80,7 +82,7 @@ export const NeoOperationCard = ({
- 干员/干员组 + {t.components.OperationCard.operators_and_groups}
@@ -97,7 +99,12 @@ export const NeoOperationCard = ({
- +
{operation.views} @@ -135,6 +142,7 @@ export const NeoOperationCard = ({ } export const OperationCard = ({ operation }: { operation: Operation }) => { + const t = useTranslation() const { data: levels } = useLevels() return ( @@ -152,7 +160,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { {operation.parsedContent.doc.title} {operation.status === CopilotInfoStatusEnum.Private && ( - 私有 + {t.components.OperationCard.private} )} @@ -183,7 +191,12 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { />
- +
{operation.views} @@ -215,7 +228,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => {
- 干员/干员组 + {t.components.OperationCard.operators_and_groups}
@@ -231,6 +244,7 @@ export const OperationCard = ({ operation }: { operation: Operation }) => { } const OperatorTags = ({ operation }: { operation: Operation }) => { + const t = useTranslation() const { opers, groups } = operation.parsedContent return opers?.length || groups?.length ? ( @@ -248,7 +262,7 @@ const OperatorTags = ({ operation }: { operation: Operation }) => { content={ opers ?.map(({ name, skill }) => `${name} ${skill ?? 1}`) - .join(', ') || '无干员' + .join(', ') || t.components.OperationCard.no_operators } > [{name}] @@ -256,7 +270,7 @@ const OperatorTags = ({ operation }: { operation: Operation }) => { ))}
) : ( -
无记录
+
{t.components.OperationCard.no_records}
) } @@ -273,6 +287,7 @@ const CardActions = ({ selected?: boolean onSelect?: (operation: Operation, selected: boolean) => void }) => { + const t = useTranslation() return selectable ? ( = withSuspensable( disabled={selectedOperations.length === 0} operationIds={selectedOperations.map((op) => op.id)} > - 添加到作业集 + {t.components.OperationList.add_to_job_set} @@ -137,21 +144,21 @@ export const OperationList: ComponentType = withSuspensable( {isReachingEnd && operations.length === 0 && ( )} {isReachingEnd && operations.length !== 0 && (
- 已经到底了哦 (゚▽゚)/ + {t.components.OperationList.reached_bottom}
)} {!isReachingEnd && ( )} setDialogOpen(false)} - title="选择干员" + title={t.components.OperatorFilter.select_operators} > -
包含的干员
+
+ {t.components.OperatorFilter.included_operators} +
updateEditingFilter(included, undefined)} /> -
排除的干员
+
+ {t.components.OperatorFilter.excluded_operators} +
updateEditingFilter(undefined, excluded)} /> -

输入干员名、拼音或拼音首字母以搜索

+

+ {t.components.OperatorFilter.search_help} +

} @@ -221,7 +229,7 @@ export const OperatorFilter: FC = ({ checked={editingFilter.save} onChange={(e) => handleSave(e.currentTarget.checked)} > - 记住选择 + {t.components.OperatorFilter.remember_selection}
diff --git a/src/components/OperatorSelect.tsx b/src/components/OperatorSelect.tsx index 3c379f3d..36e4f821 100644 --- a/src/components/OperatorSelect.tsx +++ b/src/components/OperatorSelect.tsx @@ -7,6 +7,7 @@ import Fuse from 'fuse.js' import { compact } from 'lodash-es' import { FC, useMemo } from 'react' +import { useTranslation } from '../i18n/i18n' import { OPERATORS } from '../models/operator' import { useDebouncedQuery } from '../utils/useDebouncedQuery' import { OperatorAvatar } from './editor/operator/EditorOperator' @@ -24,6 +25,7 @@ export const OperatorSelect: FC = ({ operators, onChange, }) => { + const t = useTranslation() const { query, trimmedDebouncedQuery, updateQuery, onOptionMouseDown } = useDebouncedQuery() @@ -101,7 +103,11 @@ export const OperatorSelect: FC = ({ placeholder="" noResults={ trimmedDebouncedQuery ? ( - + ) : undefined } tagInputProps={{ diff --git a/src/components/RelativeTime.tsx b/src/components/RelativeTime.tsx index 882272d7..fd409cf3 100644 --- a/src/components/RelativeTime.tsx +++ b/src/components/RelativeTime.tsx @@ -1,41 +1,44 @@ import { Tooltip2, Tooltip2Props } from '@blueprintjs/popover2' -import { FC, memo, useEffect, useMemo, useState } from 'react' +import { FC, useEffect, useState } from 'react' -import { DayjsInput, formatDateTime, formatRelativeTime } from 'utils/times' +import { formatDateTime, formatRelativeTime } from '../utils/times' -export const RelativeTime: FC<{ - moment: DayjsInput +interface RelativeTimeProps { + moment: string | number | Date className?: string - detailTooltip?: boolean Tooltip2Props?: Omit -}> = memo(({ moment, className, detailTooltip = true, Tooltip2Props }) => { - const [formatted, setFormatted] = useState(formatRelativeTime(moment)) - +} + +export const RelativeTime: FC = ({ + moment, + className, + Tooltip2Props, +}) => { + // Convert to timestamp if needed + const timestamp = + typeof moment === 'string' || moment instanceof Date + ? new Date(moment).getTime() + : moment + + const formattedDate = formatDateTime(timestamp) + const [relativeTime, setRelativeTime] = useState( + formatRelativeTime(timestamp), + ) useEffect(() => { const interval = setInterval(() => { - setFormatted(formatRelativeTime(moment)) + setRelativeTime(formatRelativeTime(timestamp)) }, 5000) - return () => clearInterval(interval) - }, [moment]) - - const absoluteTime = useMemo(() => { - return formatDateTime(moment) - }, [moment]) - - const child = useMemo( - () => {formatted}, - [formatted, className], - ) - - return detailTooltip ? ( - - {child} + }, [timestamp]) + + return ( + + {relativeTime} - ) : ( - child ) -}) - -RelativeTime.displayName = 'RelativeTime' +} diff --git a/src/components/Suspensable.tsx b/src/components/Suspensable.tsx index 1cf84ed1..0d1d68f9 100644 --- a/src/components/Suspensable.tsx +++ b/src/components/Suspensable.tsx @@ -4,11 +4,13 @@ import { ErrorBoundary } from '@sentry/react' import { ComponentType, Suspense, useEffect, useRef } from 'react' import { FCC } from 'types' +import { useTranslation } from '../i18n/i18n' + interface SuspensableProps { // deps that will cause the Suspense's error to reset retryDeps?: readonly any[] - pendingTitle?: string + pendingTitle?: string | (() => string) fetcher?: () => void errorFallback?: (params: { error: Error }) => JSX.Element | undefined @@ -17,11 +19,17 @@ interface SuspensableProps { export const Suspensable: FCC = ({ children, retryDeps = [], - pendingTitle = '加载中', + pendingTitle, fetcher, errorFallback, }) => { const resetError = useRef<() => void>() + const t = useTranslation() + + if (typeof pendingTitle === 'function') { + pendingTitle = pendingTitle() + } + pendingTitle ??= t.components.Suspensable.loading useEffect(() => { resetError.current?.() @@ -42,8 +50,12 @@ export const Suspensable: FCC = ({ return ( = ({ fetcher() }} > - 重试 + {t.components.Suspensable.retry} ) } diff --git a/src/components/UserFilter.tsx b/src/components/UserFilter.tsx index fd8ea902..d1b27d85 100644 --- a/src/components/UserFilter.tsx +++ b/src/components/UserFilter.tsx @@ -6,6 +6,7 @@ import { MaaUserInfo } from 'maa-copilot-client' import { FC, useEffect } from 'react' import { useUserSearch } from '../apis/user' +import { useTranslation } from '../i18n/i18n' import { authAtom } from '../store/auth' import { formatError } from '../utils/error' import { useDebouncedQuery } from '../utils/useDebouncedQuery' @@ -32,6 +33,7 @@ export const UserFilter: FC = ({ user, onChange, }) => { + const t = useTranslation() const auth = useAtomValue(authAtom) const { query, debouncedQuery, updateQuery, onOptionMouseDown } = useDebouncedQuery({ debounceTime: 500 }) @@ -78,17 +80,17 @@ export const UserFilter: FC = ({ disabled text={ isLoading - ? '正在搜索...' + ? t.components.UserFilter.searching : error - ? '搜索失败:' + formatError(error) + ? t.components.UserFilter.search_failed + formatError(error) : query && debouncedQuery - ? '查无此人 (゚Д゚≡゚д゚)!?' - : '输入用户名以搜索' + ? t.components.UserFilter.no_user_found + : t.components.UserFilter.enter_username } /> } inputProps={{ - placeholder: '用户名称', + placeholder: t.components.UserFilter.username_placeholder, leftElement: isValidating ? ( ) : undefined, @@ -103,7 +105,9 @@ export const UserFilter: FC = ({ icon="person" rightIcon="chevron-down" > - {user && !isMyself(user) ? user.userName : '作者'} + {user && !isMyself(user) + ? user.userName + : t.components.UserFilter.author} {!!auth.token && ( @@ -111,7 +115,7 @@ export const UserFilter: FC = ({ minimal icon="user" className="!px-3" - title="查看我自己的作业" + title={t.components.UserFilter.view_my_jobs} active={isMyself(user)} intent={isMyself(user) ? 'primary' : 'none'} onClick={() => { @@ -122,7 +126,7 @@ export const UserFilter: FC = ({ } }} > - 看看我的 + {t.components.UserFilter.view_mine} )} diff --git a/src/components/account/AuthFormShared.tsx b/src/components/account/AuthFormShared.tsx index c147c671..09a456ae 100644 --- a/src/components/account/AuthFormShared.tsx +++ b/src/components/account/AuthFormShared.tsx @@ -9,33 +9,60 @@ import { import { FormField, FormFieldProps } from 'components/FormField' import { REGEX_EMAIL, REGEX_USERNAME } from 'utils/regexes' +import { useTranslation } from '../../i18n/i18n' + export type RuleKeys = 'email' | 'password' | 'username' | 'registertoken' -export const rule: Record = { - email: { - required: '邮箱为必填项', - pattern: { value: REGEX_EMAIL, message: '不合法的邮箱' }, - }, - password: { - required: '密码为必填项', - minLength: { value: 8, message: '密码长度不能小于 8 位' }, - maxLength: { value: 32, message: '密码长度不能大于 32 位' }, - }, - username: { - required: '用户名为必填项', - minLength: { value: 4, message: '用户名长度不能小于 4 位' }, - maxLength: { value: 24, message: '用户名长度不能大于 24 位' }, - pattern: { value: REGEX_USERNAME, message: '用户名前后不能包含空格' }, - }, - registertoken: { - required: '邮箱验证码为必填项', - minLength: { value: 6, message: '邮箱验证码长度为 6 位' }, - maxLength: { value: 6, message: '邮箱验证码长度为 6 位' }, - }, +function useRules(): Record { + const t = useTranslation() + return { + email: { + required: t.components.account.AuthFormShared.email_required, + pattern: { + value: REGEX_EMAIL, + message: t.components.account.AuthFormShared.email_invalid, + }, + }, + password: { + required: t.components.account.AuthFormShared.password_required, + minLength: { + value: 8, + message: t.components.account.AuthFormShared.password_min_length, + }, + maxLength: { + value: 32, + message: t.components.account.AuthFormShared.password_max_length, + }, + }, + username: { + required: t.components.account.AuthFormShared.username_required, + minLength: { + value: 4, + message: t.components.account.AuthFormShared.username_min_length, + }, + maxLength: { + value: 24, + message: t.components.account.AuthFormShared.username_max_length, + }, + pattern: { + value: REGEX_USERNAME, + message: t.components.account.AuthFormShared.username_pattern, + }, + }, + registertoken: { + required: t.components.account.AuthFormShared.token_required, + minLength: { + value: 6, + message: t.components.account.AuthFormShared.token_length, + }, + maxLength: { + value: 6, + message: t.components.account.AuthFormShared.token_length, + }, + }, + } } -// --- **Opinioned** AuthForm Field Components --- - export type AuthFormFieldProps = Pick< FormFieldProps, 'control' | 'error' | 'field' @@ -49,7 +76,7 @@ export type AuthFormFieldProps = Pick< } export const AuthFormEmailField = ({ - label = '邮箱', + label, control, error, field, @@ -57,14 +84,17 @@ export const AuthFormEmailField = ({ autoComplete = 'email', inputGroupProps, }: AuthFormFieldProps) => { + const t = useTranslation() + const rules = useRules() + return ( ( ({ ), }} FormGroupProps={{ - helperText: register && '将通过发送邮件输入验证码确认', + helperText: + register && + t.components.account.AuthFormShared.email_verification_note, }} /> ) } + export const AuthRegistrationTokenField = ({ - label = '邮箱验证码', + label, control, error, field, @@ -93,14 +126,19 @@ export const AuthRegistrationTokenField = ({ autoComplete = '', inputGroupProps, }: AuthFormFieldProps) => { + const t = useTranslation() + const rules = useRules() + return ( ( ({ ), }} FormGroupProps={{ - helperText: register && '请输入邮件中的验证码', + helperText: + register && t.components.account.AuthFormShared.enter_email_code, }} /> ) } export const AuthFormPasswordField = ({ - label = '密码', + label, control, error, field, autoComplete = 'current-password', inputGroupProps, }: AuthFormFieldProps) => { + const t = useTranslation() + const rules = useRules() + return ( ( ({ } export const AuthFormUsernameField = ({ - label = '用户名', + label, control, error, field, autoComplete = 'username', inputGroupProps, }: AuthFormFieldProps) => { + const t = useTranslation() + const rules = useRules() + return ( ( = ({ isOpen, onClose }) => { + const t = useTranslation() const [activeTab, setActiveTab] = useState('info') return ( - +
= ({ isOpen, onClose }) => { title={
- 账户信息 + + {t.components.account.EditDialog.account_info} +
} panel={} @@ -58,7 +67,9 @@ export const EditDialog: FC = ({ isOpen, onClose }) => { title={
- 密码 + + {t.components.account.EditDialog.password} +
} panel={} @@ -71,6 +82,8 @@ export const EditDialog: FC = ({ isOpen, onClose }) => { } const InfoPanel = ({ onClose }) => { + const t = useTranslation() + interface FormValues { username: string } @@ -107,7 +120,7 @@ const InfoPanel = ({ onClose }) => { AppToaster.show({ intent: 'success', - message: `更新成功`, + message: t.components.account.EditDialog.update_success, }) onClose(false) } catch (e) { @@ -119,7 +132,11 @@ const InfoPanel = ({ onClose }) => { return (
{globalError && ( - + {globalError} )} @@ -143,7 +160,7 @@ const InfoPanel = ({ onClose }) => { onSubmit(e) }} > - 保存 + {t.components.account.EditDialog.save}
@@ -151,6 +168,8 @@ const InfoPanel = ({ onClose }) => { } const PasswordPanel = ({ onClose }) => { + const t = useTranslation() + interface FormValues { original: string newPassword: string @@ -172,7 +191,9 @@ const PasswordPanel = ({ onClose }) => { const onSubmit = handleSubmit( async ({ original, newPassword, newPassword2 }) => { if (newPassword !== newPassword2) { - setError('newPassword2', { message: '两次输入的密码不一致' }) + setError('newPassword2', { + message: t.components.account.EditDialog.passwords_dont_match, + }) return } @@ -181,7 +202,7 @@ const PasswordPanel = ({ onClose }) => { AppToaster.show({ intent: 'success', - message: `更新成功`, + message: t.components.account.EditDialog.update_success, }) onClose(false) } catch (e) { @@ -195,26 +216,30 @@ const PasswordPanel = ({ onClose }) => { <>
{globalError && ( - + {globalError} )} { icon="key" onClick={() => setResetPasswordDialogOpen(true)} > - 忘记密码... + {t.components.account.EditDialog.forgot_password} diff --git a/src/components/account/LoginPanel.tsx b/src/components/account/LoginPanel.tsx index 2390fc98..19c7b1e3 100644 --- a/src/components/account/LoginPanel.tsx +++ b/src/components/account/LoginPanel.tsx @@ -10,6 +10,7 @@ import { authAtom, fromCredentials } from 'store/auth' import { formatError } from 'utils/error' import { wrapErrorMessage } from 'utils/wrapErrorMessage' +import { useTranslation } from '../../i18n/i18n' import { AuthFormEmailField, AuthFormPasswordField } from './AuthFormShared' import { ResetPasswordDialog } from './ResetPasswordDialog' @@ -22,6 +23,7 @@ export const LoginPanel: FC<{ onNavigateRegisterPanel: () => void onComplete: () => void }> = ({ onNavigateRegisterPanel, onComplete }) => { + const t = useTranslation() const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false) const { @@ -33,13 +35,18 @@ export const LoginPanel: FC<{ const onSubmit = async ({ email, password }: LoginFormValues) => { const res = await wrapErrorMessage( - (e) => `登录失败:${formatError(e)}`, + (e) => + t.components.account.LoginPanel.login_failed({ + error: formatError(e), + }), login({ email, password }), ) setAuthState(fromCredentials(res)) AppToaster.show({ intent: 'success', - message: `登录成功。欢迎回来,${res.userInfo.userName}`, + message: t.components.account.LoginPanel.login_success({ + name: res.userInfo.userName, + }), }) onComplete() } @@ -65,16 +72,18 @@ export const LoginPanel: FC<{ icon="key" onClick={() => setResetPasswordDialogOpen(true)} > - 忘记密码... + {t.components.account.LoginPanel.forgot_password} ), })} />
- 还没有账号? + + {t.components.account.LoginPanel.no_account} +
@@ -87,7 +96,7 @@ export const LoginPanel: FC<{ icon="log-in" className="self-stretch" > - 登录 + {t.components.account.LoginPanel.login}
diff --git a/src/components/account/RegisterPanel.tsx b/src/components/account/RegisterPanel.tsx index d6ead6ba..7efa406a 100644 --- a/src/components/account/RegisterPanel.tsx +++ b/src/components/account/RegisterPanel.tsx @@ -9,6 +9,7 @@ import { formatError } from 'utils/error' import { REGEX_EMAIL } from 'utils/regexes' import { wrapErrorMessage } from 'utils/wrapErrorMessage' +import { useTranslation } from '../../i18n/i18n' import { AuthFormEmailField, AuthFormPasswordField, @@ -26,6 +27,8 @@ export interface RegisterFormValues { export const RegisterPanel: FC<{ onComplete: () => void }> = ({ onComplete }) => { + const t = useTranslation() + const { control, handleSubmit, @@ -37,7 +40,10 @@ export const RegisterPanel: FC<{ const [countdown, setCountdown] = useState(60) const onSubmit = async (val: RegisterFormValues) => { await wrapErrorMessage( - (e) => `注册失败:${formatError(e)}`, + (e) => + t.components.account.RegisterPanel.registration_failed({ + error: formatError(e), + }), register({ email: val.email, registrationToken: val.registrationToken, @@ -47,7 +53,7 @@ export const RegisterPanel: FC<{ ) AppToaster.show({ intent: 'success', - message: `注册成功`, + message: t.components.account.RegisterPanel.registration_success, }) onComplete() } @@ -71,17 +77,20 @@ export const RegisterPanel: FC<{ if (!REGEX_EMAIL.test(val.email)) { AppToaster.show({ intent: 'danger', - message: `邮箱输入为空或格式错误,请重新输入`, + message: t.components.account.RegisterPanel.invalid_email, }) return } await wrapErrorMessage( - (e) => `发送失败:${formatError(e)}`, + (e) => + t.components.account.RegisterPanel.send_failed({ + error: formatError(e), + }), sendRegistrationEmail({ email: val.email }), ) AppToaster.show({ intent: 'success', - message: `邮件发送成功`, + message: t.components.account.RegisterPanel.email_sent_success, }) setSendEmailButtonDisabled(true) } catch (e) { @@ -107,7 +116,11 @@ export const RegisterPanel: FC<{ className="self-stretch" onClick={onEmailSubmit} > - {isSendEmailButtonDisabled ? `${countdown} 秒再试` : '发送验证码'} + {isSendEmailButtonDisabled + ? t.components.account.RegisterPanel.retry_seconds({ + seconds: countdown, + }) + : t.components.account.RegisterPanel.send_verification_code}
- 注册 + {t.components.account.RegisterPanel.register} diff --git a/src/components/account/ResetPasswordDialog.tsx b/src/components/account/ResetPasswordDialog.tsx index e75e3b56..9746a2c0 100644 --- a/src/components/account/ResetPasswordDialog.tsx +++ b/src/components/account/ResetPasswordDialog.tsx @@ -4,6 +4,7 @@ import { resetPassword, sendResetPasswordEmail } from 'apis/auth' import { FC, useState } from 'react' import { FieldErrors, useForm } from 'react-hook-form' +import { useTranslation } from '../../i18n/i18n' import { formatError } from '../../utils/error' import { useNetworkState } from '../../utils/useNetworkState' import { wrapErrorMessage } from '../../utils/wrapErrorMessage' @@ -27,6 +28,8 @@ export const ResetPasswordDialog: FC = ({ isOpen, onClose, }) => { + const t = useTranslation() + const { control, handleSubmit, @@ -47,7 +50,7 @@ export const ResetPasswordDialog: FC = ({ AppToaster.show({ intent: 'success', - message: `重置成功,请重新登录`, + message: t.components.account.ResetPasswordDialog.reset_success, }) onClose() } catch (e) { @@ -59,7 +62,7 @@ export const ResetPasswordDialog: FC = ({ return ( = ({
{globalError && ( - + {globalError} )} @@ -88,17 +95,22 @@ export const ResetPasswordDialog: FC = ({ /> ( ), @@ -124,7 +136,7 @@ export const ResetPasswordDialog: FC = ({ onSubmit(e) }} > - 保存 + {t.components.account.ResetPasswordDialog.save} @@ -141,20 +153,24 @@ const RequestTokenButton = ({ email: string disabled: boolean }) => { + const t = useTranslation() const { networkState, start, finish } = useNetworkState() const [sent, setSent] = useState(false) const handleClick = () => { start() wrapErrorMessage( - (e) => `获取验证码失败:${formatError(e)}`, + (e) => + t.components.account.ResetPasswordDialog.get_code_failed({ + error: formatError(e), + }), sendResetPasswordEmail({ email }), ) .then(() => { finish(null) setSent(true) AppToaster.show({ - message: '验证码已发送至您的邮箱', + message: t.components.account.ResetPasswordDialog.code_sent, intent: 'success', }) }) @@ -170,7 +186,9 @@ const RequestTokenButton = ({ onClick={handleClick} loading={networkState.loading} > - {sent ? '重新发送' : '获取验证码'} + {sent + ? t.components.account.ResetPasswordDialog.resend + : t.components.account.ResetPasswordDialog.get_code} ) } diff --git a/src/components/announcement/AnnDialog.tsx b/src/components/announcement/AnnDialog.tsx index 62a252f4..c063549a 100644 --- a/src/components/announcement/AnnDialog.tsx +++ b/src/components/announcement/AnnDialog.tsx @@ -11,6 +11,7 @@ import { FC } from 'react' import { Components } from 'react-markdown' import { announcementBaseURL } from '../../apis/announcement' +import { useTranslation } from '../../i18n/i18n' import { AnnouncementSection, AnnouncementSectionMeta, @@ -23,6 +24,7 @@ interface AnnDialogProps extends DialogProps { } export const AnnDialog: FC = ({ sections, ...dialogProps }) => { + const t = useTranslation() const content = sections?.map(({ raw }) => raw).join('\n\n') // attach each section's meta to its heading node @@ -65,7 +67,12 @@ export const AnnDialog: FC = ({ sections, ...dialogProps }) => { } return ( - + {content ? ( = ({ sections, ...dialogProps }) => { {content || ''} ) : ( - + )} + diff --git a/src/components/announcement/AnnPanel.tsx b/src/components/announcement/AnnPanel.tsx index fb4b5f0c..65d85121 100644 --- a/src/components/announcement/AnnPanel.tsx +++ b/src/components/announcement/AnnPanel.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { FC, ReactNode, useEffect, useMemo, useState } from 'react' import { useAnnouncement } from '../../apis/announcement' +import { useTranslation } from '../../i18n/i18n' import { AnnouncementSection, parseAnnouncement, @@ -19,6 +20,7 @@ interface AnnPanelProps { } export const AnnPanel: FC = ({ className, trigger }) => { + const t = useTranslation() const { data, error } = useAnnouncement() const announcement = useMemo( () => (data ? parseAnnouncement(data) : undefined), @@ -53,7 +55,9 @@ export const AnnPanel: FC = ({ className, trigger }) => { trigger ??= ({ handleClick }) => ( - 公告 + + {t.components.announcement.AnnPanel.title} +
{announcement && ( @@ -65,7 +69,9 @@ export const AnnPanel: FC = ({ className, trigger }) => { )} {!announcement && error && (
- 公告加载失败:{formatError(error)} + {t.components.announcement.AnnPanel.load_failed({ + error: formatError(error), + })}
)} diff --git a/src/components/drawer/NavAside.tsx b/src/components/drawer/NavAside.tsx index 4b15c1ca..e70e4331 100644 --- a/src/components/drawer/NavAside.tsx +++ b/src/components/drawer/NavAside.tsx @@ -1,24 +1,28 @@ import { Drawer, Menu, MenuDivider } from '@blueprintjs/core' import { MenuItem2 } from '@blueprintjs/popover2' +import { useLinks } from 'hooks/useLinks' import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { NavLink } from 'react-router-dom' import { navAtom, toggleExpandNavAtom } from 'store/nav' -import { NAV_LINKS, SOCIAL_LINKS } from '../../links' +import { useTranslation } from '../../i18n/i18n' import { useCurrentSize } from '../../utils/useCurrenSize' import { AnnPanel } from '../announcement/AnnPanel' import { OperationSetEditorDialog } from '../operation-set/OperationSetEditor' export const NavAside = () => { + const t = useTranslation() const { isMD } = useCurrentSize() const nav = useAtomValue(navAtom) const toggleNav = useSetAtom(toggleExpandNavAtom) const [showOperationSetDialog, setShowOperationSetDialog] = useState(false) + const { NAV_LINKS, SOCIAL_LINKS } = useLinks() + if (!isMD) return null return ( @@ -51,7 +55,7 @@ export const NavAside = () => { { setShowOperationSetDialog(true) @@ -62,7 +66,7 @@ export const NavAside = () => { trigger={({ handleClick }) => ( diff --git a/src/components/editor/CardOptions.tsx b/src/components/editor/CardOptions.tsx index 4de55c97..b186d0b7 100644 --- a/src/components/editor/CardOptions.tsx +++ b/src/components/editor/CardOptions.tsx @@ -4,48 +4,67 @@ import { Popover2 } from '@blueprintjs/popover2' import clsx from 'clsx' import { FC } from 'react' +import { useTranslation } from '../../i18n/i18n' + export const CardDuplicateOption: FC = ({ className, ...props -}) => ( -
} diff --git a/src/components/editor/EditorIntegerInput.tsx b/src/components/editor/EditorIntegerInput.tsx index 04de1d63..685222cb 100644 --- a/src/components/editor/EditorIntegerInput.tsx +++ b/src/components/editor/EditorIntegerInput.tsx @@ -5,6 +5,7 @@ import { FieldValues, useController } from 'react-hook-form' import { EditorFieldProps } from 'components/editor/EditorFieldProps' +import { useTranslation } from '../../i18n/i18n' import { FieldResetButton } from '../FieldResetButton' import { NumericInput2 } from './NumericInput2' @@ -23,6 +24,7 @@ export const EditorIntegerInput = ({ NumericInputProps, ...controllerProps }: EditorIntegerInputProps) => { + const t = useTranslation() const { min } = NumericInputProps const { @@ -33,7 +35,14 @@ export const EditorIntegerInput = ({ control, ...controllerProps, rules: { - min: isNil(min) ? undefined : { value: min, message: '最小为 ${min}' }, + min: isNil(min) + ? undefined + : { + value: min, + message: t.components.editor.EditorIntegerInput.min_value({ + min, + }), + }, ...rules, }, }) diff --git a/src/components/editor/EditorResetButton.tsx b/src/components/editor/EditorResetButton.tsx index 0a11ea73..53a96089 100644 --- a/src/components/editor/EditorResetButton.tsx +++ b/src/components/editor/EditorResetButton.tsx @@ -3,6 +3,8 @@ import { Alert, Button, H4 } from '@blueprintjs/core' import { useState } from 'react' import { FieldValues, UseFormReset } from 'react-hook-form' +import { useTranslation } from '../../i18n/i18n' + export const EditorResetButton = ({ reset, entityName, @@ -10,14 +12,15 @@ export const EditorResetButton = ({ reset: UseFormReset entityName: string }) => { + const t = useTranslation() const [resetDialogOpen, setResetDialogOpen] = useState(false) return ( <> ({ setResetDialogOpen(false) }} > -

重置{entityName}

-

确定要重置{entityName}吗?

+

+ {t.components.editor.EditorResetButton.reset_entity({ + entityName, + })} +

+

+ {t.components.editor.EditorResetButton.confirm_reset({ + entityName, + })} +

) diff --git a/src/components/editor/FormError.tsx b/src/components/editor/FormError.tsx index 30bb0edb..ee974fd7 100644 --- a/src/components/editor/FormError.tsx +++ b/src/components/editor/FormError.tsx @@ -2,6 +2,8 @@ import { Callout } from '@blueprintjs/core' import { FieldError, FieldErrors, FieldValues } from 'react-hook-form' +import { useTranslation } from '../../i18n/i18n' + interface FormErrorProps { errors: FieldErrors } @@ -9,6 +11,7 @@ interface FormErrorProps { export const FormError = ({ errors, }: FormErrorProps) => { + const t = useTranslation() const errorsArray = Object.values(errors) as FieldError[] if (errorsArray.length === 0) { @@ -16,10 +19,16 @@ export const FormError = ({ } return ( - +
    {errorsArray.map((error, i) => ( -
  1. {error?.message || '未知错误'}
  2. +
  3. + {error?.message || t.components.editor.FormError.unknown_error} +
  4. ))}
diff --git a/src/components/editor/OperationEditor.tsx b/src/components/editor/OperationEditor.tsx index 51c02c34..00b66e0e 100644 --- a/src/components/editor/OperationEditor.tsx +++ b/src/components/editor/OperationEditor.tsx @@ -28,6 +28,7 @@ import { HelperText } from 'components/HelperText' import type { CopilotDocV1 } from 'models/copilot.schema' import { Level, OpDifficulty, OpDifficultyBitFlag } from 'models/operation' +import { useTranslation } from '../../i18n/i18n' import { createCustomLevel, findLevelByStageName, @@ -55,13 +56,14 @@ import { export const StageNameInput: FC<{ control: Control }> = ({ control }) => { + const t = useTranslation() const { field: { value, onChange, onBlur }, fieldState, } = useController({ name: 'stageName', control, - rules: { required: '请输入关卡' }, + rules: { required: t.components.editor.OperationEditor.stage_required }, }) // we are going to manually handle loading state so we could show the skeleton state easily, @@ -116,16 +118,16 @@ export const StageNameInput: FC<{ return ( -

键入以搜索

-

对于主线、活动关卡:键入关卡代号、关卡中文名或活动名称

-

对于悖论模拟关卡:键入关卡名或干员名

+

{t.components.editor.OperationEditor.type_to_search}

+

{t.components.editor.OperationEditor.for_main_event_stages}

+

{t.components.editor.OperationEditor.for_paradox_stages}

), }} @@ -154,27 +156,37 @@ export const StageNameInput: FC<{ onItemSelect={selectLevel} inputValueRenderer={(item) => isCustomLevel(item) - ? `${item.name} (自定义)` + ? `${item.name} (${t.components.editor.OperationEditor.custom})` : `${item.catThree} ${item.name}` } - noResults={} + noResults={ + + } createNewItemFromQuery={(query) => createCustomLevel(query)} createNewItemRenderer={(query, active, handleClick) => ( )} inputProps={{ - placeholder: '关卡', + placeholder: t.components.editor.OperationEditor.stage, large: true, onBlur, }} /> - + }> = ({ control }) => { + const t = useTranslation() const { field: { value = OpDifficulty.UNKNOWN, onChange }, fieldState: { error }, @@ -226,10 +239,12 @@ const DifficultyPicker: FC<{ return ( toggle(OpDifficultyBitFlag.REGULAR)} > - 普通 + {t.components.editor.OperationEditor.normal} @@ -269,6 +284,7 @@ export const OperationEditor: FC = ({ }, toolbar, }) => { + const t = useTranslation() const { data: levels } = useLevels() const stageName = watch('stageName') @@ -298,14 +314,21 @@ export const OperationEditor: FC = ({
- 作业编辑器 + + {t.components.editor.OperationEditor.job_editor} +
{toolbar}
{globalError && ( - + {globalError.split('\n').map((line) => (

{line}

))} @@ -313,24 +336,29 @@ export const OperationEditor: FC = ({ )}
-

作业元信息

+

{t.components.editor.OperationEditor.job_metadata}

( @@ -346,7 +374,7 @@ export const OperationEditor: FC = ({
= ({ growVertically large id="doc.details" - placeholder="如:作者名、参考的视频攻略链接(如有)等" + placeholder={ + t.components.editor.OperationEditor + .description_placeholder + } {...field} value={field.value || ''} /> @@ -375,9 +406,11 @@ export const OperationEditor: FC = ({
-

动作序列

+

{t.components.editor.OperationEditor.action_sequence}

- 拖拽以重新排序 + + {t.components.editor.OperationEditor.drag_to_reorder} +
@@ -391,6 +424,7 @@ export const OperationEditor: FC = ({ } const EditorPerformerPanel: FC = (props) => { + const t = useTranslation() const [reload, setReload] = useState(false) // temporary workaround for https://github.com/clauderic/dnd-kit/issues/799 @@ -401,19 +435,21 @@ const EditorPerformerPanel: FC = (props) => { return ( <> -

干员与干员组

+

{t.components.editor.OperationEditor.operators_and_groups}

- 拖拽以重新排序或分配干员 - 如果拖拽速度过快可能会使动画出现问题,此时请点击 + {t.components.editor.OperationEditor.drag_to_reorder_operators} + + + {t.components.editor.OperationEditor.drag_too_fast_issue} - 以修复 (不会丢失数据) + {t.components.editor.OperationEditor.to_fix_no_data_loss} diff --git a/src/components/editor/OperationEditorLauncher.tsx b/src/components/editor/OperationEditorLauncher.tsx index 2f8cfd90..521a0675 100644 --- a/src/components/editor/OperationEditorLauncher.tsx +++ b/src/components/editor/OperationEditorLauncher.tsx @@ -3,12 +3,16 @@ import { Button } from '@blueprintjs/core' import { FC } from 'react' import { Link } from 'react-router-dom' +import { useTranslation } from '../../i18n/i18n' + export const OperationEditorLauncher: FC = () => { + const t = useTranslation() + return ( <> diff --git a/src/components/editor/action/EditorActionAdd.tsx b/src/components/editor/action/EditorActionAdd.tsx index 0a43fecc..c7c07e78 100644 --- a/src/components/editor/action/EditorActionAdd.tsx +++ b/src/components/editor/action/EditorActionAdd.tsx @@ -29,6 +29,7 @@ import { EditorActionTypeSelect } from 'components/editor/action/EditorActionTyp import { CopilotDocV1 } from 'models/copilot.schema' import { useLevels } from '../../../apis/level' +import { useTranslation } from '../../../i18n/i18n' import { findLevelByStageName } from '../../../models/level' import { EditorOperatorName } from '../operator/EditorOperator' import { EditorOperatorSkillTimes } from '../operator/EditorOperatorSkillTimes' @@ -65,6 +66,7 @@ export const EditorActionAdd = ({ onSubmit: _onSubmit, onCancel, }: EditorActionAddProps) => { + const t = useTranslation() const isNew = !editingAction const operatorGroups = useWatch({ control: operationControl, name: 'groups' }) const operators = useWatch({ control: operationControl, name: 'opers' }) @@ -114,7 +116,7 @@ export const EditorActionAdd = ({ // 当重置时没办法正常清空item组件内部的值。 // 放入setTimeout中,延迟赋值,就可以避免丢失绑定的问题 setTimeout(() => { - // 修复先点击“部署”动作的编辑按钮,再连续点击“二倍速”动作的编辑按钮,“部署”的数据丢失 + // 修复先点击"部署"动作的编辑按钮,再连续点击"二倍速"动作的编辑按钮,"部署"的数据丢失 // 原因:通过reset方式赋值给form,相当于将action变量跟form绑定, // 当再通过reset(undefined)后,会将action的值置为null, // 通过setValue的方式,不会将action和form绑定 @@ -196,18 +198,27 @@ export const EditorActionAdd = ({
- {isNew ? '添加' : '编辑'}动作 + + {isNew + ? t.components.editor.action.EditorActionAdd.add + : t.components.editor.action.EditorActionAdd.edit} + {t.components.editor.action.EditorActionAdd.action} +
- {isNew ? '添加' : '保存'} + {isNew + ? t.components.editor.action.EditorActionAdd.add + : t.components.editor.action.EditorActionAdd.save} reset(resettingValues)} - entityName="正在编辑的动作" + entityName={ + t.components.editor.action.EditorActionAdd.current_action + } />
@@ -216,7 +227,7 @@ export const EditorActionAdd = ({
- label="干员或干员组名" - description="选择干员、使用干员名、或使用干员组名引用" + label={ + t.components.editor.action.EditorActionAdd.operator_group_name + } + description={ + t.components.editor.action.EditorActionAdd + .select_operator_description + } field="name" error={ ( @@ -251,8 +267,18 @@ export const EditorActionAdd = ({ FormGroupProps={{ helperText: ( <> -

键入干员名、拼音或拼音首字母以搜索干员列表

-

键入干员组名以引用干员组配置

+

+ { + t.components.editor.action.EditorActionAdd + .search_operator_hint + } +

+

+ { + t.components.editor.action.EditorActionAdd + .reference_group_hint + } +

), }} @@ -266,7 +292,8 @@ export const EditorActionAdd = ({ rules={{ required: (type === 'Deploy' || type === 'SkillUsage') && - '必须填写干员或干员组名', + t.components.editor.action.EditorActionAdd + .operator_required, }} />
@@ -301,7 +328,7 @@ export const EditorActionAdd = ({ {type === 'SkillUsage' && (
) @@ -318,7 +345,9 @@ export const EditorActionAdd = ({ {skillUsage === CopilotDocV1.SkillUsageType.ReadyToUseTimes && ( ) @@ -337,7 +366,7 @@ export const EditorActionAdd = ({ {type === 'MoveCamera' && ( <> - 移动距离一般不需要修改,只填写前置延迟(15000)和击杀数条件即可 + {t.components.editor.action.EditorActionAdd.camera_movement_hint}
@@ -372,7 +403,7 @@ export const EditorActionAdd = ({
@@ -384,7 +415,7 @@ export const EditorActionAdd = ({ /> @@ -407,12 +441,14 @@ export const EditorActionAdd = ({
- {isNew ? '添加' : '保存'} + {isNew + ? t.components.editor.action.EditorActionAdd.add + : t.components.editor.action.EditorActionAdd.save} {!isNew && ( )}
diff --git a/src/components/editor/action/EditorActionDelay.tsx b/src/components/editor/action/EditorActionDelay.tsx index 9fa646e8..983dd19a 100644 --- a/src/components/editor/action/EditorActionDelay.tsx +++ b/src/components/editor/action/EditorActionDelay.tsx @@ -5,6 +5,7 @@ import { EditorFieldProps } from 'components/editor/EditorFieldProps' import { EditorIntegerInput } from 'components/editor/EditorIntegerInput' import type { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { FormField2 } from '../../FormField' interface EditorActionDelayProps @@ -15,19 +16,22 @@ export const EditorActionPreDelay = ({ control, ...controllerProps }: EditorActionDelayProps) => { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( { + const t = useTranslation() + const { field: { onChange, onBlur, value }, formState: { errors }, @@ -23,7 +26,8 @@ export const EditorActionDistance = ({ name, control, rules: { - required: '必须填写移动距离', + required: + t.components.editor.action.EditorActionDistance.distance_required, validate: (v) => { // v being undefined is allowed because the `required` rule will handle it properly if (v) { @@ -34,7 +38,8 @@ export const EditorActionDistance = ({ v.every((i) => Number.isFinite(i)) ) ) { - return '不是有效的数字' + return t.components.editor.action.EditorActionDistance + .not_valid_number } } return undefined @@ -64,7 +69,7 @@ export const EditorActionDistance = ({ return ( onChange(transform.fromX(value))} onBlur={onBlur} @@ -88,7 +95,9 @@ export const EditorActionDistance = ({ onChange(transform.fromY(value))} onBlur={onBlur} diff --git a/src/components/editor/action/EditorActionDocColor.tsx b/src/components/editor/action/EditorActionDocColor.tsx index ad9e1b71..a1809476 100644 --- a/src/components/editor/action/EditorActionDocColor.tsx +++ b/src/components/editor/action/EditorActionDocColor.tsx @@ -9,6 +9,8 @@ import { EditorFieldProps } from 'components/editor/EditorFieldProps' import type { CopilotDocV1 } from 'models/copilot.schema' import { actionDocColors } from 'models/operator' +import { useTranslation } from '../../../i18n/i18n' + interface EditorActionDocColorProps extends SetOptional, 'name'> {} @@ -17,6 +19,8 @@ export const EditorActionDocColor = ({ control, ...controllerProps }: EditorActionDocColorProps) => { + const t = useTranslation() + const { field: { onChange, onBlur, value, ref }, formState: { errors }, @@ -31,10 +35,12 @@ export const EditorActionDocColor = ({ return ( - {color?.title} + {color?.title()} } /> @@ -64,7 +70,7 @@ export const EditorActionDocColor = ({ > diff --git a/src/components/editor/action/EditorActionExecPredicate.tsx b/src/components/editor/action/EditorActionExecPredicate.tsx index b59b0947..55db8af7 100644 --- a/src/components/editor/action/EditorActionExecPredicate.tsx +++ b/src/components/editor/action/EditorActionExecPredicate.tsx @@ -5,6 +5,7 @@ import { EditorFieldProps } from 'components/editor/EditorFieldProps' import { EditorIntegerInput } from 'components/editor/EditorIntegerInput' import type { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { FormField2 } from '../../FormField' interface EditorActionExecPredicateProps @@ -15,19 +16,27 @@ export const EditorActionExecPredicateKills = ({ control, ...controllerProps }: EditorActionExecPredicateProps) => { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( { + const t = useTranslation() const { errors } = useFormState({ control, name }) return ( = ({ {...listeners} /> - {type.title} + {type.title()} diff --git a/src/components/editor/action/EditorActionOperatorDirection.tsx b/src/components/editor/action/EditorActionOperatorDirection.tsx index 286715f6..0d29b184 100644 --- a/src/components/editor/action/EditorActionOperatorDirection.tsx +++ b/src/components/editor/action/EditorActionOperatorDirection.tsx @@ -7,6 +7,7 @@ import { SetOptional } from 'type-fest' import { EditorFieldProps } from 'components/editor/EditorFieldProps' import type { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { OperatorDirection, operatorDirections } from '../../../models/operator' import { FormField2 } from '../../FormField' @@ -21,13 +22,18 @@ export const EditorActionOperatorDirection = ({ control, ...controllerProps }: EditorActionOperatorDirectionProps) => { + const t = useTranslation() const { field: { onChange, onBlur, value, ref }, formState: { errors }, } = useController({ name, control, - rules: { required: '必须选择朝向' }, + rules: { + required: + t.components.editor.action.EditorActionOperatorDirection + .direction_required, + }, defaultValue: 'None' as CopilotDocV1.Direction.None, ...controllerProps, }) @@ -36,10 +42,16 @@ export const EditorActionOperatorDirection = ({ return ( filterable={false} @@ -52,7 +64,7 @@ export const EditorActionOperatorDirection = ({ onClick={handleClick} onFocus={handleFocus} icon={action.icon} - text={action.title} + text={action.title()} /> )} onItemSelect={(item) => { @@ -61,7 +73,7 @@ export const EditorActionOperatorDirection = ({ >
diff --git a/src/components/editor/action/validation.ts b/src/components/editor/action/validation.ts index 7978a59d..ac0bf39c 100644 --- a/src/components/editor/action/validation.ts +++ b/src/components/editor/action/validation.ts @@ -2,6 +2,8 @@ import { UseFormSetError } from 'react-hook-form' import type { CopilotDocV1 } from 'models/copilot.schema' +import { i18n } from '../../../i18n/i18n' + export function validateAction( action: CopilotDocV1.Action, setError: UseFormSetError, @@ -14,7 +16,8 @@ export function validateAction( if (!action.name && !action.location) { const error = { type: 'required', - message: '类型为技能、撤退或子弹时间时,必须填写名称或位置其中一个', + message: + i18n.components.editor.action.validation.name_or_location_required, } setError('name', error) setError('location', error) diff --git a/src/components/editor/floatingMap/FloatingMap.tsx b/src/components/editor/floatingMap/FloatingMap.tsx index a7c2cf4c..27a73873 100644 --- a/src/components/editor/floatingMap/FloatingMap.tsx +++ b/src/components/editor/floatingMap/FloatingMap.tsx @@ -7,6 +7,7 @@ import { createPortal } from 'react-dom' import { Rnd, RndResizeCallback } from 'react-rnd' import { useWindowSize } from 'react-use' +import { useTranslation } from '../../../i18n/i18n' import { Level } from '../../../models/operation' import { sendMessage, useMessage } from '../../../utils/messenger' import { useLazyStorage } from '../../../utils/useLazyStorage' @@ -48,6 +49,8 @@ const enum MapStatus { } export function FloatingMap() { + const t = useTranslation() + const [config, setConfig] = useLazyStorage( STORAGE_KEY, { @@ -197,12 +200,22 @@ export function FloatingMap() { icon={ } - description={iframeWindow ? undefined : '等待地图连接...'} + description={ + iframeWindow + ? undefined + : t.components.editor.floatingMap.FloatingMap + .waiting_connection + } /> )}
) : ( - + )} @@ -229,12 +242,13 @@ function FloatingMapHeader({ config: FloatingMapConfig setConfig: (config: FloatingMapConfig) => void }) { + const t = useTranslation() let levelName = config.level?.name if (isNil(levelName)) { - levelName = '未选择关卡' + levelName = t.components.editor.floatingMap.FloatingMap.no_stage_selected } else if (!levelName.trim()) { - levelName = '未命名关卡' + levelName = t.components.editor.floatingMap.FloatingMap.unnamed_stage } return ( @@ -251,11 +265,15 @@ function FloatingMapHeader({ minimal style={{ height: HEADER_HEIGHT }} className="px-4" - title={config.show ? '隐藏地图' : '显示地图'} + title={ + config.show + ? t.components.editor.floatingMap.FloatingMap.hide_map + : t.components.editor.floatingMap.FloatingMap.show_map + } icon={config.show ? 'caret-down' : 'caret-up'} onClick={() => setConfig({ ...config, show: !config.show })} > - 地图 + {t.components.editor.floatingMap.FloatingMap.map} {config.show && ` - ${levelName}`}
diff --git a/src/components/editor/operator/EditorGroupItem.tsx b/src/components/editor/operator/EditorGroupItem.tsx index 0015a7a9..85d8764b 100644 --- a/src/components/editor/operator/EditorGroupItem.tsx +++ b/src/components/editor/operator/EditorGroupItem.tsx @@ -6,6 +6,7 @@ import { clsx } from 'clsx' import type { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { Sortable, SortableItemProps } from '../../dnd' import { CardDeleteOption, CardEditOption } from '../CardOptions' import { EditorOperatorItem } from './EditorOperatorItem' @@ -38,6 +39,8 @@ export const EditorGroupItem = ({ attributes, listeners, }: EditorGroupItemProps) => { + const t = useTranslation() + return ( {!group.opers?.length && ( - 将干员拖拽到此处 + + {t.components.editor.operator.EditorGroupItem.drag_operators_here} + )} diff --git a/src/components/editor/operator/EditorOperator.tsx b/src/components/editor/operator/EditorOperator.tsx index b72dc2eb..ac4e1937 100644 --- a/src/components/editor/operator/EditorOperator.tsx +++ b/src/components/editor/operator/EditorOperator.tsx @@ -7,6 +7,7 @@ import { FieldValues, useController } from 'react-hook-form' import { EditorFieldProps } from 'components/editor/EditorFieldProps' +import { useTranslation } from '../../../i18n/i18n' import { CopilotDocV1 } from '../../../models/copilot.schema' import { OPERATORS } from '../../../models/operator' import { Suggest } from '../../Suggest' @@ -41,7 +42,15 @@ export const EditorOperatorName = ({ groups?: CopilotDocV1.Group[] operators?: CopilotDocV1.Operator[] }) => { - const entityName = useMemo(() => (groups ? '干员或干员组' : '干员'), [groups]) + const t = useTranslation() + + const entityName = useMemo( + () => + groups + ? t.components.editor.operator.EditorOperator.operator_or_group + : t.components.editor.operator.EditorOperator.operator, + [groups, t], + ) const { field: { onChange, onBlur, value }, @@ -49,7 +58,12 @@ export const EditorOperatorName = ({ } = useController({ name, control, - rules: { required: `请输入${entityName}名`, ...rules }, + rules: { + required: t.components.editor.operator.EditorOperator.please_enter_name({ + entityName, + }), + ...rules, + }, ...controllerProps, }) @@ -114,15 +128,27 @@ export const EditorOperatorName = ({ createNewItemRenderer={(query, active, handleClick) => ( )} - noResults={} + noResults={ + + } inputProps={{ - placeholder: `${entityName}名`, + placeholder: t.components.editor.operator.EditorOperator.entity_name({ + entityName, + }), large: true, onBlur, }} diff --git a/src/components/editor/operator/EditorOperatorGroupSelect.tsx b/src/components/editor/operator/EditorOperatorGroupSelect.tsx index 714e3580..5dac482e 100644 --- a/src/components/editor/operator/EditorOperatorGroupSelect.tsx +++ b/src/components/editor/operator/EditorOperatorGroupSelect.tsx @@ -2,6 +2,7 @@ import { MenuItem } from '@blueprintjs/core' import { useController } from 'react-hook-form' +import { useTranslation } from '../../../i18n/i18n' import { CopilotDocV1 } from '../../../models/copilot.schema' import { Suggest } from '../../Suggest' import { EditorFieldProps } from '../EditorFieldProps' @@ -19,6 +20,8 @@ export const EditorOperatorGroupSelect = ({ groups, ...controllerProps }: Props) => { + const t = useTranslation() + const { field: { onChange, onBlur, value }, fieldState, @@ -51,15 +54,26 @@ export const EditorOperatorGroupSelect = ({ createNewItemRenderer={(query, active, handleClick) => ( )} - noResults={} + noResults={ + + } inputProps={{ - placeholder: `干员组名`, + placeholder: + t.components.editor.operator.EditorOperatorGroupSelect.group_name, large: true, onBlur, }} diff --git a/src/components/editor/operator/EditorOperatorItem.tsx b/src/components/editor/operator/EditorOperatorItem.tsx index 2cdaaed4..2b705c7a 100644 --- a/src/components/editor/operator/EditorOperatorItem.tsx +++ b/src/components/editor/operator/EditorOperatorItem.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import type { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { OPERATORS, getSkillUsageTitle } from '../../../models/operator' import { SortableItemProps } from '../../dnd' import { CardDeleteOption, CardEditOption } from '../CardOptions' @@ -25,16 +26,14 @@ export const EditorOperatorItem = ({ attributes, listeners, }: EditorOperatorItemProps) => { + const t = useTranslation() + const id = OPERATORS.find(({ name }) => name === operator.name)?.id const skillUsage = getSkillUsageTitle( operator.skillUsage as CopilotDocV1.SkillUsageType, operator.skillTimes, ) - const skill = `${ - [null, '一', '二', '三'][operator.skill ?? 1] ?? '未知' - }技能:${skillUsage}` - return (

{operator.name}

-
{skill}
+
+ {t.components.editor.operator.EditorOperatorItem.skill_number({ + count: operator.skill, + })} + : {skillUsage} +
diff --git a/src/components/editor/operator/EditorOperatorSelect.tsx b/src/components/editor/operator/EditorOperatorSelect.tsx index efb7bf44..c1be1118 100644 --- a/src/components/editor/operator/EditorOperatorSelect.tsx +++ b/src/components/editor/operator/EditorOperatorSelect.tsx @@ -10,82 +10,122 @@ import { } from 'components/editor/DetailedSelect' import { EditorFieldProps } from 'components/editor/EditorFieldProps' +import { useTranslation } from '../../../i18n/i18n' + export const EditorOperatorSelect = ({ name, control, }: EditorFieldProps) => { + const t = useTranslation() + const { field: { onChange, onBlur, value, ref }, } = useController({ name, control, - rules: { required: '请选择干员' }, + rules: { + required: + t.components.editor.operator.EditorOperatorSelect + .please_select_operator, + }, }) - const menuItems = useMemo( + const menuItems: DetailedSelectItem[] = useMemo( () => [ - { type: 'header', header: '干员上/退场' }, + { + type: 'header', + header: + t.components.editor.operator.EditorOperatorSelect + .operator_deploy_retreat, + }, { type: 'choice', icon: 'new-object', - title: '部署', + title: t.components.editor.operator.EditorOperatorSelect.deploy, value: 'Deploy', - description: `部署干员至指定位置。当费用不够时,会一直等待到费用够(除非 timeout)`, + description: + t.components.editor.operator.EditorOperatorSelect.deploy_description, }, { type: 'choice', icon: 'graph-remove', - title: '撤退', + title: t.components.editor.operator.EditorOperatorSelect.retreat, value: 'Retreat', - description: '将干员从作战中撤出', + description: + t.components.editor.operator.EditorOperatorSelect.retreat_description, + }, + { + type: 'header', + header: + t.components.editor.operator.EditorOperatorSelect.operator_skills, }, - { type: 'header', header: '干员技能' }, { type: 'choice', icon: 'target', - title: '使用技能', + title: t.components.editor.operator.EditorOperatorSelect.use_skill, value: 'Skill', - description: `当技能 CD 没转好时,一直等待到技能 CD 好(除非 timeout)`, + description: + t.components.editor.operator.EditorOperatorSelect + .use_skill_description, }, { type: 'choice', icon: 'swap-horizontal', - title: '切换技能用法', + title: + t.components.editor.operator.EditorOperatorSelect.switch_skill_usage, value: 'SkillUsage', - description: `切换干员技能用法。例如,刚下桃金娘、需要她帮忙打几个怪,但此时不能自动开技能否则会漏怪,等中后期平稳了才需要她自动开技能,则可以在对应时刻后,将桃金娘的技能用法从「不自动使用」改为「好了就用」。`, + description: + t.components.editor.operator.EditorOperatorSelect + .switch_skill_usage_description, + }, + { + type: 'header', + header: + t.components.editor.operator.EditorOperatorSelect.battle_control, }, - { type: 'header', header: '作战控制' }, { type: 'choice', icon: 'fast-forward', - title: '切换二倍速', + title: t.components.editor.operator.EditorOperatorSelect.toggle_speed, value: 'SpeedUp', - description: `执行后切换至二倍速,再次执行切换至一倍速`, + description: + t.components.editor.operator.EditorOperatorSelect + .toggle_speed_description, }, { type: 'choice', icon: 'fast-backward', - title: '进入子弹时间', + title: t.components.editor.operator.EditorOperatorSelect.bullet_time, value: 'BulletTime', - description: `执行后将点击任意干员,进入 1/5 速度状态;再进行任意动作会恢复正常速度`, + description: + t.components.editor.operator.EditorOperatorSelect + .bullet_time_description, }, { type: 'choice', icon: 'antenna', - title: '开始挂机', + title: t.components.editor.operator.EditorOperatorSelect.auto_mode, value: 'SkillDaemon', - description: `进入挂机模式。仅使用 “好了就用” 的技能,其他什么都不做,直到战斗结束`, + description: + t.components.editor.operator.EditorOperatorSelect + .auto_mode_description, + }, + { + type: 'header', + header: t.components.editor.operator.EditorOperatorSelect.miscellaneous, }, - { type: 'header', header: '杂项' }, { type: 'choice', icon: 'paragraph', - title: '打印描述内容', + title: + t.components.editor.operator.EditorOperatorSelect.print_description, value: 'Ouput', - description: `对作战没有实际作用,仅用于输出描述内容(用来做字幕之类的)`, + description: + t.components.editor.operator.EditorOperatorSelect + .print_description_details, }, ], - [], + [t], ) const selectedAction = menuItems.find( (action) => action.type === 'choice' && action.value === value, @@ -102,7 +142,13 @@ export const EditorOperatorSelect = ({ )}
diff --git a/src/components/editor/operator/EditorPerformerOperator.tsx b/src/components/editor/operator/EditorPerformerOperator.tsx index a4d78eeb..f7202fa0 100644 --- a/src/components/editor/operator/EditorPerformerOperator.tsx +++ b/src/components/editor/operator/EditorPerformerOperator.tsx @@ -9,6 +9,7 @@ import { FormError } from 'components/editor/FormError' import { FormSubmitButton } from 'components/editor/FormSubmitButton' import { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { FormField2 } from '../../FormField' import { EditorOperatorName } from './EditorOperator' import { EditorOperatorGroupSelect } from './EditorOperatorGroupSelect' @@ -39,6 +40,7 @@ export const EditorPerformerOperator = ({ onCancel, categorySelector, }: EditorPerformerOperatorProps) => { + const t = useTranslation() const isNew = !operator const { @@ -108,26 +110,40 @@ export const EditorPerformerOperator = ({ reset={reset} - entityName="正在编辑的干员" + entityName={ + t.components.editor.operator.EditorPerformerOperator + .editing_operator + } />
@@ -139,12 +155,18 @@ export const EditorPerformerOperator = ({
- + @@ -153,7 +175,10 @@ export const EditorPerformerOperator = ({ {skillUsage === CopilotDocV1.SkillUsageType.ReadyToUseTimes && ( @@ -164,12 +189,14 @@ export const EditorPerformerOperator = ({
- {isNew ? '添加' : '保存'} + {isNew + ? t.components.editor.operator.EditorPerformerOperator.add + : t.components.editor.operator.EditorPerformerOperator.save} {!isNew && ( )}
diff --git a/src/components/editor/operator/EditorSheet.tsx b/src/components/editor/operator/EditorSheet.tsx index b4602287..e0fef076 100644 --- a/src/components/editor/operator/EditorSheet.tsx +++ b/src/components/editor/operator/EditorSheet.tsx @@ -4,6 +4,7 @@ import { FC, useState } from 'react' import { CopilotDocV1 } from 'models/copilot.schema' +import { useTranslation } from '../../../i18n/i18n' import { SheetGroupContainer } from './sheet/SheetGroup' import { SheetOperatorContainer } from './sheet/SheetOperator' import { SheetProvider, SheetProviderProp } from './sheet/SheetProvider' @@ -22,7 +23,9 @@ const EditorOperatorSheet = (sheetProps: EditorSheetProps) => ( ) export const EditorSheetTrigger: FC = (sheetProps) => { + const t = useTranslation() const [open, setOpen] = useState(false) + return ( <> = (sheetProps) => { > -
@@ -66,6 +75,7 @@ const EditorGroupName: FC = () => { } const SheetGroup: FC = () => { + const t = useTranslation() const { existedGroups, existedOperators } = useSheet() const defaultGroup = useMemo( @@ -104,7 +114,9 @@ const SheetGroup: FC = () => {
= () => { = () => { /> {!!existedGroups.length && (
- 已显示全部 {existedGroups.length} 个干员组 + {t.components.editor.operator.sheet.SheetGroup.group_count({ + count: existedGroups.length, + })}
)}
@@ -128,14 +144,18 @@ const SheetGroup: FC = () => {
= () => { ) } -export const SheetGroupContainer: FC = () => ( - - - -) +export const SheetGroupContainer: FC = () => { + const t = useTranslation() + return ( + + + + ) +} const SheetGroupItemsWithSkeleton: FC< SheetContainerSkeletonProps & { @@ -161,15 +187,17 @@ const SheetGroupItemsWithSkeleton: FC< > = ({ groups, itemType, ...sheetContainerSkeletonProps }) => (
- {groups.length - ? groups.map((item) => ( - - )) - : GroupNoData} + {groups.length ? ( + groups.map((item) => ( + + )) + ) : ( + + )}
) diff --git a/src/components/editor/operator/sheet/SheetNoneData.tsx b/src/components/editor/operator/sheet/SheetNoneData.tsx index cde78bff..56d7bb3e 100644 --- a/src/components/editor/operator/sheet/SheetNoneData.tsx +++ b/src/components/editor/operator/sheet/SheetNoneData.tsx @@ -1,4 +1,21 @@ import { NonIdealState } from '@blueprintjs/core' -export const OperatorNoData = -export const GroupNoData = +import { useTranslation } from '../../../../i18n/i18n' + +export const OperatorNoData = () => { + const t = useTranslation() + return ( + + ) +} + +export const GroupNoData = () => { + const t = useTranslation() + return ( + + ) +} diff --git a/src/components/editor/operator/sheet/SheetOperator.tsx b/src/components/editor/operator/sheet/SheetOperator.tsx index b097f8e8..5b3c37f5 100644 --- a/src/components/editor/operator/sheet/SheetOperator.tsx +++ b/src/components/editor/operator/sheet/SheetOperator.tsx @@ -1,5 +1,6 @@ import { FC, useCallback, useRef } from 'react' +import { useTranslation } from '../../../../i18n/i18n' import { SheetContainerSkeleton } from './SheetContainerSkeleton' import { OperatorNoData } from './SheetNoneData' import { ProfClassificationWithFilters } from './sheetOperator/ProfClassificationWithFilters' @@ -42,7 +43,7 @@ const SheetOperator: FC = () => { ) : ( - OperatorNoData + )}
@@ -54,10 +55,16 @@ const SheetOperator: FC = () => { export const SheetOperatorContainer = ( sheetOperatorProp: SheetOperatorProps, -) => ( - - - - - -) +) => { + const t = useTranslation() + return ( + + + + + + ) +} diff --git a/src/components/editor/operator/sheet/SheetOperatorSkillAbout.tsx b/src/components/editor/operator/sheet/SheetOperatorSkillAbout.tsx index 14b49b4a..5a3bc955 100644 --- a/src/components/editor/operator/sheet/SheetOperatorSkillAbout.tsx +++ b/src/components/editor/operator/sheet/SheetOperatorSkillAbout.tsx @@ -9,6 +9,7 @@ import { DetailedSelectChoice } from 'components/editor/DetailedSelect' import { CopilotDocV1 } from 'models/copilot.schema' import { operatorSkillUsages } from 'models/operator' +import { useTranslation } from '../../../../i18n/i18n' import { EditorOperatorSkill } from '../EditorOperatorSkill' import { EditorOperatorSkillTimes } from '../EditorOperatorSkillTimes' import { EditorOperatorSkillUsage } from '../EditorOperatorSkillUsage' @@ -27,6 +28,8 @@ export const SkillAboutTrigger = ({ operator, onSkillChange, }: SkillAboutProps) => { + const t = useTranslation() + const { control, handleSubmit, @@ -55,7 +58,9 @@ export const SkillAboutTrigger = ({
e.stopPropagation()} role="presentation">
@@ -73,7 +81,10 @@ export const SkillAboutTrigger = ({ {needSkillTime && ( @@ -82,9 +93,18 @@ export const SkillAboutTrigger = ({ )}
-
) diff --git a/src/components/editor/operator/sheet/sheetGroup/CollapseButton.tsx b/src/components/editor/operator/sheet/sheetGroup/CollapseButton.tsx index f7517b85..3b895714 100644 --- a/src/components/editor/operator/sheet/sheetGroup/CollapseButton.tsx +++ b/src/components/editor/operator/sheet/sheetGroup/CollapseButton.tsx @@ -2,6 +2,7 @@ import { Button, ButtonProps } from '@blueprintjs/core' import { FC } from 'react' +import { useTranslation } from '../../../../../i18n/i18n' import { Group, Operator } from '../../EditorSheet' import { GroupListModifyProp } from '../SheetGroup' @@ -22,13 +23,22 @@ export const CollapseButton: FC = ({ isCollapse, onClick, disabled, -}) => ( -
@@ -205,6 +219,7 @@ const useSheetGroupItemController = ({ groupInfo: { name, opers = [], ...rest }, itemType, }: SheetGroupItemProp): SheetGroupItemController => { + const t = useTranslation() const { submitGroupInSheet, removeGroup, existedGroups } = useSheet() const [favGroup, setFavGroup] = useAtom(favGroupAtom) @@ -271,7 +286,10 @@ const useSheetGroupItemController = ({
} position={Position.TOP} > -
@@ -315,7 +364,7 @@ const OperatorSelectorSkeleton: FC<{ } > {collapseDisabled ? ( - OperatorNoData + ) : ( {children} diff --git a/src/components/editor/operator/sheet/sheetOperator/ProfClassificationWithFilters.tsx b/src/components/editor/operator/sheet/sheetOperator/ProfClassificationWithFilters.tsx index 28b7863b..816aa8c6 100644 --- a/src/components/editor/operator/sheet/sheetOperator/ProfClassificationWithFilters.tsx +++ b/src/components/editor/operator/sheet/sheetOperator/ProfClassificationWithFilters.tsx @@ -6,6 +6,7 @@ import { FC, ImgHTMLAttributes, useEffect, useMemo } from 'react' import { PROFESSIONS } from 'models/operator' +import { useTranslation } from '../../../../../i18n/i18n' import { DEFAULTPROFID, DEFAULTSUBPROFID, @@ -16,25 +17,6 @@ import { OperatorBackToTop } from './toolBox/OperatorBackToTop' import { OperatorMutipleSelect } from './toolBox/OperatorMutipleSelect' import { OperatorRaritySelect } from './toolBox/OperatorRaritySelect' -const formattedProfessions = [ - { - id: DEFAULTPROFID.ALL, - name: '全部', - sub: [], - }, - { - id: DEFAULTPROFID.FAV, - name: '收藏', - sub: [], - }, - ...PROFESSIONS, - { - id: DEFAULTPROFID.OTHERS, - name: '其它', - sub: [], - }, -] - export interface ProfClassificationWithFiltersProp { toTop: () => void } @@ -42,19 +24,52 @@ export interface ProfClassificationWithFiltersProp { export const ProfClassificationWithFilters: FC< ProfClassificationWithFiltersProp > = ({ toTop }) => { + const t = useTranslation() const { useProfFilterState: [{ selectedProf }, setProfFilter], usePaginationFilterState: [_, setPaginationFilter], } = useOperatorFilterProvider() + const formattedProfessions = useMemo( + () => [ + { + id: DEFAULTPROFID.ALL, + name: t.components.editor.operator.sheet.sheetOperator + .ProfClassificationWithFilters.all, + sub: [], + }, + { + id: DEFAULTPROFID.FAV, + name: t.components.editor.operator.sheet.sheetOperator + .ProfClassificationWithFilters.favorites, + sub: [], + }, + ...PROFESSIONS, + { + id: DEFAULTPROFID.OTHERS, + name: t.components.editor.operator.sheet.sheetOperator + .ProfClassificationWithFilters.others, + sub: [], + }, + ], + [t], + ) const subProfs = useMemo(() => { return [ - { id: DEFAULTSUBPROFID.ALL, name: '全部' }, - { id: DEFAULTSUBPROFID.SELECTED, name: '已选择' }, + { + id: DEFAULTSUBPROFID.ALL, + name: t.components.editor.operator.sheet.sheetOperator + .ProfClassificationWithFilters.all, + }, + { + id: DEFAULTSUBPROFID.SELECTED, + name: t.components.editor.operator.sheet.sheetOperator + .ProfClassificationWithFilters.selected, + }, ...(formattedProfessions.find(({ id }) => id === selectedProf[0])?.sub || []), ] - }, [selectedProf]) + }, [selectedProf, formattedProfessions, t]) useEffect(() => { toTop() diff --git a/src/components/editor/operator/sheet/sheetOperator/SheetOperatorItem.tsx b/src/components/editor/operator/sheet/sheetOperator/SheetOperatorItem.tsx index f1d5ce00..9b180382 100644 --- a/src/components/editor/operator/sheet/sheetOperator/SheetOperatorItem.tsx +++ b/src/components/editor/operator/sheet/sheetOperator/SheetOperatorItem.tsx @@ -11,6 +11,7 @@ import { CopilotDocV1 } from 'models/copilot.schema' import { ignoreKeyDic } from 'store/useFavGroups' import { favOperatorAtom } from 'store/useFavOperators' +import { useTranslation } from '../../../../../i18n/i18n' import { OperatorAvatar } from '../../EditorOperator' import { SkillAboutTrigger } from '../SheetOperatorSkillAbout' import { useSheet } from '../SheetProvider' @@ -20,6 +21,7 @@ export interface SheetOperatorItemProp { } export const SheetOperatorItem: FC = ({ name }) => { + const t = useTranslation() const { existedOperators, existedGroups, @@ -56,7 +58,10 @@ export const SheetOperatorItem: FC = ({ name }) => { const onOperatorSelect = () => { if (grouped) AppToaster.show({ - message: `干员 ${name} 已被编组`, + message: + t.components.editor.operator.sheet.sheetOperator.SheetOperatorItem.operator_in_group( + { name }, + ), intent: Intent.DANGER, }) else { @@ -132,7 +137,11 @@ export const SheetOperatorItem: FC = ({ name }) => { className={clsx(pinned && '-rotate-45')} /> - {pinned ? '移出收藏' : '此操作会覆盖已有干员'} + {pinned + ? t.components.editor.operator.sheet.sheetOperator + .SheetOperatorItem.remove_from_favorites + : t.components.editor.operator.sheet.sheetOperator + .SheetOperatorItem.will_replace_operator} } @@ -163,7 +172,10 @@ export const SheetOperatorItem: FC = ({ name }) => { {grouped && ( - 已被编组 + { + t.components.editor.operator.sheet.sheetOperator.SheetOperatorItem + .in_group + } )}
diff --git a/src/components/editor/operator/sheet/sheetOperator/ShowMore.tsx b/src/components/editor/operator/sheet/sheetOperator/ShowMore.tsx index 8e50cc0d..5a89d685 100644 --- a/src/components/editor/operator/sheet/sheetOperator/ShowMore.tsx +++ b/src/components/editor/operator/sheet/sheetOperator/ShowMore.tsx @@ -2,6 +2,7 @@ import { H6 } from '@blueprintjs/core' import { FC, useEffect } from 'react' +import { useTranslation } from '../../../../../i18n/i18n' import { defaultPagination, useOperatorFilterProvider, @@ -12,6 +13,7 @@ export interface ShowMoreProp { } export const ShowMore: FC = ({ toTop }) => { + const t = useTranslation() const { operatorFiltered: { meta: { dataTotal }, @@ -29,13 +31,20 @@ export const ShowMore: FC = ({ toTop }) => {
{lastIndex >= dataTotal ? ( <> -
已经展示全部干员了({dataTotal})
+
+ {t.components.editor.operator.sheet.sheetOperator.ShowMore.showing_all_operators( + { total: dataTotal }, + )} +
{dataTotal > size && (
setPagination(defaultPagination)} > - 收起 + { + t.components.editor.operator.sheet.sheetOperator.ShowMore + .collapse + }
)} @@ -49,7 +58,9 @@ export const ShowMore: FC = ({ toTop }) => { })) } > - 显示更多干员(剩余{dataTotal - lastIndex}) + {t.components.editor.operator.sheet.sheetOperator.ShowMore.show_more({ + remaining: dataTotal - lastIndex, + })} )}
diff --git a/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorBackToTop.tsx b/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorBackToTop.tsx index 17f663e9..388e596a 100644 --- a/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorBackToTop.tsx +++ b/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorBackToTop.tsx @@ -2,6 +2,7 @@ import { Button } from '@blueprintjs/core' import { FC } from 'react' +import { useTranslation } from '../../../../../../i18n/i18n' import { defaultPagination, useOperatorFilterProvider, @@ -12,6 +13,7 @@ export interface OperatorBackToTopProp { } export const OperatorBackToTop: FC = ({ toTop }) => { + const t = useTranslation() const { usePaginationFilterState: [{ current }, setPaginationFilter], } = useOperatorFilterProvider() @@ -21,7 +23,10 @@ export const OperatorBackToTop: FC = ({ toTop }) => { minimal icon="symbol-triangle-up" disabled={current < 3} - title="回到顶部" + title={ + t.components.editor.operator.sheet.sheetOperator.toolbox + .OperatorBackToTop.back_to_top + } onClick={() => setPaginationFilter(defaultPagination)} /> ) diff --git a/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorMutipleSelect.tsx b/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorMutipleSelect.tsx index 76c3e6bd..da6f9922 100644 --- a/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorMutipleSelect.tsx +++ b/src/components/editor/operator/sheet/sheetOperator/toolBox/OperatorMutipleSelect.tsx @@ -2,12 +2,14 @@ import { Button } from '@blueprintjs/core' import { FC, useMemo } from 'react' +import { useTranslation } from '../../../../../../i18n/i18n' import { useSheet } from '../../SheetProvider' import { useOperatorFilterProvider } from '../SheetOperatorFilterProvider' export interface OperatorMutipleSelectProp {} export const OperatorMutipleSelect: FC = () => { + const t = useTranslation() const { operatorFiltered: { data: operatorFilteredData }, } = useOperatorFilterProvider() @@ -45,13 +47,17 @@ export const OperatorMutipleSelect: FC = () => { minimal icon="circle" disabled={cancelAllDisabled} - title={`取消选择全部${existedOperators.length}位干员`} + title={t.components.editor.operator.sheet.sheetOperator.toolbox.OperatorMutipleSelect.deselect_all_operators( + { count: existedOperators.length }, + )} onClick={cancelAll} />
@@ -69,7 +79,10 @@ export const OperatorRaritySelect: FC = () => { reverse: false, })) } - title="按从下至上升序排列" + title={ + t.components.editor.operator.sheet.sheetOperator.toolbox + .OperatorRaritySelect.sort_ascending + } />
diff --git a/src/components/editor/source/FileImporter.tsx b/src/components/editor/source/FileImporter.tsx index 86bb4c6b..8360bb30 100644 --- a/src/components/editor/source/FileImporter.tsx +++ b/src/components/editor/source/FileImporter.tsx @@ -2,11 +2,13 @@ import { MenuItem } from '@blueprintjs/core' import { ChangeEventHandler, FC, useRef } from 'react' +import { useTranslation } from '../../../i18n/i18n' import { AppToaster } from '../../Toaster' export const FileImporter: FC<{ onImport: (content: string) => void }> = ({ onImport, }) => { + const t = useTranslation() const inputRef = useRef(null) const handleUpload: ChangeEventHandler = async (e) => { @@ -21,7 +23,7 @@ export const FileImporter: FC<{ onImport: (content: string) => void }> = ({ } catch (e) { console.warn('Failed to read file:', e) AppToaster.show({ - message: '无法读取文件', + message: t.components.editor.source.FileImporter.cannot_read_file, intent: 'danger', }) } @@ -35,7 +37,7 @@ export const FileImporter: FC<{ onImport: (content: string) => void }> = ({ onClick={() => inputRef.current?.click()} text={ <> - 导入本地文件... + {t.components.editor.source.FileImporter.import_local_file} void }> = ({ onImport }) => { + const t = useTranslation() const [dialogOpen, setDialogOpen] = useState(false) const [pending, setPending] = useState(false) @@ -33,7 +33,7 @@ export const ShortCodeImporter: FC<{ control, name: 'code', rules: { - required: '请输入神秘代码', + required: t.components.editor.source.ShortCodeImporter.enter_shortcode, }, }) @@ -44,14 +44,21 @@ export const ShortCodeImporter: FC<{ const shortCodeContent = parseShortCode(code) if (!shortCodeContent) { - throw new Error('无效的神秘代码') + throw new Error( + t.components.editor.source.ShortCodeImporter.invalid_shortcode, + ) } const { id } = shortCodeContent const operationContent = (await getOperation({ id })).parsedContent - if (operationContent === INVALID_OPERATION_CONTENT) { - throw new Error('无法解析作业内容') + if ( + operationContent.doc.title === + t.models.converter.invalid_operation_content + ) { + throw new Error( + t.components.editor.source.ShortCodeImporter.cannot_parse_content, + ) } // deal with race condition @@ -65,7 +72,11 @@ export const ShortCodeImporter: FC<{ setDialogOpen(false) } catch (e) { console.warn(e) - setError('code', { message: '加载失败:' + formatError(e) }) + setError('code', { + message: + t.components.editor.source.ShortCodeImporter.load_failed + + formatError(e), + }) } finally { setPending(false) } @@ -75,25 +86,29 @@ export const ShortCodeImporter: FC<{ <> setDialogOpen(true)} /> { setPending(false) setDialogOpen(false) }} > -
+ - 导入 + {t.components.editor.source.ShortCodeImporter.import_button}
diff --git a/src/components/editor/source/SourceEditor.tsx b/src/components/editor/source/SourceEditor.tsx index a5112505..600bfec7 100644 --- a/src/components/editor/source/SourceEditor.tsx +++ b/src/components/editor/source/SourceEditor.tsx @@ -5,6 +5,7 @@ import camelcaseKeys from 'camelcase-keys' import { FC, useMemo, useState } from 'react' import { UseFormReturn } from 'react-hook-form' +import { useTranslation } from '../../../i18n/i18n' import { CopilotDocV1 } from '../../../models/copilot.schema' import { useAfterRender } from '../../../utils/useAfterRender' import { DrawerLayout } from '../../drawer/DrawerLayout' @@ -25,6 +26,7 @@ export const SourceEditor: FC = ({ }, triggerValidation, }) => { + const t = useTranslation() const hasValidationErrors = !!Object.keys(errors).length const initialText = useMemo(() => { @@ -33,9 +35,9 @@ export const SourceEditor: FC = ({ return JSON.stringify(toMaaOperation(initialOperation), null, 2) } catch (e) { console.warn(e) - return '(解析时出现错误)' + return t.components.editor.source.SourceEditor.parsing_error } - }, [getValues]) + }, [getValues, t]) const { afterRender } = useAfterRender() @@ -54,11 +56,11 @@ export const SourceEditor: FC = ({ afterRender(triggerValidation) } catch (e) { if (e instanceof SyntaxError) { - setJsonError('存在语法错误') + setJsonError(t.components.editor.source.SourceEditor.syntax_error) } else { // this will most likely not happen console.warn(e) - setJsonError('存在结构错误') + setJsonError(t.components.editor.source.SourceEditor.structure_error) } } } @@ -79,25 +81,33 @@ export const SourceEditor: FC = ({
{/* wrap in an extra div to work around a flex bug, where the children's sizes are uneven when using flex-1. refer to: https://github.com/philipwalton/flexbugs#flexbug-7 */}
diff --git a/src/components/editor/source/SourceEditorButton.tsx b/src/components/editor/source/SourceEditorButton.tsx index e3cdde58..7019bf3a 100644 --- a/src/components/editor/source/SourceEditorButton.tsx +++ b/src/components/editor/source/SourceEditorButton.tsx @@ -2,6 +2,7 @@ import { Button, Drawer } from '@blueprintjs/core' import { FC, useState } from 'react' +import { useTranslation } from '../../../i18n/i18n' import { SourceEditor, SourceEditorProps } from './SourceEditor' interface SourceEditorButtonProps extends SourceEditorProps { @@ -13,6 +14,7 @@ export const SourceEditorButton: FC = ({ triggerValidation, ...editorProps }) => { + const t = useTranslation() const [drawerOpen, setDrawerOpen] = useState(false) return ( @@ -20,7 +22,7 @@ export const SourceEditorButton: FC = ({
diff --git a/src/components/operation-set/OperationSetEditor.tsx b/src/components/operation-set/OperationSetEditor.tsx index 858125bf..0f92502c 100644 --- a/src/components/operation-set/OperationSetEditor.tsx +++ b/src/components/operation-set/OperationSetEditor.tsx @@ -52,15 +52,17 @@ import { OperationSet } from 'models/operation-set' import { formatError } from 'utils/error' import { useLevels } from '../../apis/level' +import { useTranslation } from '../../i18n/i18n' import { findLevelByStageName } from '../../models/level' export function OperationSetEditorLauncher() { + const t = useTranslation() const [isOpen, setIsOpen] = useState(false) return ( <> (null) @@ -227,9 +235,9 @@ function OperationSetForm({ operationSet, onSubmit }: FormProps) { icon="helicopter" description={ <> - 还没有添加作业哦( ̄▽ ̄) + {t.components.operationSet.OperationSetEditor.no_jobs_yet}
- 请从作业列表中添加 + {t.components.operationSet.OperationSetEditor.add_from_list} } /> @@ -239,12 +247,15 @@ function OperationSetForm({ operationSet, onSubmit }: FormProps) {
( )} /> @@ -295,7 +308,8 @@ function OperationSetForm({ operationSet, onSubmit }: FormProps) {
{isEdit && (
- 修改后请点击保存按钮 + {' '} + {t.components.operationSet.OperationSetEditor.click_save}
)} @@ -307,12 +321,18 @@ function OperationSetForm({ operationSet, onSubmit }: FormProps) { icon="floppy-disk" className="ml-auto" > - {isEdit ? '保存' : '创建'} + {isEdit + ? t.components.operationSet.OperationSetEditor.save + : t.components.operationSet.OperationSetEditor.create}
{globalError && ( - + {globalError} )} @@ -337,6 +357,7 @@ function OperationSelector({ operationSet, selectorRef, }: OperationSelectorProps) { + const t = useTranslation() const { operations, error } = useOperations({ operationIds: operationSet.copilotIds, }) @@ -442,41 +463,59 @@ function OperationSelector({ disabled={levelLoading} icon="sort-alphabetical" text={ - '按关卡' + + t.components.operationSet.OperationSetEditor.sort_by_level + (levelLoading - ? ' (加载中...)' + ? ' (' + + t.components.operationSet.OperationSetEditor.loading + + ')' : levelError - ? ' (关卡加载失败,使用备用排序)' + ? ' (' + + t.components.operationSet.OperationSetEditor + .level_load_failed + + ')' : '') } onClick={() => sort('level')} /> sort('title')} /> sort('id')} /> } > -
{error && ( - + {formatError(error)} )} diff --git a/src/components/uploader/OperationUploader.tsx b/src/components/uploader/OperationUploader.tsx index 7ba2c586..fb9e5670 100644 --- a/src/components/uploader/OperationUploader.tsx +++ b/src/components/uploader/OperationUploader.tsx @@ -13,6 +13,7 @@ import { Tooltip2 } from '@blueprintjs/popover2' import { useLevels } from 'apis/level' import { createOperation } from 'apis/operation' +import { CopilotInfoStatusEnum } from 'maa-copilot-client' import { ComponentType, useState } from 'react' import { useList } from 'react-use' @@ -20,6 +21,7 @@ import { withSuspensable } from 'components/Suspensable' import { AppToaster } from 'components/Toaster' import { DrawerLayout } from 'components/drawer/DrawerLayout' +import { useTranslation } from '../../i18n/i18n' import { CopilotDocV1 } from '../../models/copilot.schema' import { formatError } from '../../utils/error' import { parseOperationFile, patchOperation, validateOperation } from './utils' @@ -32,23 +34,30 @@ interface FileEntry { } export const OperationUploader: ComponentType = withSuspensable(() => { + const t = useTranslation() const [files, { set: setFiles, update: updateFileWhere }] = useList([]) const [globalErrors, setGlobalErrors] = useState(null as string[] | null) const [isProcessing, setIsProcessing] = useState(false) const [isUploading, setIsUploading] = useState(false) + const [operationStatus] = useState( + CopilotInfoStatusEnum.Private, + ) // reasons are in the order of keys const nonUploadableReason = Object.entries({ - ['正在上传,请等待']: isUploading, - ['正在解析文件,请等待']: isProcessing, - ['请选择文件']: !files.length, - ['文件列表中包含已上传的文件,请重新选择']: files.some( + [t.components.uploader.OperationUploader.wait_upload]: isUploading, + [t.components.uploader.OperationUploader.wait_parsing]: isProcessing, + [t.components.uploader.OperationUploader.select_files]: !files.length, + [t.components.uploader.OperationUploader.contains_uploaded]: files.some( (file) => file.uploaded, ), - ['文件存在错误,请修改内容']: files.some((file) => file.error), - ['存在错误,请排查问题']: globalErrors?.length, + [t.components.uploader.OperationUploader.file_errors]: files.some( + (file) => file.error, + ), + [t.components.uploader.OperationUploader.errors_exist]: + globalErrors?.length, }).find(([, value]) => value)?.[0] const isUploadable = !nonUploadableReason @@ -104,7 +113,10 @@ export const OperationUploader: ComponentType = withSuspensable(() => { await Promise.allSettled( files.map((file) => - createOperation({ content: JSON.stringify(file.operation) }) + createOperation({ + content: JSON.stringify(file.operation), + status: operationStatus, + }) .then(() => { successCount++ updateFileWhere((candidate) => candidate === file, { @@ -116,7 +128,9 @@ export const OperationUploader: ComponentType = withSuspensable(() => { console.warn(e) updateFileWhere((candidate) => candidate === file, { ...file, - error: `上传失败:${formatError(e)}`, + error: t.components.uploader.OperationUploader.upload_failed({ + error: formatError(e), + }), }) }), ), @@ -126,7 +140,10 @@ export const OperationUploader: ComponentType = withSuspensable(() => { AppToaster.show({ intent: 'success', - message: `作业上传完成:成功 ${successCount} 个,失败 ${errorCount} 个`, + message: t.components.uploader.OperationUploader.upload_complete({ + successCount, + errorCount, + }), }) } finally { setIsUploading(false) @@ -138,33 +155,45 @@ export const OperationUploader: ComponentType = withSuspensable(() => { title={ <> - 上传本地作业 + + {t.components.uploader.OperationUploader.upload_local_jobs} + } >
-

上传本地作业

+

{t.components.uploader.OperationUploader.upload_local_jobs}

- 若需要在上传前进行编辑,请在作业编辑器的 + {t.components.uploader.OperationUploader.edit_before_upload_message} - 编辑 JSON + {t.components.uploader.OperationUploader.edit_json} - 处导入作业 + {t.components.uploader.OperationUploader.import_job}

选择作业文件} + label={ + + {t.components.uploader.OperationUploader.select_job_files} + + } labelFor="file-input" - labelInfo="仅支持 .json 文件,可多选" + labelInfo={t.components.uploader.OperationUploader.json_files_only} > { } onClick={handleOperationSubmit} > - {isUploading ? `${settledCount}/${files.length}` : '上传'} + {isUploading + ? `${settledCount}/${files.length}` + : t.components.uploader.OperationUploader.upload} ) })()} {globalErrors && ( - + {globalErrors.map((error) => (
  • {error}
  • ))}
    )} - {!!files.length &&
    文件详情
    } + {!!files.length && ( +
    + {t.components.uploader.OperationUploader.file_details} +
    + )} {files.map(({ file, uploaded, error, operation }, index) => (

    - {operation ? operation.doc.title || '无标题' : null} + {operation + ? operation.doc.title || + t.components.uploader.OperationUploader.untitled + : null}

    {error &&

    {error}

    }
    diff --git a/src/components/uploader/OperationUploaderLauncher.tsx b/src/components/uploader/OperationUploaderLauncher.tsx index 402a5616..7a45dda5 100644 --- a/src/components/uploader/OperationUploaderLauncher.tsx +++ b/src/components/uploader/OperationUploaderLauncher.tsx @@ -4,7 +4,10 @@ import { FC, useState } from 'react' import { OperationUploader } from 'components/uploader/OperationUploader' +import { useTranslation } from '../../i18n/i18n' + export const OperationUploaderLauncher: FC = () => { + const t = useTranslation() const [uploaderActive, setUploaderActive] = useState(false) return ( @@ -23,7 +26,7 @@ export const OperationUploaderLauncher: FC = () => { icon="cloud-upload" onClick={() => setUploaderActive(true)} > - 上传本地作业 + {t.components.uploader.OperationUploaderLauncher.upload_local_jobs} ) diff --git a/src/components/uploader/utils.ts b/src/components/uploader/utils.ts index 0ee06118..f1b657f3 100644 --- a/src/components/uploader/utils.ts +++ b/src/components/uploader/utils.ts @@ -3,6 +3,7 @@ import { isString } from '@sentry/utils' import ajvLocalizeZh from 'ajv-i18n/localize/zh' import { isFinite, isPlainObject } from 'lodash-es' +import { i18n } from '../../i18n/i18n' import { CopilotDocV1 } from '../../models/copilot.schema' import { copilotSchemaValidator } from '../../models/copilot.schema.validator' import { @@ -17,7 +18,7 @@ import { AppToaster } from '../Toaster' export async function parseOperationFile(file: File): Promise { if (file.type !== 'application/json') { - throw new Error('请选择 JSON 文件') + throw new Error(i18n.components.uploader.utils.select_json_file) } try { @@ -26,12 +27,14 @@ export async function parseOperationFile(file: File): Promise { const json = JSON.parse(fileText) if (!isPlainObject(json)) { - throw new Error('不是有效的对象') + throw new Error(i18n.components.uploader.utils.invalid_object) } return json } catch (e) { - throw new Error('请选择合法的 JSON 文件:JSON 解析失败:' + formatError(e)) + throw new Error( + i18n.components.uploader.utils.json_parse_failed + formatError(e), + ) } } @@ -66,7 +69,10 @@ export function patchOperation(operation: object, levels: Level[]): object { !isString(operation['doc']['details']) || operation['doc']['details'] === '' ) { - operation['doc']['details'] = `作业 ${stage_name}` + operation['doc']['details'] = + i18n.components.uploader.utils.job_with_stage_name({ + stageName: stage_name, + }) } // i18n compatibility of level id @@ -90,7 +96,9 @@ export function patchOperation(operation: object, levels: Level[]): object { operation['stage_name'] = [...uniqueStageIds][0] } else { const reason = - uniqueStageIds.size > 0 ? '匹配到的关卡不唯一' : '未找到对应关卡' + uniqueStageIds.size > 0 + ? i18n.components.uploader.utils.stage_not_unique + : i18n.components.uploader.utils.stage_not_found const error = new Error(`${reason}(${stage_name})`) ;(error as any).matchedLevels = matchedLevels @@ -101,7 +109,7 @@ export function patchOperation(operation: object, levels: Level[]): object { } catch (e) { console.warn(e) AppToaster.show({ - message: '自动修正失败:' + formatError(e), + message: i18n.components.uploader.utils.auto_fix_failed + formatError(e), intent: 'warning', }) } @@ -134,6 +142,8 @@ export function validateOperation( ) } } catch (e) { - throw new Error('验证失败:' + formatError(e)) + throw new Error( + i18n.components.uploader.utils.validation_failed + formatError(e), + ) } } diff --git a/src/components/viewer/OperationRating.tsx b/src/components/viewer/OperationRating.tsx index dc7a2869..d519fabb 100644 --- a/src/components/viewer/OperationRating.tsx +++ b/src/components/viewer/OperationRating.tsx @@ -8,6 +8,8 @@ import Rating from 'react-rating' import { Operation } from 'models/operation' import { ratingLevelToString } from 'models/rating' +import { useTranslation } from '../../i18n/i18n' + type PickedOperation = Pick< Operation, 'notEnoughRating' | 'ratingRatio' | 'ratingLevel' | 'like' | 'dislike' @@ -17,21 +19,28 @@ const GetLevelDescription: FC<{ operation: PickedOperation layout?: 'horizontal' | 'vertical' }> = ({ operation, layout }) => { + const t = useTranslation() + const likePercent = Math.round( + (operation.like / (operation.like + operation.dislike)) * 100, + ) + const likeRatio = `${operation.like}/${operation.like + operation.dislike}` + return operation.notEnoughRating ? ( layout === 'vertical' ? ( - 还没有足够的评分 + {t.components.viewer.OperationRating.not_enough_ratings_long} ) : ( - 评分不足 + + {t.components.viewer.OperationRating.not_enough_ratings_short} + ) ) : ( {ratingLevelToString(operation.ratingLevel)} diff --git a/src/components/viewer/OperationSetViewer.tsx b/src/components/viewer/OperationSetViewer.tsx index 94069036..b0b5de44 100644 --- a/src/components/viewer/OperationSetViewer.tsx +++ b/src/components/viewer/OperationSetViewer.tsx @@ -33,6 +33,7 @@ import { OperationSet } from 'models/operation-set' import { authAtom } from 'store/auth' import { wrapErrorMessage } from 'utils/wrapErrorMessage' +import { i18nDefer, useTranslation } from '../../i18n/i18n' import { formatError } from '../../utils/error' import { UserName } from '../UserName' @@ -40,6 +41,7 @@ const ManageMenu: FC<{ operationSet: OperationSet onUpdate: () => void }> = ({ operationSet, onUpdate }) => { + const t = useTranslation() const refreshOperationSets = useRefreshOperationSets() const [loading, setLoading] = useState(false) @@ -50,7 +52,10 @@ const ManageMenu: FC<{ setLoading(true) try { await wrapErrorMessage( - (e) => `删除失败:${formatError(e)}`, + (e) => + t.components.viewer.OperationSetViewer.delete_failed({ + error: formatError(e), + }), deleteOperationSet({ id: operationSet.id }), ) @@ -58,7 +63,7 @@ const ManageMenu: FC<{ AppToaster.show({ intent: 'success', - message: `删除成功`, + message: t.components.viewer.OperationSetViewer.delete_success, }) setDeleteDialogOpen(false) onUpdate() @@ -73,8 +78,8 @@ const ManageMenu: FC<{ <> setDeleteDialogOpen(false)} onConfirm={handleDelete} > -

    删除作业集

    -

    确定要删除作业集吗?

    +

    {t.components.viewer.OperationSetViewer.delete_task_set}

    +

    {t.components.viewer.OperationSetViewer.confirm_delete_task_set}

    setEditorOpen(true)} /> setDeleteDialogOpen(true)} /> @@ -116,6 +121,7 @@ export const OperationSetViewer: ComponentType<{ onCloseDrawer: () => void }> = withSuspensable( function OperationSetViewer({ operationSetId, onCloseDrawer }) { + const t = useTranslation() const { data: operationSet, error } = useOperationSet({ id: operationSetId, suspense: true, @@ -143,17 +149,21 @@ export const OperationSetViewer: ComponentType<{ if (error) { AppToaster.show({ intent: 'danger', - message: `刷新作业集失败:${formatError(error)}`, + message: t.components.viewer.OperationSetViewer.refresh_failed({ + error: formatError(error), + }), }) } - }, [error]) + }, [error, t]) return ( - PRTS Plus 作业集 + + {t.components.viewer.OperationSetViewer.maa_copilot_task_set} +
    @@ -169,7 +179,7 @@ export const OperationSetViewer: ComponentType<{ {operationOwned && isMainComment(comment) && ( @@ -323,14 +332,14 @@ const CommentActions = ({ className="!font-normal !text-[13px]" onClick={() => setDeleteDialogOpen(true)} > - 删除 + {t.components.viewer.comment.delete} )} setDeleteDialogOpen(false)} onConfirm={handleDelete} > -

    删除评论

    +

    {t.components.viewer.comment.delete_comment}

    - 确定要删除评论吗? - {isMainComment(comment) && '所有子评论都会被删除。'} + {t.components.viewer.comment.confirm_delete} + {isMainComment(comment) && + t.components.viewer.comment.all_subcomments_deleted}

    @@ -353,6 +363,7 @@ const CommentActions = ({ } const CommentRatingButtons = ({ comment }: { comment: CommentInfo }) => { + const t = useTranslation() const { commentId, like } = comment const { reload } = useContext(CommentAreaContext) @@ -366,7 +377,8 @@ const CommentRatingButtons = ({ comment }: { comment: CommentInfo }) => { setPending(true) await wrapErrorMessage( - (e) => '评分失败:' + formatError(e), + (e) => + t.components.viewer.comment.rating_failed({ error: formatError(e) }), rateComment({ commentId, rating }), ).catch(console.warn) @@ -396,6 +408,7 @@ const CommentRatingButtons = ({ comment }: { comment: CommentInfo }) => { } const CommentTopButton = ({ comment }: { comment: MainCommentInfo }) => { + const t = useTranslation() const { commentId, topping } = comment const { reload } = useContext(CommentAreaContext) @@ -409,7 +422,7 @@ const CommentTopButton = ({ comment }: { comment: MainCommentInfo }) => { setPending(true) await wrapErrorMessage( - (e) => '置顶失败:' + formatError(e), + (e) => t.components.viewer.comment.pin_failed({ error: formatError(e) }), topComment({ commentId, topping: !topping }), ).catch(console.warn) @@ -419,7 +432,9 @@ const CommentTopButton = ({ comment }: { comment: MainCommentInfo }) => { return ( ) } diff --git a/src/components/viewer/comment/CommentForm.tsx b/src/components/viewer/comment/CommentForm.tsx index 0ebc7e2b..9e74ed4a 100644 --- a/src/components/viewer/comment/CommentForm.tsx +++ b/src/components/viewer/comment/CommentForm.tsx @@ -4,6 +4,7 @@ import { sendComment } from 'apis/comment' import clsx from 'clsx' import { useContext, useState } from 'react' +import { useTranslation } from '../../../i18n/i18n' import { MAX_COMMENT_LENGTH } from '../../../models/comment' import { formatError } from '../../../utils/error' import { wrapErrorMessage } from '../../../utils/wrapErrorMessage' @@ -22,12 +23,16 @@ export interface CommentFormProps { export const CommentForm = ({ className, primary, - placeholder = '发一条友善的评论吧', + placeholder, inputAutoFocus, maxLength = MAX_COMMENT_LENGTH, }: CommentFormProps) => { + const t = useTranslation() const { operationId, replyTo, reload } = useContext(CommentAreaContext) + const defaultPlaceholder = + t.components.viewer.comment.friendly_comment_placeholder + const [message, setMessage] = useState('') const [showMarkdownPreview, setShowMarkdownPreview] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) @@ -36,7 +41,7 @@ export const CommentForm = ({ if (!message.trim()) { AppToaster.show({ intent: 'primary', - message: '请输入评论内容', + message: t.components.viewer.comment.enter_comment, }) return } @@ -48,14 +53,15 @@ export const CommentForm = ({ setIsSubmitting(true) await wrapErrorMessage( - (e) => '发表评论失败:' + formatError(e), + (e) => + t.components.viewer.comment.submit_failed({ error: formatError(e) }), (async () => { if (primary) { // this comment is a main comment and does not reply to others await sendComment({ message, operationId }) } else { if (!replyTo) { - throw new Error('要回复的评论不存在') + throw new Error(t.components.viewer.comment.reply_target_not_found) } await sendComment({ message, @@ -66,7 +72,7 @@ export const CommentForm = ({ AppToaster.show({ intent: 'success', - message: `发表成功`, + message: t.components.viewer.comment.submit_success, }) setMessage('') @@ -85,7 +91,7 @@ export const CommentForm = ({ growVertically large maxLength={maxLength} - placeholder={placeholder} + placeholder={placeholder || defaultPlaceholder} value={message} onChange={(e) => setMessage(e.target.value)} // eslint-disable-next-line jsx-a11y/no-autofocus @@ -99,7 +105,9 @@ export const CommentForm = ({ loading={isSubmitting} onClick={handleSubmit} > - {primary ? '发表评论' : '回复'} + {primary + ? t.components.viewer.comment.post_comment + : t.components.viewer.comment.reply} - 预览 Markdown + {t.components.viewer.comment.preview_markdown}
    @@ -119,7 +127,9 @@ export const CommentForm = ({ {showMarkdownPreview && ( - {message || '*没有内容*'} + + {message || t.components.viewer.comment.no_content} + )} diff --git a/src/hooks/useLinks.tsx b/src/hooks/useLinks.tsx new file mode 100644 index 00000000..b2ba63a7 --- /dev/null +++ b/src/hooks/useLinks.tsx @@ -0,0 +1,17 @@ +import { NAV_CONFIG, SOCIAL_CONFIG } from '../links' + +export const useLinks = () => { + const NAV_LINKS = NAV_CONFIG.map(({ to, labelKey, icon }) => ({ + to, + label: labelKey(), + icon, + })) + + const SOCIAL_LINKS = SOCIAL_CONFIG.map(({ icon, href, labelKey }) => ({ + icon, + href, + label: labelKey(), + })) + + return { NAV_LINKS, SOCIAL_LINKS } +} diff --git a/src/i18n/I18NProvider.tsx b/src/i18n/I18NProvider.tsx new file mode 100644 index 00000000..7f07d5e2 --- /dev/null +++ b/src/i18n/I18NProvider.tsx @@ -0,0 +1,97 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useHydrateAtoms } from 'jotai/utils' +import { noop } from 'lodash-es' +import { Suspense, useEffect } from 'react' +import useSWR from 'swr' + +import { + Language, + RawTranslations, + i18n, + languageAtom, + rawTranslationsAtom, +} from './i18n' + +let refresh: () => void = noop + +export const I18NProvider = ({ children }: { children: JSX.Element }) => { + // We use Suspense but without a fallback because React somehow tries to throttle + // the loading state and shows the fallback for a longer time than needed, + // likely 200-500ms, although the loading of translations is almost instant... + return ( + + {children} + + ) +} + +const I18NProviderInner = ({ children }: { children: JSX.Element }) => { + const language = useAtomValue(languageAtom) + const setRawTranslations = useSetAtom(rawTranslationsAtom) + const { data, mutate } = useSWR( + 'i18n-' + language, + async () => { + const translations = await loadTranslations(language) + const rawTranslations: RawTranslations = { + language, + data: translations, + } + return rawTranslations + }, + { + suspense: true, + keepPreviousData: true, + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ) + + refresh = mutate + + useEffect(() => { + // update the atom with new translations + setRawTranslations(data) + }, [data, setRawTranslations]) + + // set initial value for the atom + useHydrateAtoms([[rawTranslationsAtom, data]]) + return children +} + +const hotReloadedModules: Partial>> = + {} + +async function loadTranslations(language: Language) { + if (import.meta.hot) { + if (hotReloadedModules[language]) { + return hotReloadedModules[language] + } + } + + try { + // note: modules must be imported with literal strings, otherwise HMR won't work + if (language === 'cn') { + return (await import(`./generated/cn`)).default + } + return (await import(`./generated/en`)).default + } catch (e) { + throw new Error(i18n.essentials.translation_load_failed) + } +} + +// handle HMR +if (import.meta.hot) { + import.meta.hot.accept( + ['./generated/cn', './generated/en'], + ([cnModule, enModule]) => { + if (cnModule?.default) { + hotReloadedModules['cn'] = cnModule?.default + } + if (enModule?.default) { + hotReloadedModules['en'] = enModule?.default + } + refresh() + }, + ) +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts new file mode 100644 index 00000000..6650d994 --- /dev/null +++ b/src/i18n/i18n.ts @@ -0,0 +1,279 @@ +import { atom, getDefaultStore, useAtomValue } from 'jotai' +import { atomWithStorage } from 'jotai/utils' +import { isObject, isString } from 'lodash-es' +import mitt from 'mitt' +import { Fragment, ReactElement, ReactNode, createElement } from 'react' +import { ValueOf } from 'type-fest' + +import ESSENTIALS from './generated/essentials' + +export const languages = ['cn', 'en'] as const +const defaultLanguage = navigator.language.startsWith('zh') ? 'cn' : 'en' + +const updater = mitt() + +export type Language = (typeof languages)[number] + +export type I18NTranslations = MakeTranslations< + | typeof import('./generated/cn').default + | typeof import('./generated/en').default +> & { essentials: I18NEssentials } + +type I18NEssentials = MakeTranslations<(typeof ESSENTIALS)[Language]> + +type MakeTranslations = MakeEndpoints> + +type ParseValue = T extends string + ? ParseMessage + : T extends PluralObject + ? ParseMessage + : { [P in keyof T]: ParseValue } + +type ParseMessage< + T extends string, + InitialKeys extends unknown[], + Keys = InterpolationKeys, +> = Keys extends [] ? string : Keys + +type InterpolationKeys< + Str, + Keys extends unknown[], +> = Str extends `${string}{{${infer Key}}}${infer End}` + ? InterpolationKeys + : Keys + +type PluralObject = Record<`${number}` | 'other', string> + +type MakeEndpoints = string extends T + ? T + : [T] extends [unknown[]] + ? Endpoint + : { [P in K]: MakeEndpoints } + +type Endpoint = Keys[number] extends UnnamedKey + ? UnnamedInterpolation<{ [K in keyof Keys]: ReactNode }> + : Interpolation<{ [K in Extract]: ReactNode }> + +type Interpolation = ( + ...args: [T] +) => InterpolationResult> + +type UnnamedInterpolation = ( + ...args: T +) => InterpolationResult + +type InterpolationResult = T extends string | number ? string : ReactElement + +declare const unnamedKey: unique symbol +type UnnamedKey = typeof unnamedKey + +export const allEssentials = Object.fromEntries( + Object.entries(ESSENTIALS).map(([language, data]) => [ + language, + setupTranslations({ + language: language as Language, + data, + }), + ]), +) as Record + +const languageStorageKey = 'maa-copilot-lang' + +let currentLanguage: Language +let currentTranslations: I18NTranslations | undefined + +export const i18n = new Proxy({} as I18NTranslations, { + get(target, prop) { + if (!currentTranslations) { + if (prop === 'essentials') { + return allEssentials[currentLanguage] + } + // if this error occurs during dev, it's probably because the code containing i18n.* is executed + // before the translations are loaded, in which case you should change it to i18nDefer.* + throw new Error(allEssentials[currentLanguage].translations_not_loaded) + } + return currentTranslations[prop] || prop + }, +}) + +type Deferred = T extends string + ? () => string + : T extends Function + ? T + : { [K in keyof T]: Deferred } + +export const i18nDefer = createDeferredProxy( + '', +) as unknown as Deferred + +function createDeferredProxy(path: string) { + const toString = () => path + + let updatedValue: unknown + updater.on(path, (value) => (updatedValue = value)) + + return new Proxy(toString, { + get(target, prop) { + if (prop === 'toString') { + return toString + } + if (Object.prototype.hasOwnProperty.call(target, prop)) { + return target[prop] + } + target[prop] = createDeferredProxy(path + '.' + String(prop)) + return target[prop] + }, + apply(target, _this, args) { + if (updatedValue !== undefined) { + if (typeof updatedValue === 'function') { + return updatedValue(...args) + } + return updatedValue + } + return toString() + }, + }) +} + +export const languageAtom = atomWithStorage( + languageStorageKey, + defaultLanguage, + undefined, + { getOnInit: true }, +) + +currentLanguage = getDefaultStore().get(languageAtom) + +export interface RawTranslations { + language: Language + data: object +} + +const internalRawTranslationsAtom = atom(undefined) +export const rawTranslationsAtom = atom( + (get) => get(internalRawTranslationsAtom), + (get, set, rawTranslations: RawTranslations) => { + const translations = setupTranslations(rawTranslations) as I18NTranslations + currentLanguage = rawTranslations.language + currentTranslations = translations + + set(internalRawTranslationsAtom, rawTranslations) + set(translationsAtom, translations) + }, +) +const internalTranslationsAtom = atom(undefined) +const translationsAtom = atom( + (get) => { + const translations = get(internalTranslationsAtom) + if (!translations) { + throw new Error(allEssentials[currentLanguage].translations_not_loaded) + } + return translations + }, + (get, set, translations: I18NTranslations) => + set(internalTranslationsAtom, translations), +) + +function setupTranslations({ language, data }: RawTranslations) { + data = { + ...data, + essentials: ESSENTIALS[language], + } + + const interpolationRegex = /{{([^}]*)}}/ + + const convert = (path: string, value: unknown) => { + const converted = doConvert(path, value) + updater.emit(path, converted) + return converted + } + + const doConvert = (path: string, value: unknown) => { + let isPlural = false + + if (isObject(value)) { + const keys = Object.keys(value) + isPlural = keys.every( + (key) => key === 'other' || !Number.isNaN(Number(key)), + ) + if (!isPlural) { + return Object.fromEntries( + keys.map((key) => [key, convert(`${path}.${key}`, value[key])]), + ) + } + } else if (!isString(value)) { + return value + } else { + const hasInterpolation = interpolationRegex.test(value) + if (!hasInterpolation) { + return value + } + } + + // as of now, value is either an interpolatable string or a plural object + + return (...args: unknown[]) => { + try { + let message: string + + if (isPlural) { + const pluralObject = value as PluralObject + const count = isObject(args[0]) + ? (args[0] as Record).count + : undefined + if (typeof count === 'number') { + message = pluralObject[String(count)] ?? pluralObject.other + } else { + message = pluralObject.other + } + } else { + message = value as string + } + + const segments = message.split(interpolationRegex) + if (segments.length === 1) { + return message + } + let hasJsx = false + const translated = segments.map((segment, index) => { + if (index % 2 === 0) { + return segment + } + if (!segment) { + const valueIndex = (index - 1) / 2 + const value = args[valueIndex] + if (!value) { + return '' + } + if (typeof value !== 'string' && typeof value !== 'number') { + hasJsx = true + } + return value + } + + const value = args[0]?.[segment] + if (!value) { + return '' + } + if (typeof value !== 'string' && typeof value !== 'number') { + hasJsx = true + } + return value + }) + if (hasJsx) { + return createElement(Fragment, {}, ...translated) + } + return translated.join('') + } catch (e) { + console.error('Error in translation:', path, e) + return path + } + } + } + + return convert('', data) +} + +export function useTranslation() { + return useAtomValue(translationsAtom) +} diff --git a/src/i18n/translations.json b/src/i18n/translations.json new file mode 100644 index 00000000..ee3c84de --- /dev/null +++ b/src/i18n/translations.json @@ -0,0 +1,3107 @@ +{ + "essentials": { + "language": { + "cn": "简体中文", + "en": "English" + }, + "error_occurred": { + "cn": "エラー発生", + "en": "Error Occurred" + }, + "render_error": { + "cn": "页面渲染出现错误;请尝试:", + "en": "An error occurred while rendering the page; please try" + }, + "refresh_page": { + "cn": "刷新页面", + "en": "Refreshing the Page" + }, + "translations_not_loaded": { + "cn": "翻译未加载", + "en": "Translations have not been loaded." + }, + "translation_load_failed": { + "cn": "翻译加载失败", + "en": "Failed to load translations." + } + }, + "apis": { + "announcement": { + "network_error": { + "cn": "网络错误", + "en": "Network error" + } + }, + "comment": { + "invalid_operation_id": { + "cn": "无效的作业ID", + "en": "Invalid operation ID" + } + }, + "operation_set": { + "search_requires_suspense": { + "cn": "useOperationSetSearch 必须使用 suspense", + "en": "useOperationSetSearch must be used with suspense" + }, + "search_cannot_be_disabled": { + "cn": "useOperationSetSearch 不能被禁用", + "en": "useOperationSetSearch cannot be disabled" + } + } + }, + "components": { + "account": { + "AuthFormShared": { + "email_required": { + "cn": "邮箱为必填项", + "en": "Email is required" + }, + "email_invalid": { + "cn": "不合法的邮箱", + "en": "Invalid email format" + }, + "password_required": { + "cn": "密码为必填项", + "en": "Password is required" + }, + "password_min_length": { + "cn": "密码长度不能小于 8 位", + "en": "Password must be at least 8 characters" + }, + "password_max_length": { + "cn": "密码长度不能大于 32 位", + "en": "Password cannot be longer than 32 characters" + }, + "username_required": { + "cn": "用户名为必填项", + "en": "Username is required" + }, + "username_min_length": { + "cn": "用户名长度不能小于 4 位", + "en": "Username must be at least 4 characters" + }, + "username_max_length": { + "cn": "用户名长度不能大于 24 位", + "en": "Username cannot be longer than 24 characters" + }, + "username_pattern": { + "cn": "用户名前后不能包含空格", + "en": "Username cannot start or end with spaces" + }, + "token_required": { + "cn": "邮箱验证码为必填项", + "en": "Email verification code is required" + }, + "token_length": { + "cn": "邮箱验证码长度为 6 位", + "en": "Email verification code must be 6 characters" + }, + "email": { + "cn": "邮箱", + "en": "Email" + }, + "email_verification_code": { + "cn": "邮箱验证码", + "en": "Email Verification Code" + }, + "password": { + "cn": "密码", + "en": "Password" + }, + "username": { + "cn": "用户名", + "en": "Username" + }, + "email_verification_note": { + "cn": "将通过发送邮件输入验证码确认", + "en": "A verification code will be sent to your email" + }, + "enter_email_code": { + "cn": "请输入邮件中的验证码", + "en": "Please enter the verification code from your email" + } + }, + "EditDialog": { + "edit_account_info": { + "cn": "修改账户信息", + "en": "Edit Account Information" + }, + "account_info": { + "cn": "账户信息", + "en": "Account Info" + }, + "password": { + "cn": "密码", + "en": "Password" + }, + "update_success": { + "cn": "更新成功", + "en": "Update successful" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "current_password": { + "cn": "当前密码", + "en": "Current Password" + }, + "new_password": { + "cn": "新密码", + "en": "New Password" + }, + "confirm_new_password": { + "cn": "确认新密码", + "en": "Confirm New Password" + }, + "forgot_password": { + "cn": "忘记密码...", + "en": "Forgot Password..." + }, + "passwords_dont_match": { + "cn": "两次输入的密码不一致", + "en": "The passwords don't match" + } + }, + "LoginPanel": { + "login_failed": { + "cn": "登录失败:{{error}}", + "en": "Login failed: {{error}}" + }, + "login_success": { + "cn": "登录成功。欢迎回来,{{name}}", + "en": "Login successful. Welcome back, {{name}}" + }, + "forgot_password": { + "cn": "忘记密码...", + "en": "Forgot Password..." + }, + "no_account": { + "cn": "还没有账号?", + "en": "Don't have an account?" + }, + "go_register": { + "cn": "前往注册", + "en": "Register now" + }, + "login": { + "cn": "登录", + "en": "Login" + } + }, + "RegisterPanel": { + "registration_failed": { + "cn": "注册失败:{{error}}", + "en": "Registration failed: {{error}}" + }, + "registration_success": { + "cn": "注册成功", + "en": "Registration successful" + }, + "invalid_email": { + "cn": "邮箱输入为空或格式错误,请重新输入", + "en": "Email is empty or has an invalid format, please try again" + }, + "send_failed": { + "cn": "发送失败:{{error}}", + "en": "Sending failed: {{error}}" + }, + "email_sent_success": { + "cn": "邮件发送成功", + "en": "Email sent successfully" + }, + "retry_seconds": { + "cn": "{{seconds}} 秒再试", + "en": "Retry in {{seconds}} seconds" + }, + "send_verification_code": { + "cn": "发送验证码", + "en": "Send Verification Code" + }, + "register": { + "cn": "注册", + "en": "Register" + } + }, + "ResetPasswordDialog": { + "reset_success": { + "cn": "重置成功,请重新登录", + "en": "Reset successful, please log in again" + }, + "reset_password": { + "cn": "重置密码", + "en": "Reset Password" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "verification_code": { + "cn": "验证码", + "en": "Verification Code" + }, + "code_required": { + "cn": "验证码为必填项", + "en": "Verification code is required" + }, + "enter_email_code": { + "cn": "请填入邮件中的验证码", + "en": "Please enter the verification code from your email" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "get_code_failed": { + "cn": "获取验证码失败:{{error}}", + "en": "Failed to get verification code: {{error}}" + }, + "code_sent": { + "cn": "验证码已发送至您的邮箱", + "en": "Verification code has been sent to your email" + }, + "resend": { + "cn": "重新发送", + "en": "Resend" + }, + "get_code": { + "cn": "获取验证码", + "en": "Get Code" + } + } + }, + "announcement": { + "AnnDialog": { + "title": { + "cn": "公告", + "en": "Announcement" + }, + "no_announcements": { + "cn": "暂无公告", + "en": "No announcements" + }, + "ok": { + "cn": "确定", + "en": "OK" + } + }, + "AnnPanel": { + "title": { + "cn": "公告", + "en": "Announcement" + }, + "load_failed": { + "cn": "公告加载失败:{{error}}", + "en": "Failed to load announcement: {{error}}" + } + } + }, + "drawer": { + "NavAside": { + "create_job_set": { + "cn": "创建作业集...", + "en": "Create Job Set" + }, + "announcement": { + "cn": "公告", + "en": "Announcement" + } + } + }, + "editor": { + "action": { + "EditorActionAdd": { + "add": { + "cn": "添加", + "en": "Add" + }, + "edit": { + "cn": "编辑", + "en": "Edit" + }, + "action": { + "cn": "动作", + "en": " Action" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "current_action": { + "cn": "正在编辑的动作", + "en": "Current Action" + }, + "action_type": { + "cn": "动作类型", + "en": "Action Type" + }, + "operator_group_name": { + "cn": "干员或干员组名", + "en": "Operator or Group Name" + }, + "select_operator_description": { + "cn": "选择干员、使用干员名、或使用干员组名引用", + "en": "Select an operator, use operator name, or reference an operator group" + }, + "search_operator_hint": { + "cn": "键入干员名、拼音或拼音首字母以搜索干员列表", + "en": "Enter operator name, initials to start search" + }, + "reference_group_hint": { + "cn": "键入干员组名以引用干员组配置", + "en": "Type group name to reference operator group configuration" + }, + "operator_required": { + "cn": "必须填写干员或干员组名", + "en": "Operator or group name is required" + }, + "skill_usage": { + "cn": "技能用法", + "en": "Skill Usage" + }, + "skill_usage_count": { + "cn": "技能使用次数", + "en": "Skill Usage Count" + }, + "camera_movement_hint": { + "cn": "移动距离一般不需要修改,只填写前置延迟(15000)和击杀数条件即可", + "en": "Movement distance usually doesn't need modification, just fill in pre-delay (15000) and kill count conditions" + }, + "execution_conditions": { + "cn": "执行条件", + "en": "Execution Conditions" + }, + "log": { + "cn": "日志", + "en": "Log" + }, + "description": { + "cn": "描述", + "en": "Description" + }, + "description_placeholder": { + "cn": "描述,可选。会显示在界面上,没有实际作用", + "en": "Description, optional. Will be displayed in the interface but has no practical effect." + }, + "cancel_edit": { + "cn": "取消编辑", + "en": "Cancel Edit" + } + }, + "EditorActionDelay": { + "pre_delay": { + "cn": "前置延时", + "en": "Pre-delay" + }, + "delay_description": { + "cn": "可选,默认为 0,单位毫秒", + "en": "Optional, defaults to 0. In milliseconds" + }, + "post_delay": { + "cn": "后置延时", + "en": "Post-delay" + } + }, + "EditorActionDistance": { + "distance_required": { + "cn": "必须填写移动距离", + "en": "Movement distance is required" + }, + "not_valid_number": { + "cn": "不是有效的数字", + "en": "Not a valid number" + }, + "movement_distance": { + "cn": "移动距离", + "en": "Movement Distance" + }, + "x_distance": { + "cn": "X 距离", + "en": "X Distance" + }, + "y_distance": { + "cn": "Y 距离", + "en": "Y Distance" + } + }, + "EditorActionDocColor": { + "description_color": { + "cn": "描述颜色", + "en": "Description Color" + }, + "color_description": { + "cn": "在 MAA 中打印描述时的颜色", + "en": "Color used when printing descriptions in MAA" + } + }, + "EditorActionExecPredicate": { + "kill_count_condition": { + "cn": "击杀数条件", + "en": "Kill Count Condition" + }, + "kill_count_description": { + "cn": "如果没达到就一直等待。可选,默认为 0,直接执行", + "en": "If not reached, will wait. Optional, defaults to 0 (execute immediately)" + }, + "kill_count": { + "cn": "击杀数", + "en": "Kill Count" + }, + "cost_condition": { + "cn": "费用条件", + "en": "DP Cost Condition" + }, + "cost_condition_description": { + "cn": "如果没达到就一直等待。可选,默认为 0,直接执行。费用受潜能等影响,可能并不完全正确,仅适合对时间轴要求不严格的战斗,否则请使用下面的费用变化量条件。另外仅在费用是两位数的时候识别的比较准,三位数的费用可能会识别错,不推荐使用。", + "en": "If not reached, will wait. Optional, defaults to 0. DP costs can be affected by potentials and may not be completely accurate. Only suitable for battles that don't require strict timing; otherwise use the DP change condition below. Recognition is more reliable with two-digit DP costs; three-digit costs may be recognized incorrectly and are not recommended." + }, + "dp_cost": { + "cn": "费用", + "en": "DP Cost" + }, + "cost_change_condition": { + "cn": "费用变化量条件", + "en": "DP Change Condition" + }, + "cost_change_description": { + "cn": "如果没达到就一直等待。可选,默认为 0,直接执行。注意:费用变化量是从开始执行本动作时开始计算的(即:使用前一个动作结束时的费用作为基准)。支持负数,即费用变少了(例如\"孑\"等吸费干员使得费用变少了)。另外仅在费用是两位数的时候识别的比较准,三位数的费用可能会识别错,不推荐使用。", + "en": "If not reached, will wait. Optional, defaults to 0. Note: DP change is calculated from the start of this action (using the DP at the end of the previous action as baseline). Supports negative values (e.g., when DP decreases due to operators like Jaye). Recognition is more reliable with two-digit DP costs; three-digit costs may be recognized incorrectly and are not recommended." + }, + "dp_change_amount": { + "cn": "费用变化量", + "en": "DP Change Amount" + }, + "cooldown_operator_condition": { + "cn": "CD 中干员数量条件", + "en": "Operators on Cooldown Condition" + }, + "cooldown_description": { + "cn": "如果没达到就一直等待。可选,默认 -1,不识别", + "en": "If not reached, will wait. Optional, defaults to -1 (not recognized)" + }, + "cooldown_count": { + "cn": "CD 中干员数量", + "en": "N° of Ops on Cooldown" + } + }, + "EditorActionOperatorDirection": { + "direction_required": { + "cn": "必须选择朝向", + "en": "Direction is required" + }, + "operator_direction": { + "cn": "干员朝向", + "en": "Operator Direction" + }, + "direction_description": { + "cn": "部署干员的干员朝向", + "en": "Direction the operator will face when deployed" + } + }, + "EditorActionOperatorLocation": { + "location_required": { + "cn": "必须填写位置", + "en": "Location is required" + }, + "invalid_location": { + "cn": "位置不是有效的数字", + "en": "Location is not a valid number" + }, + "x_out_of_range": { + "cn": "X 坐标超出地图范围 (0-{{max}})", + "en": "X coordinate out of map range (0-{{max}})" + }, + "y_out_of_range": { + "cn": "Y 坐标超出地图范围 (0-{{max}})", + "en": "Y coordinate out of map range (0-{{max}})" + }, + "operator_location": { + "cn": "干员位置", + "en": "Operator Location" + }, + "map_location_description": { + "cn": "填完关卡名后开一局,会在目录下 map 文件夹中生成地图坐标图片", + "en": "After entering stage name and starting a game, a map coordinate image will be generated in the 'map' folder" + }, + "click_on_map": { + "cn": "可在地图上点击以选择位置", + "en": "Click on the map to select a location" + }, + "x_coordinate": { + "cn": "X 坐标", + "en": "X Coordinate" + }, + "y_coordinate": { + "cn": "Y 坐标", + "en": "Y Coordinate" + } + }, + "EditorActionTypeSelect": { + "select_action_type_required": { + "cn": "请选择动作类型", + "en": "Please select an action type" + }, + "select_action": { + "cn": "选择动作", + "en": "Select action" + } + }, + "EditorActions": { + "no_actions": { + "cn": "暂无动作", + "en": "No actions yet" + }, + "update_action_not_found": { + "cn": "未能找到要更新的动作", + "en": "Could not find the action to update" + } + }, + "validation": { + "name_or_location_required": { + "cn": "类型为技能、撤退或子弹时间时,必须填写名称或位置其中一个", + "en": "When type is Skill, Retreat, or BulletTime, you must provide either a name or location" + } + } + }, + "floatingMap": { + "connection": { + "env_var_not_defined": { + "cn": "环境变量 VITE_THERESA_SERVER 未定义。", + "en": "Environment variable VITE_THERESA_SERVER is not defined." + } + }, + "FloatingMap": { + "waiting_connection": { + "cn": "等待地图连接...", + "en": "Waiting for map connection..." + }, + "no_stage_selected": { + "cn": "未选择关卡", + "en": "No stage selected" + }, + "hide_map": { + "cn": "隐藏地图", + "en": "Hide map" + }, + "show_map": { + "cn": "显示地图", + "en": "Show map" + }, + "map": { + "cn": "地图", + "en": "Map" + }, + "unnamed_stage": { + "cn": "未命名关卡", + "en": "Unnamed stage" + } + } + }, + "operator": { + "sheet": { + "sheetGroup": { + "CollapseButton": { + "collapse": { + "cn": "折叠所包含干员", + "en": "Collapse included operators" + }, + "expand": { + "cn": "展开所包含干员", + "en": "Expand included operators" + } + }, + "SheetGroupItem": { + "unsaved_changes": { + "cn": "当前干员组名修改未保存,是否放弃修改?", + "en": "Current operator group name changes are not saved, discard changes?" + }, + "edit_group_name": { + "cn": "修改干员组名称", + "en": "Edit operator group name" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "confirm": { + "cn": "确认", + "en": "Confirm" + }, + "remove_from_favorites": { + "cn": "从收藏分组中移除", + "en": "Remove from favourite groups" + }, + "will_replace_same_name": { + "cn": "此操作会替换同名干员组", + "en": "This operation will replace the operator group with the same name" + }, + "add_to_favorites": { + "cn": "添加至收藏分组", + "en": "Add to favourite groups" + }, + "already_added": { + "cn": "已添加", + "en": "Already added" + }, + "same_name_detected": { + "cn": "检测到同名干员组", + "en": "Found operator group with the same name" + }, + "use_recommended_group": { + "cn": "使用该推荐分组", + "en": "Use this recommended group" + } + }, + "SheetOperatorEditor": { + "edit_operator_info": { + "cn": "编辑干员组中干员信息", + "en": "Edit operator information in the group" + }, + "select_operator": { + "cn": "选择干员", + "en": "Select operator" + }, + "selected_operators": { + "cn": "已选择干员", + "en": "Selected operators" + }, + "unselected_operators": { + "cn": "未选择干员", + "en": "Unselected operators" + }, + "operators_in_other_groups": { + "cn": "其他分组干员", + "en": "Operators in other groups" + }, + "select_all": { + "cn": "全选", + "en": "Select all" + }, + "confirm": { + "cn": "确认", + "en": "Confirm" + }, + "unsaved_data_warning": { + "cn": "所有未保存的数据均会丢失,确认继续?", + "en": "All unsaved data will be lost, continue?" + }, + "continue": { + "cn": "继续", + "en": "Continue" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "reset": { + "cn": "重置", + "en": "Reset" + } + } + }, + "sheetOperator": { + "toolbox": { + "OperatorBackToTop": { + "back_to_top": { + "cn": "回到顶部", + "en": "Back to top" + } + }, + "OperatorMutipleSelect": { + "deselect_all_operators": { + "cn": "取消选择全部{{count}}位干员", + "en": "Deselect all {{count}} operators" + }, + "select_all_operators": { + "cn": "全选{{count}}位干员", + "en": "Select all {{count}} operators" + } + }, + "OperatorRaritySelect": { + "display_by_rarity": { + "cn": "按干员星级展示", + "en": "Display by operator rarity" + }, + "reset_selection": { + "cn": "重置选择", + "en": "Reset selection" + }, + "sort_ascending": { + "cn": "按从下至上升序排列", + "en": "Sort in ascending order (bottom to top)" + }, + "sort_descending": { + "cn": "按从下至上降序排列", + "en": "Sort in descending order (bottom to top)" + } + } + }, + "ProfClassificationWithFilters": { + "all": { + "cn": "全部", + "en": "All" + }, + "favorites": { + "cn": "收藏", + "en": "Favs" + }, + "others": { + "cn": "其它", + "en": "Other" + }, + "selected": { + "cn": "已选择", + "en": "Selected" + } + }, + "SheetOperatorItem": { + "operator_in_group": { + "cn": "干员 {{name}} 已被编组", + "en": "Operator {{name}} is already in a group" + }, + "remove_from_favorites": { + "cn": "移出收藏", + "en": "Remove from favourites" + }, + "will_replace_operator": { + "cn": "此操作会覆盖已有干员", + "en": "This will replace the existing operator" + }, + "in_group": { + "cn": "已被编组", + "en": "In a group" + } + }, + "ShowMore": { + "showing_all_operators": { + "cn": "已经展示全部干员了({{total}})", + "en": "Showing all operators ({{total}})" + }, + "collapse": { + "cn": "收起", + "en": "Collapse" + }, + "show_more": { + "cn": "显示更多干员(剩余{{remaining}})", + "en": "Show more operators ({{remaining}} remaining)" + } + } + }, + "SheetGroup": { + "add_operator_group": { + "cn": "添加干员组", + "en": "Add Operator Group" + }, + "configured_groups": { + "cn": "已设置的干员组", + "en": "Configured Operator Groups" + }, + "group_count": { + "cn": "已显示全部 {{count}} 个干员组", + "en": "Showing all {{count}} operator groups" + }, + "recommended_groups": { + "cn": "推荐分组", + "en": "Recommended Groups" + }, + "favorite_groups": { + "cn": "收藏分组", + "en": "Favourite Groups" + }, + "set_operator_groups": { + "cn": "设置干员组", + "en": "Configure Operator Groups" + }, + "group_name_empty": { + "cn": "干员组名不能为空", + "en": "Operator group name cannot be empty" + }, + "enter_group_name": { + "cn": "输入干员组名", + "en": "Enter operator group name" + }, + "add": { + "cn": "添加", + "en": "Add" + }, + "reset": { + "cn": "重置", + "en": "Reset" + } + }, + "SheetNoneData": { + "no_operators": { + "cn": "暂无干员", + "en": "No operators yet" + }, + "no_groups": { + "cn": "暂无干员组", + "en": "No operator groups yet" + } + }, + "SheetOperator": { + "select_operator": { + "cn": "选择干员", + "en": "Select Operator" + } + }, + "SheetOperatorSkillAbout": { + "skill": { + "cn": "技能", + "en": "Skill" + }, + "skill_usage": { + "cn": "技能用法", + "en": "Skill Usage" + }, + "skill_usage_count": { + "cn": "技能使用次数", + "en": "Skill Usage Count" + }, + "confirm": { + "cn": "确定", + "en": "Confirm" + }, + "default_settings_tooltip": { + "cn": "若不进行任何设置, 将使用默认值 (一技能 · 不自动使用 · 技能使用次数: 1次)", + "en": "If no settings are applied, default values will be used (Skill 1 · Don't use automatically · Usage count: 1)" + }, + "not_set": { + "cn": "未设置技能", + "en": "Skill not set" + } + } + }, + "EditorGroupItem": { + "drag_operators_here": { + "cn": "将干员拖拽到此处", + "en": "Drag operators here" + } + }, + "EditorOperator": { + "operator_or_group": { + "cn": "干员或干员组", + "en": "Operator or operator group" + }, + "operator": { + "cn": "干员", + "en": "Operator" + }, + "please_enter_name": { + "cn": "请输入{{entityName}}名", + "en": "Please enter {{entityName}} name" + }, + "no_matching_entity": { + "cn": "没有匹配的{{entityName}}", + "en": "No matching {{entityName}}" + }, + "use_custom_name": { + "cn": "使用自定义{{entityName}}名 \"{{query}}\"", + "en": "Use custom {{entityName}} name \"{{query}}\"" + }, + "entity_name": { + "cn": "{{entityName}}名", + "en": "{{entityName}} name" + } + }, + "EditorOperatorGroupSelect": { + "create_new_group": { + "cn": "创建新的干员组 \"{{query}}\"", + "en": "Create new operator group \"{{query}}\"" + }, + "no_matching_groups": { + "cn": "没有匹配的干员组", + "en": "No matching operator groups" + }, + "group_name": { + "cn": "干员组名", + "en": "Operator group name" + } + }, + "EditorOperatorItem": { + "skill_number": { + "cn": { + "1": "一技能", + "2": "二技能", + "3": "三技能", + "other": "未知技能" + }, + "en": { + "1": "S1", + "2": "S2", + "3": "S3", + "other": "Unknown Skill" + } + } + }, + "EditorOperatorSelect": { + "please_select_operator": { + "cn": "请选择干员", + "en": "Please select operator" + }, + "operator_deploy_retreat": { + "cn": "干员上/退场", + "en": "Operator Deploy/Retreat" + }, + "deploy": { + "cn": "部署", + "en": "Deploy" + }, + "deploy_description": { + "cn": "部署干员至指定位置。当费用不够时,会一直等待到费用够(除非 timeout)", + "en": "Deploy operator to specified location. When DP is insufficient, will wait until enough (unless timeout)" + }, + "retreat": { + "cn": "撤退", + "en": "Retreat" + }, + "retreat_description": { + "cn": "将干员从作战中撤出", + "en": "Withdraw operator from battle" + }, + "operator_skills": { + "cn": "干员技能", + "en": "Operator Skills" + }, + "use_skill": { + "cn": "使用技能", + "en": "Use Skill" + }, + "use_skill_description": { + "cn": "当技能 CD 没转好时,一直等待到技能 CD 好(除非 timeout)", + "en": "When skill cooldown isn't ready, will wait until it is (unless timeout)" + }, + "switch_skill_usage": { + "cn": "切换技能用法", + "en": "Switch Skill Usage" + }, + "switch_skill_usage_description": { + "cn": "切换干员技能用法。例如,刚下桃金娘、需要她帮忙打几个怪,但此时不能自动开技能否则会漏怪,等中后期平稳了才需要她自动开技能,则可以在对应时刻后,将桃金娘的技能用法从「不自动使用」改为「好了就用」。", + "en": "Switch operator skill usage. For example, when Perfumer is just deployed and needs to help defeat some enemies, but automatic skill activation would miss enemies, and only in mid/late game should she activate skills automatically, you can change Perfumer's skill usage from 'Don't use automatically' to 'Use when ready'." + }, + "battle_control": { + "cn": "作战控制", + "en": "Battle Control" + }, + "toggle_speed": { + "cn": "切换二倍速", + "en": "Toggle 2× Speed" + }, + "toggle_speed_description": { + "cn": "执行后切换至二倍速,再次执行切换至一倍速", + "en": "After execution, switches to 2× speed; executing again switches to 1× speed" + }, + "bullet_time": { + "cn": "进入子弹时间", + "en": "Enter Bullet Time" + }, + "bullet_time_description": { + "cn": "执行后将点击任意干员,进入 1/5 速度状态;再进行任意动作会恢复正常速度", + "en": "After execution, click any operator to enter 1/5 speed state; any further action will restore normal speed" + }, + "auto_mode": { + "cn": "开始挂机", + "en": "Start Auto Mode" + }, + "auto_mode_description": { + "cn": "进入挂机模式。仅使用 \"好了就用\" 的技能,其他什么都不做,直到战斗结束", + "en": "Enter auto mode. Only uses skills set to 'Use when ready', does nothing else until battle ends" + }, + "miscellaneous": { + "cn": "杂项", + "en": "Miscellaneous" + }, + "print_description": { + "cn": "打印描述内容", + "en": "Print Description" + }, + "print_description_details": { + "cn": "对作战没有实际作用,仅用于输出描述内容(用来做字幕之类的)", + "en": "Has no actual effect on battle, only outputs description content (for subtitles, etc.)" + }, + "select_operator": { + "cn": "选择干员", + "en": "Select Operator" + } + }, + "EditorOperatorSkill": { + "skill_number": { + "cn": { + "1": "一技能", + "2": "二技能", + "3": "三技能", + "other": "未知技能" + }, + "en": { + "1": "Skill 1", + "2": "Skill 2", + "3": "Skill 3", + "other": "Unknown Skill" + } + } + }, + "EditorOperatorSkillTimes": { + "skill_usage_count": { + "cn": "技能使用次数", + "en": "Skill Usage Count" + } + }, + "EditorOperatorSkillUsage": { + "select_skill_usage": { + "cn": "选择技能用法", + "en": "Select Skill Usage" + } + }, + "EditorPerformer": { + "no_operators": { + "cn": "暂无干员", + "en": "No operators yet" + }, + "no_operator_groups": { + "cn": "暂无干员组", + "en": "No operator groups yet" + }, + "operators": { + "cn": "干员", + "en": "Operators" + }, + "operator_groups": { + "cn": "干员组", + "en": "Operator Groups" + }, + "update_operator_not_found": { + "cn": "未能找到要更新的干员", + "en": "Could not find the operator to update" + }, + "update_group_not_found": { + "cn": "未能找到要更新的干员组", + "en": "Could not find the operator group to update" + }, + "operator_already_exists": { + "cn": "干员已存在", + "en": "Operator already exists" + }, + "group_already_exists": { + "cn": "干员组已存在", + "en": "Operator group already exists" + }, + "ungrouped_operators": { + "cn": "未加入干员", + "en": "Ungrouped operators" + } + }, + "EditorPerformerAdd": { + "add": { + "cn": "添加", + "en": "Add" + }, + "operator": { + "cn": "干员", + "en": "Operator" + }, + "operator_group": { + "cn": "干员组", + "en": "Operator Group" + } + }, + "EditorPerformerGroup": { + "editing_operator_group": { + "cn": "正在编辑的干员组", + "en": "Operator group being edited" + }, + "what_is_group": { + "cn": "什么是干员组?", + "en": "What is an operator group?" + }, + "group_explanation": { + "cn": "编队时将选择组内练度最高的一位干员;组内前后顺序并不影响判断", + "en": "When forming a team, the operator with the highest level in the group will be selected; order within the group does not affect selection" + }, + "group_name": { + "cn": "干员组名", + "en": "Operator Group Name" + }, + "name_description": { + "cn": "任意名称,用于在动作中引用。例如:速狙、群奶", + "en": "Any name, used for reference in actions. Examples: Fast-Snipers, AoE-Healers" + }, + "name_required": { + "cn": "请输入干员组名", + "en": "Please enter an operator group name" + }, + "name_placeholder": { + "cn": "干员组名", + "en": "Operator group name" + }, + "add": { + "cn": "添加", + "en": "Add" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "cancel_edit": { + "cn": "取消编辑", + "en": "Cancel Edit" + } + }, + "EditorPerformerOperator": { + "editing_operator": { + "cn": "正在编辑的干员", + "en": "Operator being edited" + }, + "operator_name": { + "cn": "干员名", + "en": "Operator Name" + }, + "operator_description": { + "cn": "选择干员或直接使用搜索内容创建干员", + "en": "Select an operator or create one directly using the search content" + }, + "search_hint": { + "cn": "键入干员名、拼音或拼音首字母以从干员列表中搜索", + "en": "Enter operator name, initials to start search" + }, + "group_membership": { + "cn": "所属干员组", + "en": "Operator Group Membership" + }, + "group_membership_description": { + "cn": "该干员的所属干员组,如果不存在则会自动创建", + "en": "The operator group this operator belongs to; if it doesn't exist, it will be created automatically" + }, + "skill": { + "cn": "技能", + "en": "Skill" + }, + "skill_usage": { + "cn": "技能用法", + "en": "Skill Usage" + }, + "skill_usage_count": { + "cn": "技能使用次数", + "en": "Skill Usage Count" + }, + "add": { + "cn": "添加", + "en": "Add" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "cancel_edit": { + "cn": "取消编辑", + "en": "Cancel Edit" + } + }, + "EditorSheet": { + "quick_edit": { + "cn": "快捷编辑", + "en": "Quick Edit" + } + } + }, + "source": { + "FileImporter": { + "cannot_read_file": { + "cn": "无法读取文件", + "en": "Cannot read file" + }, + "import_local_file": { + "cn": "导入本地文件...", + "en": "Import local file..." + } + }, + "ShortCodeImporter": { + "enter_shortcode": { + "cn": "请输入神秘代码", + "en": "Please enter a shortcode" + }, + "invalid_shortcode": { + "cn": "无效的神秘代码", + "en": "Invalid shortcode" + }, + "cannot_parse_content": { + "cn": "无法解析作业内容", + "en": "Cannot parse Job content" + }, + "load_failed": { + "cn": "加载失败:", + "en": "Loading failed: " + }, + "import_shortcode": { + "cn": "导入神秘代码...", + "en": "Import shortcode..." + }, + "import_shortcode_title": { + "cn": "导入神秘代码", + "en": "Import Shortcode" + }, + "shortcode_label": { + "cn": "神秘代码", + "en": "Shortcode" + }, + "shortcode_description": { + "cn": "神秘代码可在本站的作业详情中获取", + "en": "Shortcodes can be found in Job details on this site" + }, + "import_button": { + "cn": "导入", + "en": "Import" + } + }, + "SourceEditorButton": { + "edit_json": { + "cn": "编辑 JSON", + "en": "Edit JSON" + } + }, + "SourceEditor": { + "parsing_error": { + "cn": "(解析时出现错误)", + "en": "(Error occurred during parsing)" + }, + "syntax_error": { + "cn": "存在语法错误", + "en": "Syntax error exists" + }, + "structure_error": { + "cn": "存在结构错误", + "en": "Structure error exists" + }, + "json_update_notice": { + "cn": "在此处编辑 JSON 将会实时更新表单", + "en": "Editing JSON here will update the form in real-time" + }, + "json_validation": { + "cn": "JSON 验证:{{status}}", + "en": "JSON validation: {{status}}" + }, + "syntax_error_short": { + "cn": "语法错误", + "en": "Syntax error" + }, + "passed": { + "cn": "通过", + "en": "Passed" + }, + "see_errors_in_form": { + "cn": "请在表单中查看错误信息", + "en": "Please check the error messages in the form" + }, + "form_validation": { + "cn": "表单验证:{{status}}", + "en": "Form validation: {{status}}" + }, + "not_passed": { + "cn": "未通过", + "en": "Not passed" + } + }, + "SourceEditorHeader": { + "edit_json": { + "cn": "编辑 JSON", + "en": "Edit JSON" + }, + "json_copied": { + "cn": "已复制 JSON 到剪贴板", + "en": "JSON copied to clipboard" + }, + "untitled": { + "cn": "未命名", + "en": "Untitled" + }, + "job_json_downloaded": { + "cn": "已下载作业 JSON 文件", + "en": "Job JSON file downloaded" + }, + "import": { + "cn": "导入", + "en": "Import" + }, + "copy": { + "cn": "复制", + "en": "Copy" + }, + "download": { + "cn": "下载", + "en": "Download" + }, + "export": { + "cn": "导出", + "en": "Export" + } + } + }, + "CardOptions": { + "duplicate": { + "cn": "复制", + "en": "Duplicate" + }, + "edit": { + "cn": "编辑", + "en": "Edit" + }, + "delete": { + "cn": "删除", + "en": "Delete" + } + }, + "EditorIntegerInput": { + "min_value": { + "cn": "最小为 {{min}}", + "en": "Minimum value is {{min}}" + } + }, + "EditorResetButton": { + "reset": { + "cn": "重置", + "en": "Reset" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "reset_entity": { + "cn": "重置{{entityName}}", + "en": "Reset {{entityName}}" + }, + "confirm_reset": { + "cn": "确定要重置{{entityName}}吗?", + "en": "Are you sure you want to reset the {{entityName}}?" + }, + "reset_button": { + "cn": "重置...", + "en": "Reset..." + } + }, + "FormError": { + "error_occurred": { + "cn": "发生错误…", + "en": "Errors occurred..." + }, + "unknown_error": { + "cn": "未知错误", + "en": "Unknown error" + } + }, + "OperationEditorLauncher": { + "launch_job_editor": { + "cn": "启动作业编辑器", + "en": "Launch Job editor" + } + }, + "OperationEditor": { + "stage_required": { + "cn": "请输入关卡", + "en": "Please enter a stage" + }, + "stage": { + "cn": "关卡", + "en": "Stage" + }, + "type_to_search": { + "cn": "键入以搜索", + "en": "Type to search" + }, + "for_main_event_stages": { + "cn": "对于主线、活动关卡:键入关卡代号、关卡中文名或活动名称", + "en": "For main story or event stages: Type stage code, stage name or event name" + }, + "for_paradox_stages": { + "cn": "对于悖论模拟关卡:键入关卡名或干员名", + "en": "For paradox simulation stages: Type stage name or operator name" + }, + "custom": { + "cn": "自定义", + "en": "Custom" + }, + "no_matching_stages": { + "cn": "没有匹配的关卡", + "en": "No matching stages" + }, + "use_custom_stage": { + "cn": "使用自定义关卡名 \"{{query}}\"", + "en": "Use custom stage name \"{{query}}\"" + }, + "view_in_prts_map": { + "cn": "在 PRTS.Map 中查看关卡", + "en": "View stage in PRTS.Map" + }, + "stage_difficulty": { + "cn": "关卡难度", + "en": "Stage Difficulty" + }, + "difficulty_description": { + "cn": "在作业上显示的难度标识,如果不选择则不显示", + "en": "Difficulty indicator shown on the Job, not displayed if not selected" + }, + "no_challenge_mode": { + "cn": "该关卡没有突袭难度,无需选择", + "en": "This stage has no challenge mode, no need to select" + }, + "normal": { + "cn": "普通", + "en": "Standard" + }, + "challenge": { + "cn": "突袭", + "en": "Challenge" + }, + "job_editor": { + "cn": "作业编辑器", + "en": "Job Editor" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "job_metadata": { + "cn": "作业元信息", + "en": "Job Metadata" + }, + "job_title": { + "cn": "作业标题", + "en": "Job Title" + }, + "title_required": { + "cn": "必须填写标题", + "en": "Title is required" + }, + "title_placeholder": { + "cn": "起一个引人注目的标题吧", + "en": "Create an eye-catching title" + }, + "job_description": { + "cn": "作业描述", + "en": "Job Description" + }, + "description_placeholder": { + "cn": "如:作者名、参考的视频攻略链接(如有)等", + "en": "Example: Author name, reference video strategy links (if any), etc." + }, + "action_sequence": { + "cn": "动作序列", + "en": "Action Sequence" + }, + "drag_to_reorder": { + "cn": "拖拽以重新排序", + "en": "Drag to reorder" + }, + "operators_and_groups": { + "cn": "干员与干员组", + "en": "Operators and Groups" + }, + "drag_to_reorder_operators": { + "cn": "拖拽以重新排序或分配干员", + "en": "Drag to reorder or assign operators" + }, + "drag_too_fast_issue": { + "cn": "如果拖拽速度过快可能会使动画出现问题,此时请点击", + "en": "Dragging too quickly causes animation issues, please click " + }, + "refresh_ui": { + "cn": "刷新界面", + "en": "refresh UI" + }, + "to_fix_no_data_loss": { + "cn": "以修复 (不会丢失数据)", + "en": " to fix. (no data will be lost)" + } + }, + "useAutosave": { + "autosave_info": { + "cn": "每隔 {{minutes}} 分钟自动保存编辑过的内容,记录上限为 {{limit}} 条", + "en": "Auto-saves edited content every {{minutes}} minutes, with a maximum of {{limit}} records" + }, + "autosaved_at": { + "cn": "已自动保存:{{time}}", + "en": "Auto-saved: {{time}}" + }, + "not_saved": { + "cn": "未保存", + "en": "Not saved" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "confirm": { + "cn": "确定", + "en": "Confirm" + }, + "restore_content": { + "cn": "恢复内容", + "en": "Restore Content" + }, + "restore_confirmation": { + "cn": "当前的编辑内容将会被覆盖,确定要恢复内容吗?", + "en": "Current edits will be overwritten. Are you sure you want to restore this content?" + } + }, + "validation": { + "empty_group": { + "cn": "干员组\"{{name}}\"不能为空", + "en": "Operator group \"{{name}}\" cannot be empty" + }, + "bullet_time_error": { + "cn": "第{{index}}个动作是\"{{actionType}}\",但它的下一个动作不是\"{{validTypes}}\"其中之一", + "en": "Action #{{index}} is \"{{actionType}}\", but the next action is not one of \"{{validTypes}}\"" + }, + "bullet_time_separator": { + "cn": "\"、\"", + "en": "\", \"" + } + } + }, + "entity": { + "EDifficulty": { + "regular": { + "cn": "普通", + "en": "Standard" + }, + "regular_description": { + "cn": "本作业支持普通难度作战", + "en": "This Job supports Standard mode" + }, + "hard": { + "cn": "突袭", + "en": "Challenge" + }, + "hard_description": { + "cn": "本作业支持突袭难度作战", + "en": "This Job supports Challenge mode" + }, + "unknown": { + "cn": "未知难度", + "en": "Unknown Difficulty" + }, + "unknown_description": { + "cn": "本作业并未支持难度支持标识,请自行根据作业的文字描述判断其所支持的作业难度等级。通常来说,未写明支持难度的作业均兼容突袭和普通难度作战。", + "en": "This Job doesn't specify supported difficulty levels. Please refer to the Job description to determine compatibility. Generally, Jobs without specified difficulty support both standard and challenge modes." + } + }, + "ELevel": { + "custom_level": { + "cn": "自定义关卡", + "en": "Custom Stage" + } + } + }, + "operationSet": { + "AddToOperationSet": { + "add_to_job_set_title": { + "cn": "添加 {{count}} 份作业到作业集", + "en": "Add {{count}} Jobs to Job Set" + }, + "not_logged_in": { + "cn": "未登录", + "en": "Not logged in" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "no_job_sets_yet": { + "cn": "还没有作业集哦( ̄▽ ̄)", + "en": "No Job sets yet ( ̄▽ ̄)" + }, + "no_added_job_sets_yet": { + "cn": "还没有已添加的作业集哦( ̄▽ ̄)", + "en": "No Job sets added yet ( ̄▽ ̄)" + }, + "added_to_job_set": { + "cn": "已添加到作业集", + "en": "Added to Job Set" + }, + "click_to_view": { + "cn": "点击查看", + "en": "Click to view" + }, + "show_only_added": { + "cn": "只显示已添加的", + "en": "Show only added" + }, + "create_job_set": { + "cn": "创建作业集...", + "en": "Create Job Set..." + }, + "confirm": { + "cn": "确定", + "en": "Confirm" + }, + "load_more": { + "cn": "加载更多", + "en": "Load more" + }, + "private": { + "cn": "私有", + "en": "Private" + } + }, + "OperationSetEditor": { + "create_job_set": { + "cn": "创建作业集", + "en": "Create Job Set" + }, + "edit_job_set": { + "cn": "编辑作业集", + "en": "Edit Job Set" + }, + "update_success": { + "cn": "更新作业集成功", + "en": "Job set updated successfully" + }, + "create_success": { + "cn": "创建作业集成功", + "en": "Job set created successfully" + }, + "no_jobs_yet": { + "cn": "还没有添加作业哦( ̄▽ ̄)", + "en": "No Jobs added yet ( ̄▽ ̄)" + }, + "add_from_list": { + "cn": "请从作业列表中添加", + "en": "Please add from the Job list" + }, + "title": { + "cn": "标题", + "en": "Title" + }, + "title_required": { + "cn": "标题不能为空", + "en": "Title is required" + }, + "description": { + "cn": "描述", + "en": "Description" + }, + "visible_to_all": { + "cn": "对所有人可见", + "en": "Visible to everyone" + }, + "click_save": { + "cn": "修改后请点击保存按钮", + "en": "Click save after making changes" + }, + "save": { + "cn": "保存", + "en": "Save" + }, + "create": { + "cn": "创建", + "en": "Create" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "sort_by_level": { + "cn": "按关卡", + "en": "By Level" + }, + "loading": { + "cn": "加载中...", + "en": "Loading..." + }, + "level_load_failed": { + "cn": "关卡加载失败,使用备用排序", + "en": "Stage loading failed, using fallback sorting" + }, + "sort_by_title": { + "cn": "按标题", + "en": "By Title" + }, + "sort_by_id": { + "cn": "按 ID", + "en": "By ID" + }, + "quick_sort": { + "cn": "一键排序", + "en": "Quick Sort" + }, + "reverse_list": { + "cn": "反转列表", + "en": "Reverse List" + } + } + }, + "uploader": { + "OperationUploader": { + "wait_upload": { + "cn": "正在上传,请等待", + "en": "Uploading, please wait" + }, + "wait_parsing": { + "cn": "正在解析文件,请等待", + "en": "Parsing files, please wait" + }, + "select_files": { + "cn": "请选择文件", + "en": "Please select files" + }, + "contains_uploaded": { + "cn": "文件列表中包含已上传的文件,请重新选择", + "en": "File list contains already uploaded files, please select again" + }, + "file_errors": { + "cn": "文件存在错误,请修改内容", + "en": "Files have errors, please fix content" + }, + "errors_exist": { + "cn": "存在错误,请排查问题", + "en": "Errors exist, please check issues" + }, + "upload_failed": { + "cn": "上传失败:{{error}}", + "en": "Upload failed: {{error}}" + }, + "upload_complete": { + "cn": "作业上传完成:成功 {{successCount}} 个,失败 {{errorCount}} 个", + "en": "Job upload complete: {{successCount}} successful, {{errorCount}} failed" + }, + "upload_local_jobs": { + "cn": "上传本地作业", + "en": "Upload local Jobs" + }, + "edit_before_upload_message": { + "cn": "若需要在上传前进行编辑,请在作业编辑器的", + "en": "If you need to edit before uploading, use" + }, + "edit_json": { + "cn": "编辑 JSON", + "en": "Edit JSON" + }, + "import_job": { + "cn": "处导入作业", + "en": "in the Job editor to import" + }, + "select_job_files": { + "cn": "选择作业文件", + "en": "Select Job Files" + }, + "json_files_only": { + "cn": "仅支持 .json 文件,可多选", + "en": "Only .json files supported, multiple selection allowed" + }, + "browse": { + "cn": "浏览", + "en": "Browse" + }, + "file_count": { + "cn": "{{count}} 个文件", + "en": "{{count}} files" + }, + "choose_files": { + "cn": "选择文件...", + "en": "Choose files..." + }, + "upload": { + "cn": "上传", + "en": "Upload" + }, + "error": { + "cn": "错误", + "en": "Error" + }, + "file_details": { + "cn": "文件详情", + "en": "File Details" + }, + "untitled": { + "cn": "无标题", + "en": "Untitled" + } + }, + "OperationUploaderLauncher": { + "upload_local_jobs": { + "cn": "上传本地作业", + "en": "Upload local Jobs" + } + }, + "utils": { + "invalid_object": { + "cn": "不是有效的对象", + "en": "Not a valid object" + }, + "select_json_file": { + "cn": "请选择 JSON 文件", + "en": "Please select a JSON file" + }, + "json_parse_failed": { + "cn": "请选择合法的 JSON 文件:JSON 解析失败:", + "en": "Please select a valid JSON file: JSON parsing failed: " + }, + "job_with_stage_name": { + "cn": "作业 {{stageName}}", + "en": "Job {{stageName}}" + }, + "stage_not_unique": { + "cn": "匹配到的关卡不唯一", + "en": "Stage match is not unique" + }, + "stage_not_found": { + "cn": "未找到对应关卡", + "en": "Corresponding stage not found" + }, + "auto_fix_failed": { + "cn": "自动修正失败:", + "en": "Auto-correction failed: " + }, + "validation_failed": { + "cn": "验证失败:", + "en": "Validation failed: " + } + } + }, + "viewer": { + "comment": { + "no_comments": { + "cn": "还没有评论,发一条评论鼓励作者吧!", + "en": "No comments yet. Be the first to encourage the author!" + }, + "encourage_author": { + "cn": "(。・ω・。)ノ♡", + "en": "(。・ω・。)ノ♡" + }, + "reached_bottom": { + "cn": "已经到底了哦 (゚▽゚)/", + "en": "You've reached the bottom (゚▽゚)/" + }, + "load_more": { + "cn": "加载更多", + "en": "Load More" + }, + "deleted": { + "cn": "(已删除)", + "en": "(Deleted)" + }, + "reply": { + "cn": "回复", + "en": "Reply" + }, + "pinned": { + "cn": "置顶", + "en": "Pinned" + }, + "delete": { + "cn": "删除", + "en": "Delete" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "delete_comment": { + "cn": "删除评论", + "en": "Delete Comment" + }, + "confirm_delete": { + "cn": "确定要删除评论吗?", + "en": "Are you sure you want to delete this comment?" + }, + "all_subcomments_deleted": { + "cn": "所有子评论都会被删除。", + "en": "All replies will be deleted as well." + }, + "rating_failed": { + "cn": "评分失败:{{error}}", + "en": "Rating failed: {{error}}" + }, + "pin_failed": { + "cn": "置顶失败:{{error}}", + "en": "Pinning failed: {{error}}" + }, + "unpin": { + "cn": "取消置顶", + "en": "Unpin" + }, + "pin": { + "cn": "置顶", + "en": "Pin" + }, + "friendly_comment_placeholder": { + "cn": "发一条友善的评论吧", + "en": "Leave a friendly comment" + }, + "enter_comment": { + "cn": "请输入评论内容", + "en": "Please enter comment content" + }, + "submit_failed": { + "cn": "发表评论失败:{{error}}", + "en": "Failed to submit comment: {{error}}" + }, + "reply_target_not_found": { + "cn": "要回复的评论不存在", + "en": "The comment you're replying to doesn't exist" + }, + "submit_success": { + "cn": "发表成功", + "en": "Successfully posted" + }, + "post_comment": { + "cn": "发表评论", + "en": "Post Comment" + }, + "preview_markdown": { + "cn": "预览 Markdown", + "en": "Preview Markdown" + }, + "no_content": { + "cn": "*没有内容*", + "en": "*No content*" + } + }, + "OperationRating": { + "not_enough_ratings_long": { + "cn": "还没有足够的评分", + "en": "Not enough ratings yet" + }, + "not_enough_ratings_short": { + "cn": "评分不足", + "en": "Not enough ratings" + }, + "liked_percentage": { + "cn": "有{{percent}}%的人为本作业点了个赞({{ratio}})", + "en": "{{percent}}% of users liked this Job ({{ratio}})" + } + }, + "OperationSetViewer": { + "delete_failed": { + "cn": "删除失败:{{error}}", + "en": "Delete failed: {{error}}" + }, + "delete_success": { + "cn": "删除成功", + "en": "Delete successful" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "delete": { + "cn": "删除", + "en": "Delete" + }, + "delete_task_set": { + "cn": "删除作业集", + "en": "Delete Job Set" + }, + "confirm_delete_task_set": { + "cn": "确定要删除作业集吗?", + "en": "Are you sure you want to delete this Job set?" + }, + "edit_task_set": { + "cn": "编辑作业集...", + "en": "Edit Job Set..." + }, + "refresh_failed": { + "cn": "刷新作业集失败:{{error}}", + "en": "Failed to refresh Job set: {{error}}" + }, + "maa_copilot_task_set": { + "cn": "PRTS Plus 作业集", + "en": "PRTS Plus Job Set" + }, + "manage": { + "cn": "管理", + "en": "Manage" + }, + "copy_secret_code": { + "cn": "复制神秘代码", + "en": "Copy Secret Code" + }, + "render_error": { + "cn": "渲染错误", + "en": "Render Error" + }, + "render_problem": { + "cn": "渲染此作业集时出现了问题。是否是还未支持的作业集类型?", + "en": "There was a problem rendering this Job set. Is it an unsupported Job set type?" + }, + "published_at": { + "cn": "发布于", + "en": "Published at" + }, + "author": { + "cn": "作者", + "en": "Author" + }, + "render_preview_problem": { + "cn": "渲染此作业集的预览时出现了问题 Σ(っ °Д °;)っ", + "en": "There was a problem rendering the preview of this Job set Σ(っ °Д °;)っ" + }, + "task_list": { + "cn": "作业列表", + "en": "Job List" + }, + "loading_task_set": { + "cn": "作业集加载中", + "en": "Loading Job Set" + } + }, + "OperationViewer": { + "operation_failed": { + "cn": "操作失败:{{error}}", + "en": "Operation failed: {{error}}" + }, + "delete_failed": { + "cn": "删除失败:{{error}}", + "en": "Delete failed: {{error}}" + }, + "delete_success": { + "cn": "删除成功", + "en": "Delete successful" + }, + "modify_task": { + "cn": "修改作业", + "en": "Modify Job" + }, + "close_comments": { + "cn": "关闭评论区", + "en": "Close Comments" + }, + "confirm_close_comments": { + "cn": "确定要关闭评论区吗?", + "en": "Are you sure you want to close the comments section?" + }, + "existing_comments_preserved": { + "cn": "已有的评论不会被删除,重新开启后会恢复显示", + "en": "Existing comments won't be deleted and will be visible again when reopened" + }, + "open_comments": { + "cn": "开启评论区", + "en": "Open Comments" + }, + "confirm_open_comments": { + "cn": "确定要开启评论区吗?", + "en": "Are you sure you want to open the comments section?" + }, + "delete": { + "cn": "删除", + "en": "Delete" + }, + "delete_task": { + "cn": "删除作业", + "en": "Delete Job" + }, + "confirm_delete_task": { + "cn": "确定要删除作业吗?", + "en": "Are you sure you want to delete this Job?" + }, + "three_confirmations": { + "cn": "需要三次确认以删除", + "en": "Three confirmations required to delete" + }, + "refresh_failed": { + "cn": "刷新作业失败:{{error}}", + "en": "Failed to refresh Job: {{error}}" + }, + "submit_rating_failed": { + "cn": "提交评分失败:{{error}}", + "en": "Failed to submit rating: {{error}}" + }, + "maa_copilot_task": { + "cn": "PRTS Plus 作业", + "en": "PRTS Plus Job" + }, + "manage": { + "cn": "管理", + "en": "Manage" + }, + "download_json": { + "cn": "下载原 JSON", + "en": "Download Original JSON" + }, + "copy_secret_code": { + "cn": "复制神秘代码", + "en": "Copy Secret Code" + }, + "render_error": { + "cn": "渲染错误", + "en": "Render Error" + }, + "render_problem": { + "cn": "渲染此作业时出现了问题。是否是还未支持的作业类型?", + "en": "There was a problem rendering this Job. Is it an unsupported Job type?" + }, + "loading_task": { + "cn": "作业加载中", + "en": "Loading Job" + }, + "private": { + "cn": "私有", + "en": "Private" + }, + "skill": { + "cn": "技能", + "en": "Skill" + }, + "stage": { + "cn": "作战", + "en": "Stage" + }, + "task_rating": { + "cn": "作业评分", + "en": "Job Rating" + }, + "views": { + "cn": "浏览量", + "en": "Views" + }, + "published_at": { + "cn": "发布于", + "en": "Published at" + }, + "author": { + "cn": "作者", + "en": "Author" + }, + "render_preview_problem": { + "cn": "渲染此作业的预览时出现了问题。是否是还未支持的作业类型?", + "en": "There was a problem rendering the preview of this Job. Is it an unsupported Job type?" + }, + "comments": { + "cn": "评论", + "en": "Comments" + }, + "comments_count": { + "cn": "评论 ({{count}})", + "en": "Comments ({{count}})" + }, + "comments_closed": { + "cn": "评论区已关闭", + "en": "Comment section is closed" + }, + "feel_the_silence": { + "cn": "感受…宁静……", + "en": "Feel... the silence..." + }, + "operators_and_groups": { + "cn": "干员与干员组", + "en": "Operators and Operator Groups" + }, + "operator_group_tooltip": { + "cn": "干员组:组内干员可以任选其一,自动编队时按最高练度来选择", + "en": "Operator Group: Choose any operator in the group; auto-formation selects the highest level" + }, + "no_operators": { + "cn": "暂无干员", + "en": "No Operators" + }, + "no_operators_added": { + "cn": "作业并未添加干员", + "en": "No operators have been added to this Job" + }, + "unknown": { + "cn": "未知", + "en": "Unknown" + }, + "no_operator": { + "cn": "无干员", + "en": "No Operator" + }, + "action_sequence": { + "cn": "动作序列", + "en": "Action Sequence" + }, + "no_actions": { + "cn": "暂无动作", + "en": "No Actions" + }, + "no_actions_defined": { + "cn": "作业并未定义任何动作", + "en": "No actions have been defined for this Job" + } + } + }, + "AccountManager": { + "logout_success": { + "cn": "已退出登录", + "en": "Logged out successfully" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "logout": { + "cn": "退出登录", + "en": "Log Out" + }, + "logout_confirm": { + "cn": "确定要退出登录吗?", + "en": "Are you sure you want to log out?" + }, + "account_not_activated": { + "cn": "账号未激活,请在退出登录后,以重置密码的方式激活", + "en": "Account not activated. Please log out and use the password reset feature to activate." + }, + "profile": { + "cn": "个人主页", + "en": "Profile" + }, + "edit_info": { + "cn": "修改信息...", + "en": "Edit Information" + }, + "maa_account": { + "cn": "PRTS Plus 账户", + "en": "PRTS Plus Account" + }, + "login": { + "cn": "登录", + "en": "Login" + }, + "register": { + "cn": "注册", + "en": "Register" + }, + "login_register": { + "cn": "登录 / 注册", + "en": "Login / Register" + } + }, + "ActionCard": { + "coordinates": { + "cn": "坐标", + "en": "Coordinates" + }, + "direction": { + "cn": "朝向", + "en": "Direction" + }, + "distance": { + "cn": "距离", + "en": "Distance" + }, + "kills": { + "cn": "击杀", + "en": "Kills" + }, + "cooling": { + "cn": "冷却中", + "en": "On Cooldown" + }, + "cost": { + "cn": "费用", + "en": "DP Cost" + }, + "cost_changes": { + "cn": "费用变化", + "en": "DP Change" + }, + "pre_delay": { + "cn": "前置", + "en": "Pre-delay" + }, + "rear_delay": { + "cn": "后置", + "en": "Post-delay" + } + }, + "Confirm": { + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "confirm": { + "cn": "确定", + "en": "Confirm" + } + }, + "LevelSelect": { + "annihilation": { + "cn": "剿灭作战", + "en": "Annihilation" + }, + "contingency_contract": { + "cn": "危机合约", + "en": "Contingency Contract" + }, + "related_levels": { + "cn": "相关关卡", + "en": "Related Stages" + }, + "search_level": { + "cn": "直接搜索关卡 \"{{query}}\"", + "en": "Search Stage \"{{query}}\"" + }, + "level_search_placeholder": { + "cn": "关卡名、类型、编号", + "en": "Stage name, category, or ID" + }, + "level": { + "cn": "关卡", + "en": "Stage" + } + }, + "OperationCard": { + "private": { + "cn": "私有", + "en": "Private" + }, + "operators_and_groups": { + "cn": "干员/干员组", + "en": "Operators/Groups" + }, + "no_operators": { + "cn": "无干员", + "en": "No operators" + }, + "no_records": { + "cn": "无记录", + "en": "No records" + }, + "views_count": { + "cn": "访问量:{{count}}", + "en": "Views: {{count}}" + }, + "download_json": { + "cn": "下载原 JSON", + "en": "Download Original JSON" + }, + "copy_secret_code": { + "cn": "复制神秘代码", + "en": "Copy Secret Code" + }, + "add_to_job_set": { + "cn": "添加到作业集", + "en": "Add to Job Set" + } + }, + "OperationList": { + "selected_jobs": { + "cn": "已选择 {{count}} 份作业", + "en": "{{count}} Jobs selected" + }, + "only_loaded_items": { + "cn": "只能选择已加载的项目", + "en": "Only loaded items can be selected" + }, + "select_all": { + "cn": "全选", + "en": "Select All" + }, + "clear": { + "cn": "清空", + "en": "Clear" + }, + "add_to_job_set": { + "cn": "添加到作业集", + "en": "Add to Job Set" + }, + "no_jobs_found": { + "cn": "没有找到任何作业", + "en": "No Jobs found" + }, + "sad_face": { + "cn": "(つД`)・゚・", + "en": "(つД`)・゚・" + }, + "reached_bottom": { + "cn": "已经到底了哦 (゚▽゚)/", + "en": "You've reached the bottom (゚▽゚)/" + }, + "load_more": { + "cn": "加载更多", + "en": "Load More" + } + }, + "Operations": { + "operations": { + "cn": "作业", + "en": "Jobs" + }, + "operation_sets": { + "cn": "作业集", + "en": "Job Sets" + }, + "enable_multi_select": { + "cn": "启动多选", + "en": "Enable Multi-select" + }, + "search_placeholder": { + "cn": "标题、描述、神秘代码", + "en": "Title, Description, Secret Code" + }, + "sort_by": { + "cn": "排序:", + "en": "Sort by:" + }, + "popularity": { + "cn": "热度", + "en": "Popularity" + }, + "newest": { + "cn": "最新", + "en": "Newest" + }, + "views": { + "cn": "访问量", + "en": "Views" + } + }, + "OperationSetCard": { + "private": { + "cn": "私有", + "en": "Private" + }, + "jobs_count": { + "cn": "{{count}}份作业", + "en": "{{count}} Jobs" + }, + "copy_secret_code": { + "cn": "复制神秘代码", + "en": "Copy Secret Code" + } + }, + "OperationSetList": { + "no_job_sets_found": { + "cn": "没有找到任何作业集", + "en": "No Job Sets found" + }, + "sad_face": { + "cn": "(つД`)・゚・", + "en": "(つД`)・゚・" + }, + "reached_bottom": { + "cn": "已经到底了哦 (゚▽゚)/", + "en": "You've reached the bottom (゚▽゚)/" + }, + "load_more": { + "cn": "加载更多", + "en": "Load More" + } + }, + "OperatorFilter": { + "disable_operator_selection": { + "cn": "禁用干员选择", + "en": "Disable operator selection" + }, + "operators": { + "cn": "干员", + "en": "Operators" + }, + "select_operators": { + "cn": "选择干员", + "en": "Select Operators" + }, + "included_operators": { + "cn": "包含的干员", + "en": "Included Operators" + }, + "excluded_operators": { + "cn": "排除的干员", + "en": "Excluded Operators" + }, + "search_help": { + "cn": "输入干员名、拼音或拼音首字母以搜索", + "en": "Enter operator name, initials to start search" + }, + "cancel": { + "cn": "取消", + "en": "Cancel" + }, + "confirm": { + "cn": "确认", + "en": "Confirm" + }, + "remember_selection": { + "cn": "记住选择", + "en": "Remember Selection" + } + }, + "OperatorSelect": { + "no_matching_operators": { + "cn": "没有匹配的干员", + "en": "No matching operators" + } + }, + "Suspensable": { + "loading": { + "cn": "加载中", + "en": "Loading" + }, + "loadFailed": { + "cn": "加载失败", + "en": "Load Failed" + }, + "dataLoadFailedRetry": { + "cn": "数据加载失败,请重试", + "en": "Data load failed, please retry" + }, + "retry": { + "cn": "重试", + "en": "Retry" + } + }, + "UserFilter": { + "searching": { + "cn": "正在搜索...", + "en": "Searching..." + }, + "search_failed": { + "cn": "搜索失败:", + "en": "Search failed: " + }, + "no_user_found": { + "cn": "查无此人 (゚Д゚≡゚д゚)!?", + "en": "No user found (゚Д゚≡゚д゚)!?" + }, + "enter_username": { + "cn": "输入用户名以搜索", + "en": "Enter username to search" + }, + "username_placeholder": { + "cn": "用户名称", + "en": "Username" + }, + "author": { + "cn": "作者", + "en": "Author" + }, + "view_my_jobs": { + "cn": "查看我自己的作业", + "en": "View my own Jobs" + }, + "view_mine": { + "cn": "看看我的", + "en": "View personal" + } + } + }, + "models": { + "announcement": { + "default_title": { + "cn": "公告", + "en": "Announcement" + } + }, + "converter": { + "invalid_operation_content": { + "cn": "无法解析作业内容", + "en": "Unable to parse Job content" + }, + "failed_to_parse_operation": { + "cn": "解析作业失败", + "en": "Failed to parse operation" + } + }, + "operator": { + "skill_number": { + "cn": { + "1": "一技能", + "2": "二技能", + "3": "三技能", + "other": "未知技能" + }, + "en": { + "1": "S1", + "2": "S2", + "3": "S3", + "other": "Unknown Skill" + } + }, + "skill_usage": { + "none_title": { + "cn": "不自动使用", + "en": "Don't use automatically" + }, + "none_description": { + "cn": "不由 MAA 自动开启技能、或干员技能并不需要操作开启(自动触发)。若需要手动开启技能,请添加「使用技能」动作", + "en": "Skills won't be activated automatically by MAA, or for operators whose skills don't need manual activation (auto-trigger). If you need to manually activate a skill, add a 'Use Skill' action" + }, + "ready_to_use_title": { + "cn": "好了就用", + "en": "Use when ready" + }, + "ready_to_use_description": { + "cn": "有多少次用多少次,例如:棘刺 3 技能、桃金娘 1 技能等", + "en": "Use as many times as available. Examples: Thorns S3, Perfumer S1, etc." + }, + "ready_to_use_times_title": { + "cn": "好了就用(指定次数)", + "en": "Use when ready (specific times)" + }, + "ready_to_use_times_description": { + "cn": "默认仅使用一次,例如:山 2 技能", + "en": "By default, only use once. Example: Mountain S2" + }, + "automatically_title": { + "cn": "自动判断使用时机", + "en": "Automatically determine timing" + }, + "automatically_description": { + "cn": "(锐意开发中) 画饼.jpg", + "en": "(In development) Coming Soon™" + }, + "unknown_title": { + "cn": "未知用法", + "en": "Unknown usage" + }, + "format_times": { + "cn": "好了就用({{times}}次)", + "en": { + "1": "Use when ready (1 time)", + "other": "Use when ready ({{times}} times)" + } + }, + "format_specific_times": { + "cn": "好了就用(指定次数)", + "en": "Use when ready (specific times)" + } + }, + "direction": { + "none": { + "cn": "无", + "en": "None" + }, + "up": { + "cn": "上", + "en": "Up" + }, + "down": { + "cn": "下", + "en": "Down" + }, + "left": { + "cn": "左", + "en": "Left" + }, + "right": { + "cn": "右", + "en": "Right" + }, + "unknown": { + "cn": "未知方向", + "en": "Unknown direction" + } + }, + "color": { + "black": { + "cn": "黑色", + "en": "Black" + }, + "gray": { + "cn": "灰色", + "en": "Gray" + }, + "dark_red": { + "cn": "红色", + "en": "Red" + }, + "dark_goldenrod": { + "cn": "橙色", + "en": "Orange" + }, + "gold": { + "cn": "黄色", + "en": "Yellow" + }, + "spring_green": { + "cn": "绿色", + "en": "Green" + }, + "dark_cyan": { + "cn": "青色", + "en": "Cyan" + }, + "deep_sky_blue": { + "cn": "蓝色", + "en": "Blue" + }, + "purple": { + "cn": "紫色", + "en": "Purple" + }, + "pink": { + "cn": "粉色", + "en": "Pink" + } + } + }, + "rating": { + "overwhelmingly_negative": { + "cn": "差评如潮", + "en": "Overwhelmingly Negative" + }, + "very_negative": { + "cn": "特别差评", + "en": "Very Negative" + }, + "negative": { + "cn": "差评", + "en": "Negative" + }, + "mostly_negative": { + "cn": "多半差评", + "en": "Mostly Negative" + }, + "mixed": { + "cn": "褒贬不一", + "en": "Mixed" + }, + "mostly_positive": { + "cn": "多半好评", + "en": "Mostly Positive" + }, + "positive": { + "cn": "好评", + "en": "Positive" + }, + "very_positive": { + "cn": "特别好评", + "en": "Very Positive" + }, + "overwhelmingly_positive": { + "cn": "好评如潮", + "en": "Overwhelmingly Positive" + } + }, + "types": { + "action_group": { + "operator_deploy_retreat": { + "cn": "干员上/退场", + "en": "Operator Deploy/Retreat" + }, + "operator_skills": { + "cn": "干员技能", + "en": "Operator Skills" + }, + "battle_control": { + "cn": "作战控制", + "en": "Battle Control" + }, + "miscellaneous": { + "cn": "杂项", + "en": "Miscellaneous" + }, + "unknown": { + "cn": "未知", + "en": "Unknown" + } + }, + "action_type": { + "deploy": { + "title": { + "cn": "部署", + "en": "Deploy" + }, + "description": { + "cn": "部署干员至指定位置。当费用不够时,会一直等待到费用够(除非 timeout)", + "en": "Deploy operator to specified location. When DP is insufficient, will wait until enough (unless timeout)" + } + }, + "retreat": { + "title": { + "cn": "撤退", + "en": "Retreat" + }, + "description": { + "cn": "将干员从作战中撤出", + "en": "Withdraw operator from battle" + } + }, + "skill": { + "title": { + "cn": "使用技能", + "en": "Use Skill" + }, + "description": { + "cn": "当技能 CD 没转好时,一直等待到技能 CD 好(除非 timeout)", + "en": "When skill cooldown isn't ready, will wait until it is (unless timeout)" + } + }, + "skill_usage": { + "title": { + "cn": "切换技能用法", + "en": "Switch Skill Usage" + }, + "description": { + "cn": "切换干员技能用法。例如,刚下桃金娘、需要她帮忙打几个怪,但此时不能自动开技能否则会漏怪,等中后期平稳了才需要她自动开技能,则可以在对应时刻后,将桃金娘的技能用法从「不自动使用」改为「好了就用」。", + "en": "Switch operator skill usage. For example, when Perfumer is just deployed and needs to help defeat some enemies, but automatic skill activation would miss enemies, and only in mid/late game should she activate skills automatically, you can change Perfumer's skill usage from 'Don't use automatically' to 'Use when ready'." + } + }, + "speed_up": { + "title": { + "cn": "切换二倍速", + "en": "Toggle 2× Speed" + }, + "description": { + "cn": "执行后切换至二倍速,再次执行切换至一倍速", + "en": "After execution, switches to 2× speed; executing again switches to 1× speed" + } + }, + "bullet_time": { + "title": { + "cn": "进入子弹时间", + "en": "Enter Bullet Time" + }, + "description": { + "cn": "执行后将点击任意干员,进入 1/5 速度状态;再进行任意动作会恢复正常速度。下一个任务必须是\"部署\"、\"技能\"、\"撤退\"其中之一,此时会提前点开该干员,等待满足条件后再执行。", + "en": "After execution, click any operator to enter 1/5 speed state; any further action will restore normal speed. The next task must be one of 'Deploy', 'Skill', or 'Retreat', which will select that operator in advance and wait until conditions are met before executing." + } + }, + "move_camera": { + "title": { + "cn": "移动相机", + "en": "Move Camera" + }, + "description": { + "cn": "仅用于引航者试炼模式中切换区域", + "en": "Only used for switching areas in Integrated Strategies mode" + } + }, + "skill_daemon": { + "title": { + "cn": "开始挂机", + "en": "Start Auto Mode" + }, + "description": { + "cn": "进入挂机模式。仅使用 \"好了就用\" 的技能,其他什么都不做,直到战斗结束", + "en": "Enter auto mode. Only uses skills set to 'Use when ready', does nothing else until battle ends" + } + }, + "output": { + "title": { + "cn": "打印描述内容", + "en": "Print Description" + }, + "description": { + "cn": "对作战没有实际作用,仅用于输出描述内容(用来做字幕之类的)", + "en": "Has no actual effect on battle, only outputs description content (for subtitles, etc.)" + } + }, + "unknown": { + "title": { + "cn": "未知类型", + "en": "Unknown Type" + }, + "description": { + "cn": "未知动作类型", + "en": "Unknown action type" + } + } + } + } + }, + "pages": { + "_404": { + "page_not_found": { + "cn": "未找到页面", + "en": "Page Not Found" + }, + "check_url": { + "cn": "请检查您的 URL 是否正确", + "en": "Please check if your URL is correct" + }, + "return_home": { + "cn": "返回首页", + "en": "Return to Home" + } + }, + "index": { + "create_new_task": { + "cn": "创建新作业", + "en": "Create a new Job" + }, + "advertisement": { + "cn": "广告", + "en": "Ad" + } + }, + "profile": { + "invalid_id": { + "cn": "ID 无效", + "en": "Invalid ID" + }, + "tasks": { + "cn": "作业", + "en": "Jobs" + }, + "task_sets": { + "cn": "作业集", + "en": "Job Sets" + } + }, + "create": { + "publish": { + "cn": "发布", + "en": "Publish" + }, + "update": { + "cn": "更新", + "en": "Update" + }, + "task_publish_success": { + "cn": "作业发布成功", + "en": "Job published successfully" + }, + "task_update_success": { + "cn": "作业更新成功", + "en": "Job updated successfully" + }, + "task_publish_failed": { + "cn": "作业发布失败:{{error}}", + "en": "Failed to publish Job: {{error}}" + }, + "task_update_failed": { + "cn": "作业更新失败:{{error}}", + "en": "Failed to update Job: {{error}}" + }, + "negative_cost_not_supported": { + "cn": "目前暂不支持上传费用变化量为负数的动作(第{{actionIndex}}个动作)", + "en": "Currently, actions with negative DP changes are not supported (action #{{actionIndex}})" + }, + "untitled": { + "cn": "无标题", + "en": "Untitled" + }, + "public": { + "cn": "公开", + "en": "Public" + }, + "public_task_description": { + "cn": "公开作业:会在所有列表中显示", + "en": "Public Job: Will be visible in all lists" + }, + "private_task_description": { + "cn": "私有作业:其他人无法在列表中看见,但可以通过神秘代码访问", + "en": "Private Job: Others won't see it in lists, but can be accessed via secret code" + } + }, + "about": { + "slogan_line1": { + "cn": "作业站解君愁", + "en": "The Job Site Solves Your Worries" + }, + "slogan_line2": { + "cn": "点个收藏不迷路", + "en": "Bookmark us so can find your way back!" + }, + "changelog": { + "cn": "更新日志", + "en": "Changelog" + } + } + }, + "services": { + "operation": { + "json_downloaded": { + "cn": "已下载作业 JSON 文件,前往 MAA 选择即可使用~", + "en": "Job JSON file downloaded, go to MAA to use it~" + }, + "json_download_failed": { + "cn": "JSON下载失败:{{error}}", + "en": "JSON download failed: {{error}}" + }, + "json_data_error": { + "cn": "JSON 数据错误,请联系开发者", + "en": "JSON data error, please contact the developer" + }, + "shortcode_copied": { + "cn": "已复制神秘代码,前往 MAA 粘贴即可使用~", + "en": "Secret code copied, paste it in MAA to use it~" + }, + "shortcode_copy_failed": { + "cn": "复制神秘代码失败:{{error}}", + "en": "Failed to copy secret code: {{error}}" + } + } + }, + "utils": { + "error": { + "unknown_error": { + "cn": "未知错误", + "en": "Unknown error" + }, + "unauthorized": { + "cn": "未登录,请先登录", + "en": "Not logged in, please login first" + }, + "token_expired": { + "cn": "登录已过期,请重新登录", + "en": "Login has expired, please login again" + }, + "invalid_token": { + "cn": "登录失效,请重新登录", + "en": "Login is invalid, please login again" + }, + "not_found": { + "cn": "资源不存在", + "en": "Resource not found" + }, + "network_error": { + "cn": "网络错误", + "en": "Network error" + }, + "api_error": { + "cn": "请求错误", + "en": "Request error" + } + }, + "maa_copilot_client": { + "invalid_response": { + "cn": "返回值无效", + "en": "Invalid response" + }, + "server_error": { + "cn": "服务器错误", + "en": "Server error" + }, + "serialization_comment": { + "cn": "默认的序列化会把 undefined 转成字符串,我真的会谢", + "en": "Default serialization converts undefined to string, which is undesirable" + } + } + }, + "links": { + "home": { + "cn": "首页", + "en": "Home" + }, + "create_job": { + "cn": "创建作业", + "en": "Create Job" + }, + "about": { + "cn": "关于", + "en": "About" + }, + "official_site": { + "cn": "MAA 官网", + "en": "MAA Official Site" + }, + "feedback": { + "cn": "意见与反馈", + "en": "Feedback" + }, + "maa_repo": { + "cn": "MAA GitHub Repo", + "en": "MAA GitHub Repo" + }, + "frontend_repo": { + "cn": "前端 GitHub Repo", + "en": "Frontend GitHub Repo" + }, + "backend_repo": { + "cn": "后端 GitHub Repo", + "en": "Backend GitHub Repo" + }, + "creator_group": { + "cn": "作业制作者交流群:{{groupNumber}}", + "en": "Creator Community Group: {{groupNumber}}" + }, + "sharing_group": { + "cn": "作业分享群", + "en": "Job Sharing Group" + } + } +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 37c813bc..81ea399a 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -1,61 +1,64 @@ import { Button, Navbar, Tag } from '@blueprintjs/core' +import { useLinks } from 'hooks/useLinks' import { Link, NavLink } from 'react-router-dom' import { FCC } from 'types' import { AccountManager } from 'components/AccountManager' import { BackToTop } from 'components/BackToTop' +import { LanguageSwitcher } from 'components/LanguageSwitcher' import { NavExpandButton } from 'components/NavExpandButton' import { ThemeSwitchButton } from 'components/ThemeSwitchButton' import { NavAside } from 'components/drawer/NavAside' -import { NAV_LINKS } from '../links' +export const AppLayout: FCC = ({ children }) => { + const { NAV_LINKS } = useLinks() -// const darkMode = localStorage.getItem('darkMode') === 'true' + return ( +
    + + +
    + PRTS Plus +
    -export const AppLayout: FCC = ({ children }) => ( -
    - - -
    - PRTS Plus + + Beta + + + +
    + +
    + {NAV_LINKS.map((link) => ( + + {({ isActive }) => ( + + )} + + ))} +
    + +
    + +
    + + + +
    + + + +
    {children}
    - - Beta - - - -
    - -
    - {NAV_LINKS.map((link) => ( - - {({ isActive }) => ( - - )} - - ))} -
    - -
    - -
    - - - -
    - - - -
    {children}
    - - -
    -) + +
    + ) +} diff --git a/src/links.tsx b/src/links.tsx index 45e83715..d56953fd 100644 --- a/src/links.tsx +++ b/src/links.tsx @@ -1,70 +1,68 @@ -import { Icon as BlueprintIcon, IconName } from '@blueprintjs/core' +import { Icon as BlueprintIcon, Icon } from '@blueprintjs/core' import simpleIconsGitHub from '@iconify/icons-simple-icons/github' import simpleIconsQQ from '@iconify/icons-simple-icons/tencentqq' import { Icon as IconifyIcon } from '@iconify/react' -export const NAV_LINKS: { - to: string - label: string - icon: IconName -}[] = [ +import { i18n, i18nDefer } from './i18n/i18n' + +export const NAV_CONFIG = [ { to: '/', - label: '首页', - icon: 'home', + labelKey: i18nDefer.links.home, + icon: , }, { to: '/create', - label: '创建作业', - icon: 'add', + labelKey: i18nDefer.links.create_job, + icon: , }, { to: '/about', - label: '关于', - icon: 'info-sign', + labelKey: i18nDefer.links.about, + icon: , }, ] -export const SOCIAL_LINKS = [ +export const SOCIAL_CONFIG = [ { icon: , href: 'https://maa.plus', - label: 'MAA 官网', + labelKey: i18nDefer.links.official_site, }, { icon: , href: 'https://github.com/MaaAssistantArknights/maa-copilot-frontend/issues/new/choose', - label: '意见与反馈', + labelKey: i18nDefer.links.feedback, }, { icon: ( ), href: 'https://github.com/MaaAssistantArknights/MaaAssistantArknights', - label: 'MAA GitHub Repo', + labelKey: i18nDefer.links.maa_repo, }, { icon: ( ), href: 'https://github.com/MaaAssistantArknights/maa-copilot-frontend', - label: '前端 GitHub Repo', + labelKey: i18nDefer.links.frontend_repo, }, { icon: ( ), href: 'https://github.com/MaaAssistantArknights/MaaBackendCenter', - label: '后端 GitHub Repo', + labelKey: i18nDefer.links.backend_repo, }, { icon: , href: 'https://jq.qq.com/?_wv=1027&k=ElimpMzQ', - label: '作业制作者交流群:1169188429', + labelKey: () => i18n.links.creator_group({ groupNumber: '1169188429' }), }, { icon: , href: 'https://ota.maa.plus/MaaAssistantArknights/api/qqgroup/index.html', - label: '作业分享群', + labelKey: i18nDefer.links.sharing_group, }, ] diff --git a/src/models/announcement.ts b/src/models/announcement.ts index 4f433e97..a16abfbd 100644 --- a/src/models/announcement.ts +++ b/src/models/announcement.ts @@ -1,5 +1,7 @@ import { chunk, compact } from 'lodash-es' +import { i18n } from '../i18n/i18n' + export interface Announcement { sections: AnnouncementSection[] raw: string @@ -48,7 +50,9 @@ export function parseAnnouncement(raw: string): Announcement { const slices = rawSection.split(emptyLinesMatcher) const segments = compact(slices.map((s) => s.trim())) // filter out the matched empty lines - const title = segments[0]?.replace(/^#+/, '').trim() || '公告' + const title = + segments[0]?.replace(/^#+/, '').trim() || + i18n.models.announcement.default_title let meta: AnnouncementSectionMeta | undefined const jsonBlockStart = '```json' diff --git a/src/models/converter.ts b/src/models/converter.ts index a388a00f..349d802b 100644 --- a/src/models/converter.ts +++ b/src/models/converter.ts @@ -3,14 +3,7 @@ import { CopilotInfo } from 'maa-copilot-client' import type { CopilotDocV1 } from 'models/copilot.schema' -export const INVALID_OPERATION_CONTENT: CopilotDocV1.Operation = Object.freeze({ - doc: { - title: '无法解析作业内容', - }, - minimumRequired: 'v4.0.0', - actions: [], - stageName: '', -}) +import { i18n } from '../i18n/i18n' export function toCopilotOperation( apiOperation: CopilotInfo, @@ -22,5 +15,12 @@ export function toCopilotOperation( console.error('Failed to parse operation', apiOperation, e) } - return INVALID_OPERATION_CONTENT + return { + doc: { + title: i18n.models.converter.invalid_operation_content, + }, + minimumRequired: 'v4.0.0', + actions: [], + stageName: '', + } } diff --git a/src/models/operator.ts b/src/models/operator.ts index 2fa14833..0d609ba1 100644 --- a/src/models/operator.ts +++ b/src/models/operator.ts @@ -7,6 +7,7 @@ import { DetailedSelectItem, isChoice, } from '../components/editor/DetailedSelect' +import { i18n, i18nDefer } from '../i18n/i18n' import { OPERATORS, PROFESSIONS } from '../models/generated/operators.json' export { OPERATORS, PROFESSIONS } @@ -23,31 +24,32 @@ export const operatorSkillUsages: readonly DetailedSelectItem[] = [ { type: 'choice', icon: 'disable', - title: '不自动使用', + title: i18nDefer.models.operator.skill_usage.none_title, value: CopilotDocV1.SkillUsageType.None, - description: - '不由 MAA Copilot 自动开启技能、或干员技能并不需要操作开启(自动触发)。若需要手动开启技能,请添加「使用技能」动作', + description: i18nDefer.models.operator.skill_usage.none_description, }, { type: 'choice', icon: 'automatic-updates', - title: '好了就用', + title: i18nDefer.models.operator.skill_usage.ready_to_use_title, value: CopilotDocV1.SkillUsageType.ReadyToUse, - description: '有多少次用多少次,例如:棘刺 3 技能、桃金娘 1 技能等', + description: i18nDefer.models.operator.skill_usage.ready_to_use_description, }, { type: 'choice', icon: 'circle', - title: '好了就用(指定次数)', + title: i18nDefer.models.operator.skill_usage.ready_to_use_times_title, value: CopilotDocV1.SkillUsageType.ReadyToUseTimes, - description: '默认仅使用一次,例如:山 2 技能', + description: + i18nDefer.models.operator.skill_usage.ready_to_use_times_description, }, { type: 'choice', icon: 'predictive-analysis', - title: '自动判断使用时机', + title: i18nDefer.models.operator.skill_usage.automatically_title, value: CopilotDocV1.SkillUsageType.Automatically, - description: '(锐意开发中) 画饼.jpg', + description: + i18nDefer.models.operator.skill_usage.automatically_description, disabled: true, }, ] @@ -55,7 +57,7 @@ export const operatorSkillUsages: readonly DetailedSelectItem[] = [ const unknownSkillUsage: DetailedOperatorSkillUsage = { type: 'choice', icon: 'error', - title: '未知用法', + title: i18nDefer.models.operator.skill_usage.unknown_title, value: -1, description: '', } @@ -74,14 +76,20 @@ export function getSkillUsageTitle( skillTimes?: CopilotDocV1.SkillTimes, ) { if (skillUsage === CopilotDocV1.SkillUsageType.ReadyToUseTimes) { - return `好了就用(${skillTimes ? `${skillTimes}次` : '指定次数'})` + return skillTimes + ? i18n.models.operator.skill_usage.format_times({ + count: skillTimes, + times: skillTimes, + }) + : i18n.models.operator.skill_usage.format_specific_times } - return findOperatorSkillUsage(skillUsage).title + const title = findOperatorSkillUsage(skillUsage).title + return typeof title === 'function' ? title() : title } export interface OperatorDirection { icon?: IconName - title: string + title: () => string value: CopilotDocV1.Direction | null } @@ -92,34 +100,34 @@ export const operatorDirections: OperatorDirection[] = [ // TODO: remove these string literals when CopilotDocV1 can be imported { icon: 'slash', - title: '无', + title: i18nDefer.models.operator.direction.none, value: 'None' as CopilotDocV1.Direction.None, }, { icon: 'arrow-up', - title: '上', + title: i18nDefer.models.operator.direction.up, value: 'Up' as CopilotDocV1.Direction.Up, }, { icon: 'arrow-down', - title: '下', + title: i18nDefer.models.operator.direction.down, value: 'Down' as CopilotDocV1.Direction.Down, }, { icon: 'arrow-left', - title: '左', + title: i18nDefer.models.operator.direction.left, value: 'Left' as CopilotDocV1.Direction.Left, }, { icon: 'arrow-right', - title: '右', + title: i18nDefer.models.operator.direction.right, value: 'Right' as CopilotDocV1.Direction.Right, }, ] const unknownDirection: OperatorDirection = { icon: 'error', - title: '未知方向', + title: i18nDefer.models.operator.direction.unknown, value: null, } @@ -127,14 +135,12 @@ export function findOperatorDirection( value: CopilotDocV1.Direction = defaultDirection, ): OperatorDirection { return ( - operatorDirections.find( - (item) => item.value === value || item.title === value, - ) || unknownDirection + operatorDirections.find((item) => item.value === value) || unknownDirection ) } export interface ActionDocColor { - title: string + title: () => string value: string } @@ -142,43 +148,43 @@ export interface ActionDocColor { // https://github.com/MaaAssistantArknights/MaaAssistantArknights/blob/50f5f94dfcc2ec175556bbaa55d0ffec74128a8e/src/MeoAsstGui/Helper/LogColor.cs export const actionDocColors: ActionDocColor[] = [ { - title: '黑色', + title: i18nDefer.models.operator.color.black, value: 'Black', }, { - title: '灰色', + title: i18nDefer.models.operator.color.gray, value: 'Gray', }, { - title: '红色', + title: i18nDefer.models.operator.color.dark_red, value: 'DarkRed', }, { - title: '橙色', + title: i18nDefer.models.operator.color.dark_goldenrod, value: 'DarkGoldenrod', }, { - title: '黄色', + title: i18nDefer.models.operator.color.gold, value: 'Gold', }, { - title: '绿色', + title: i18nDefer.models.operator.color.spring_green, value: 'SpringGreen', }, { - title: '青色', + title: i18nDefer.models.operator.color.dark_cyan, value: 'DarkCyan', }, { - title: '蓝色', + title: i18nDefer.models.operator.color.deep_sky_blue, value: 'DeepSkyBlue', }, { - title: '紫色', + title: i18nDefer.models.operator.color.purple, value: '#6f42c1', }, { - title: '粉色', + title: i18nDefer.models.operator.color.pink, value: '#d63384', }, ] diff --git a/src/models/rating.ts b/src/models/rating.ts index 9c519aff..880087be 100644 --- a/src/models/rating.ts +++ b/src/models/rating.ts @@ -1,28 +1,30 @@ import { clamp } from 'lodash-es' -const ratingLevels = [ - '差评如潮', - '特别差评', - '差评', - '多半差评', - '褒贬不一', - '多半好评', - '好评', - '特别好评', - '好评如潮', -] +import { i18n } from '../i18n/i18n' const minRatingLevel = 0 const maxRatingLevel = 10 export function ratingLevelToString(level: number): string { + const ratingLevelKeys = [ + i18n.models.rating.overwhelmingly_negative, + i18n.models.rating.very_negative, + i18n.models.rating.negative, + i18n.models.rating.mostly_negative, + i18n.models.rating.mixed, + i18n.models.rating.mostly_positive, + i18n.models.rating.positive, + i18n.models.rating.very_positive, + i18n.models.rating.overwhelmingly_positive, + ] + const ratio = level / (maxRatingLevel - minRatingLevel) const index = clamp( - Math.floor(ratio * ratingLevels.length), + Math.floor(ratio * ratingLevelKeys.length), 0, - ratingLevels.length - 1, + ratingLevelKeys.length - 1, ) - return ratingLevels[index] + return ratingLevelKeys[index] } diff --git a/src/models/types.ts b/src/models/types.ts index e5105a32..b8ac826a 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -1,16 +1,17 @@ import { IconName } from '@blueprintjs/core' +import { i18nDefer } from '../i18n/i18n' import { CopilotDocV1 } from './copilot.schema' interface ActionType { type: 'choice' icon: IconName accent: string - title: string + title: () => string value: CopilotDocV1.Type | 'Unknown' alternativeValue: string - description: string - group: string + description: () => string + group: () => string } const accent = { @@ -30,91 +31,91 @@ export const ACTION_TYPES: ActionType[] = [ type: 'choice', icon: 'new-object', accent: accent.red, - title: '部署', + title: i18nDefer.models.types.action_type.deploy.title, value: CopilotDocV1.Type.Deploy, alternativeValue: '部署', - description: `部署干员至指定位置。当费用不够时,会一直等待到费用够(除非 timeout)`, - group: '干员上/退场', + description: i18nDefer.models.types.action_type.deploy.description, + group: i18nDefer.models.types.action_group.operator_deploy_retreat, }, { type: 'choice', icon: 'graph-remove', accent: accent.amber, - title: '撤退', + title: i18nDefer.models.types.action_type.retreat.title, value: CopilotDocV1.Type.Retreat, alternativeValue: '撤退', - description: '将干员从作战中撤出', - group: '干员上/退场', + description: i18nDefer.models.types.action_type.retreat.description, + group: i18nDefer.models.types.action_group.operator_deploy_retreat, }, { type: 'choice', icon: 'target', accent: accent.lime, - title: '使用技能', + title: i18nDefer.models.types.action_type.skill.title, value: CopilotDocV1.Type.Skill, alternativeValue: '技能', - description: `当技能 CD 没转好时,一直等待到技能 CD 好(除非 timeout)`, - group: '干员技能', + description: i18nDefer.models.types.action_type.skill.description, + group: i18nDefer.models.types.action_group.operator_skills, }, { type: 'choice', icon: 'swap-horizontal', accent: accent.emerald, - title: '切换技能用法', + title: i18nDefer.models.types.action_type.skill_usage.title, value: CopilotDocV1.Type.SkillUsage, alternativeValue: '技能用法', - description: `切换干员技能用法。例如,刚下桃金娘、需要她帮忙打几个怪,但此时不能自动开技能否则会漏怪,等中后期平稳了才需要她自动开技能,则可以在对应时刻后,将桃金娘的技能用法从「不自动使用」改为「好了就用」。`, - group: '干员技能', + description: i18nDefer.models.types.action_type.skill_usage.description, + group: i18nDefer.models.types.action_group.operator_skills, }, { type: 'choice', icon: 'fast-forward', accent: accent.cyan, - title: '切换二倍速', + title: i18nDefer.models.types.action_type.speed_up.title, value: CopilotDocV1.Type.SpeedUp, alternativeValue: '二倍速', - description: `执行后切换至二倍速,再次执行切换至一倍速`, - group: '作战控制', + description: i18nDefer.models.types.action_type.speed_up.description, + group: i18nDefer.models.types.action_group.battle_control, }, { type: 'choice', icon: 'fast-backward', accent: accent.blue, - title: '进入子弹时间', + title: i18nDefer.models.types.action_type.bullet_time.title, value: CopilotDocV1.Type.BulletTime, alternativeValue: '子弹时间', - description: `执行后将点击任意干员,进入 1/5 速度状态;再进行任意动作会恢复正常速度。下一个任务必须是“部署”、“技能”、“撤退”其中之一,此时会提前点开该干员,等待满足条件后再执行。`, - group: '作战控制', + description: i18nDefer.models.types.action_type.bullet_time.description, + group: i18nDefer.models.types.action_group.battle_control, }, { type: 'choice', icon: 'camera', accent: accent.blue, - title: '移动相机', + title: i18nDefer.models.types.action_type.move_camera.title, value: CopilotDocV1.Type.MoveCamera, alternativeValue: '移动相机', - description: `仅用于引航者试炼模式中切换区域`, - group: '作战控制', + description: i18nDefer.models.types.action_type.move_camera.description, + group: i18nDefer.models.types.action_group.battle_control, }, { type: 'choice', icon: 'antenna', accent: accent.violet, - title: '开始挂机', + title: i18nDefer.models.types.action_type.skill_daemon.title, value: CopilotDocV1.Type.SkillDaemon, alternativeValue: '摆完挂机', - description: `进入挂机模式。仅使用 “好了就用” 的技能,其他什么都不做,直到战斗结束`, - group: '作战控制', + description: i18nDefer.models.types.action_type.skill_daemon.description, + group: i18nDefer.models.types.action_group.battle_control, }, { type: 'choice', icon: 'paragraph', accent: accent.fuchsia, - title: '打印描述内容', + title: i18nDefer.models.types.action_type.output.title, value: CopilotDocV1.Type.Output, alternativeValue: '打印', - description: `对作战没有实际作用,仅用于输出描述内容(用来做字幕之类的)`, - group: '杂项', + description: i18nDefer.models.types.action_type.output.description, + group: i18nDefer.models.types.action_group.miscellaneous, }, ] @@ -128,11 +129,11 @@ const notFoundActionType: ActionType = { type: 'choice', icon: 'help', accent: accent.zinc, - title: '未知类型', + title: i18nDefer.models.types.action_type.unknown.title, value: 'Unknown', - alternativeValue: '未知', - description: `未知动作类型`, - group: '未知', + alternativeValue: '', + description: i18nDefer.models.types.action_type.unknown.description, + group: i18nDefer.models.types.action_group.unknown, } export const findActionType = (type?: string) => { diff --git a/src/pages/404.tsx b/src/pages/404.tsx index f2433c26..aaf85958 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -5,16 +5,20 @@ import { Link } from 'react-router-dom' import { withGlobalErrorBoundary } from 'components/GlobalErrorBoundary' +import { useTranslation } from '../i18n/i18n' + export const NotFoundPage: ComponentType = withGlobalErrorBoundary(() => { + const t = useTranslation() + return ( -
    diff --git a/src/services/operation.ts b/src/services/operation.ts index 24d28291..8d99b9f2 100644 --- a/src/services/operation.ts +++ b/src/services/operation.ts @@ -1,5 +1,6 @@ import { AppToaster } from 'components/Toaster' +import { i18n } from '../i18n/i18n' import { CopilotDocV1 } from '../models/copilot.schema' import { ShortCodeContent, toShortCode } from '../models/shortCode' import { formatError } from '../utils/error' @@ -31,14 +32,17 @@ export const handleDownloadJSON = (operationDoc: CopilotDocV1.Operation) => { doTriggerDownloadJSON(json, `MAACopilot_${operationDoc.doc.title}.json`) AppToaster.show({ - message: '已下载作业 JSON 文件,前往 MAA 选择即可使用~', + message: i18n.services.operation.json_downloaded, intent: 'success', }) } export const handleLazyDownloadJSON = async (id: number, title: string) => { const resp = await wrapErrorMessage( - (e) => `JSON下载失败:${formatError(e)}`, + (e) => + i18n.services.operation.json_download_failed({ + error: formatError(e), + }), new OperationApi().getCopilotById({ id: id, }), @@ -52,13 +56,13 @@ export const handleLazyDownloadJSON = async (id: number, title: string) => { ) doTriggerDownloadJSON(json, `MAACopilot_${title}.json`) AppToaster.show({ - message: '已下载作业 JSON 文件,前往 MAA 选择即可使用~', + message: i18n.services.operation.json_downloaded, intent: 'success', }) } catch (e) { console.error(e) AppToaster.show({ - message: 'JSON 数据错误,请联系开发者', + message: i18n.services.operation.json_data_error, intent: 'danger', }) } @@ -77,12 +81,14 @@ export const copyShortCode = async (target: { id: number }) => { navigator.clipboard.writeText(shortCode) AppToaster.show({ - message: '已复制神秘代码,前往 MAA 粘贴即可使用~', + message: i18n.services.operation.shortcode_copied, intent: 'success', }) } catch (e) { AppToaster.show({ - message: '复制神秘代码失败:' + formatError(e), + message: i18n.services.operation.shortcode_copy_failed({ + error: formatError(e), + }), intent: 'danger', }) } diff --git a/src/styles/blueprint.less b/src/styles/blueprint.less index fcd66371..43e704db 100644 --- a/src/styles/blueprint.less +++ b/src/styles/blueprint.less @@ -254,7 +254,6 @@ body { // navbar .bp4-navbar { - a, .bp4-tag span { @apply text-slate-50; @@ -272,7 +271,6 @@ body { } } - // 作业详情页 .bp4-card p, .bp4-card div, @@ -307,7 +305,6 @@ body { &.bp4-intent-primary { @apply bg-blue-500; } - } .bp4-callout p { @@ -315,7 +312,6 @@ body { } .bp4-label { - p, span, div { @@ -344,7 +340,6 @@ body { } .bp4-tabs { - div, span, p { diff --git a/src/utils/error.ts b/src/utils/error.ts index 30146de0..0ec6a793 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,4 +1,9 @@ -export function formatError(e: unknown, fallback = '未知错误'): string { +import { i18n } from '../i18n/i18n' + +export function formatError( + e: unknown, + fallback = i18n.utils.error.unknown_error, +): string { if (typeof e === 'string') { return e || fallback } @@ -16,25 +21,25 @@ export function formatError(e: unknown, fallback = '未知错误'): string { } export class UnauthorizedError extends Error { - message = this.message || '未登录,请先登录' + message: string = this.message || i18n.utils.error.unauthorized } export class TokenExpiredError extends Error { - message = this.message || '登录已过期,请重新登录' + message: string = this.message || i18n.utils.error.token_expired } export class InvalidTokenError extends Error { - message = this.message || '登录失效,请重新登录' + message: string = this.message || i18n.utils.error.invalid_token } export class NotFoundError extends Error { - message = this.message || '资源不存在' + message: string = this.message || i18n.utils.error.not_found } export class NetworkError extends Error { - message = this.message || '网络错误' + message: string = this.message || i18n.utils.error.network_error } export class ApiError extends Error { - message = this.message || '请求错误' + message: string = this.message || i18n.utils.error.api_error } diff --git a/src/utils/maa-copilot-client.ts b/src/utils/maa-copilot-client.ts index 1b9f0839..3644cd7c 100644 --- a/src/utils/maa-copilot-client.ts +++ b/src/utils/maa-copilot-client.ts @@ -19,6 +19,8 @@ import { } from 'utils/error' import { TokenManager } from 'utils/token-manager' +import { i18n } from '../i18n/i18n' + declare module 'maa-copilot-client' { interface Configuration { options?: ApiOptions @@ -216,7 +218,7 @@ JSONApiResponse.prototype.value = async function value() { if (validateStatusCode === 'always' || requireData) { if (!isObject(result)) { console.error('response is not an object', result) - throw new ApiError('返回值无效') + throw new ApiError(i18n.utils.maa_copilot_client.invalid_response) } } @@ -226,13 +228,17 @@ JSONApiResponse.prototype.value = async function value() { ) { if (result.statusCode !== 200) { console.error('response.statusCode is not 200', result) - throw new ApiError(result.message || '服务器错误') + throw new ApiError( + result.message || i18n.utils.maa_copilot_client.server_error, + ) } } if (requireData && (result.data === undefined || result.data === null)) { console.error('response.data is missing', result) - throw new ApiError(result.message || '返回值无效') + throw new ApiError( + result.message || i18n.utils.maa_copilot_client.invalid_response, + ) } return result diff --git a/src/utils/times.ts b/src/utils/times.ts index 8cd4c5b4..e39d9b5c 100644 --- a/src/utils/times.ts +++ b/src/utils/times.ts @@ -1,9 +1,22 @@ import dayjs from 'dayjs' +import 'dayjs/locale/en' import 'dayjs/locale/zh-cn' import relativeTime from 'dayjs/plugin/relativeTime' +import { getDefaultStore } from 'jotai' + +import { Language, languageAtom } from '../i18n/i18n' dayjs.extend(relativeTime) -dayjs.locale('zh-cn') + +function updateDayjsLocale(language: Language) { + dayjs.locale(language === 'cn' ? 'zh-cn' : 'en') +} + +updateDayjsLocale(getDefaultStore().get(languageAtom)) + +getDefaultStore().sub(languageAtom, () => { + updateDayjsLocale(getDefaultStore().get(languageAtom)) +}) export type DayjsInput = string | number | dayjs.Dayjs | Date | null | undefined diff --git a/tsconfig.json b/tsconfig.json index 0a1117af..bc4fd06b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,10 +9,7 @@ "jsx": "react-jsx", "moduleResolution": "node", "allowSyntheticDefaultImports": true, - "lib": [ - "es6", - "dom" - ], + "lib": ["es6", "dom"], "sourceMap": true, "allowJs": true, "rootDir": "./", @@ -22,26 +19,13 @@ "noImplicitAny": false, "importHelpers": true, "strictNullChecks": true, - "suppressImplicitAnyIndexErrors": true, "noUnusedLocals": true, "skipLibCheck": true, "esModuleInterop": true, "experimentalDecorators": true, - "resolveJsonModule": true, - // "paths": { - // "src/*": [ - // "*" - // ] - // } + "resolveJsonModule": true }, - // "include": [ - // "src" - // ], - "exclude": [ - "node_modules", - "build", - "public" - ], + "include": ["src"], "references": [ { "path": "./tsconfig.node.json" diff --git a/tsconfig.node.json b/tsconfig.node.json index e993792c..0d9e92bc 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,5 +4,5 @@ "module": "esnext", "moduleResolution": "node" }, - "include": ["vite.config.ts"] + "include": ["./*.ts", "scripts"] } diff --git a/vite.config.ts b/vite.config.ts index 720c229d..1c5a1cdb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react' import { defineConfig, loadEnv } from 'vite' import viteTsconfigPath from 'vite-tsconfig-paths' +import { generateTranslations } from './scripts/generate-translations' + // https://vitejs.dev/config/ export default defineConfig(({ command, mode }) => { // Load env file based on `mode` in the current working directory. @@ -10,7 +12,7 @@ export default defineConfig(({ command, mode }) => { const env = loadEnv(mode, process.cwd(), '') return { - plugins: [react(), viteTsconfigPath()], + plugins: [react(), viteTsconfigPath(), generateTranslations()], server: { port: +env.PORT || undefined, }, diff --git a/yarn.lock b/yarn.lock index 49b29c4d..6ba36df2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4162,6 +4162,11 @@ minizlib@^2.1.1: minipass "^3.0.0" yallist "^4.0.0" +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mkdirp@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"