mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(i18n): add Russian UI and harden localization
This commit is contained in:
@@ -87,3 +87,4 @@ profile.cov
|
||||
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
|
||||
frontend/messages/zh-CN.json
|
||||
frontend/messages/en.json
|
||||
frontend/messages/ru.json
|
||||
|
||||
@@ -17,6 +17,10 @@ sidebar: false
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- 新增完整俄语界面资源及服务端展示文案,支持俄语浏览器语言识别、原生语言名称、日期与数字格式、日历及数量复数形式;补齐图表标题、表单字段和无障碍按钮文案的国际化,内置错误页及离线页跟随界面语言,保留用户自定义内容。
|
||||
- 选择非默认语言时,在静态页面首次绘制前等待对应语言资源就绪,避免刷新或完整跳转时短暂显示英文。
|
||||
- 修复本地化审查发现的通知收件人误判,保留历史系统目标并正确解析同名普通用户;恢复推送模板变量与富文本标签,补齐公开错误和限流响应翻译,修正俄语技术示例、数量及术语,并在首次渲染、系统指标和日历无障碍标签中应用当前语言。界面脚本加载失败时提供独立的同语言重新加载入口,避免无限空白页。
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 将界面默认语言调整为英文,保留已有语言偏好和中文语言资源。
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
|
||||
Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。
|
||||
|
||||
前端继续使用 `next-intl`,以 `en` 为默认语言,保留 `zh-CN` 语言资源。当前阶段先整理英文基准文案并移除运行时中文硬编码,中文译文的进一步审校和统一验证安排在后续阶段。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。
|
||||
前端使用 `next-intl`,支持 `en`、`zh-CN` 和 `ru`,默认语言仍为 `en`。语言偏好按 `NEXT_LOCALE` Cookie、浏览器语言、默认语言的顺序解析,俄语区域变体(如 `ru-RU`)归一为 `ru`。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包,禁止手动修改生成的 `en.json`、`zh-CN.json` 和 `ru.json`。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。
|
||||
|
||||
语言选择器始终从各语言自己的资源读取名称:`English` 与 `中文`,不随当前界面语言翻译语言名称。
|
||||
语言选择器始终从各语言自己的资源读取名称:`English`、`中文`、`Русский`,不随当前界面语言翻译语言名称。日期、数字和日历跟随界面语言;语言切换不改变既有时区。俄语数量文案使用 ICU 的 `one`、`few`、`many` 和 `other` 分支。
|
||||
|
||||
静态导出仍预渲染英文。`<head>` 中的独立启动脚本在正文绘制前读取语言偏好;若与预渲染语言不同,则暂时隐藏正文,Provider 提交对应语言资源后再显示,避免刷新时闪现英文。脚本不依赖应用 bundle,损坏的 Cookie 会回退到浏览器语言。脚本加载出错或等待超过 10 秒时,显示同语言的独立恢复提示和重新加载按钮;应用恢复后隐藏提示并清理等待监听,避免无限空白页。格式化使用的语言在 Provider 更新上下文前同步,已有数据的首次本地化渲染也使用正确的日期和数字格式。
|
||||
|
||||
不在 React 渲染上下文中的浏览器代码(如网络异常和纯表单工具)通过 `frontend/i18n/runtime.ts` 调用同一套 `next-intl` 文案。消息在使用时解析语言,不在模块导入时固定语言。API 客户端发送当前界面语言的 `Accept-Language`。
|
||||
|
||||
## 服务端展示边界
|
||||
|
||||
`pkg/i18n/locales` 保留内置服务端文案的英文和中文版本,英文同时是运行时源码中的基准文案。展示接口依次采用 `NEXT_LOCALE` Cookie、`Accept-Language`,没有支持的语言时回退英文。
|
||||
`pkg/i18n/locales` 保留内置服务端文案的英文、中文和俄语版本,英文同时是运行时源码中的基准文案。展示接口依次采用 `NEXT_LOCALE` Cookie、`Accept-Language`,没有支持的语言时回退英文。
|
||||
|
||||
- 错误中间件先记录原始英文异常及 OTel 状态,再翻译 HTTP 响应中的 `error_msg`。登录流程使用的 `smtp_invalid:` 和 `need_email_code:` 控制前缀保持不变。
|
||||
- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。
|
||||
- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。
|
||||
- 通知接收目标只识别既有技术标识 `system`、`0` 和历史别名 `系统`;显示译文 `System`、`Система` 不会扩大保留目标集合,也不会把同名普通用户改投给管理员。
|
||||
- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。
|
||||
|
||||
格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。
|
||||
@@ -27,7 +30,9 @@ Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日
|
||||
|
||||
历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。
|
||||
|
||||
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知中英文默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。
|
||||
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知语言默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。内置源站错误页和离线页在选择模板或空值预览时使用当前语言,已保存的自定义 HTML 保持不变。
|
||||
|
||||
系统状态 API 的诊断时长和容量字符串保留英文及技术单位,界面在展示边界转换为当前语言;原始字符串继续用于既有内存计算。数据库计数采用当前 `next-intl` 上下文格式化。日历使用 DayPicker 扩展语言包,同时翻译日期与导航、下拉框、选中日期等无障碍名称。
|
||||
|
||||
## 校验与扩展
|
||||
|
||||
@@ -35,6 +40,6 @@ Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日
|
||||
|
||||
清单包含标点、表情、注释、文档、语言资源与历史迁移,不表示每个命中都需要替换。依赖、构建输出和私有环境文件不进入清单;二进制文件单独列出,不执行截图 OCR 或解包。生成的 Swagger 和中文文档站配置保留中文,既有上传分类键保留兼容性。字符集也不等同于语言,例如 Han 字符可能属于中文或日文。
|
||||
|
||||
`pnpm check:i18n` 校验中英文键一致性,并检查 Go、TypeScript/TSX 和安装脚本中的中文运行时文本;注释、语言资源、测试样例和上述六个持久化键有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。
|
||||
`pnpm check:i18n` 校验三个语言包的键、标签和 `$` 变量一致性,并检查 Go、TypeScript/TSX 和安装脚本中的汉字、日文假名、韩文及西里尔字母运行时文本;注释、语言资源、测试样例、六个上传持久化键及通知历史目标别名有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。俄语资源测试还验证命名参数、真实 rich-text 渲染、ICU 格式、复数边界、可复制的限速语法及 HTML 模板占位符,服务端测试验证格式动词、模板变量和枚举值。技术标识(如 `10r/s`、`30m`、`true`、`and`)不随语言翻译。
|
||||
|
||||
新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
|
||||
|
||||
@@ -368,6 +368,8 @@ export function IpAnalysisPanel({
|
||||
*/
|
||||
initialHours?: number;
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('accessLogs');
|
||||
const trendChartConfig = useTrendChartConfig();
|
||||
const [ipGroupOpen, setIpGroupOpen] = useState(false);
|
||||
@@ -576,32 +578,32 @@ export function IpAnalysisPanel({
|
||||
{analysis ? (
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<MiniRankCard
|
||||
title='Top Paths'
|
||||
title={uiText('ui_topPaths')}
|
||||
color='#a78bfa'
|
||||
items={toRankItems(analysis.top_paths)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Hosts'
|
||||
title={uiText('ui_topHosts')}
|
||||
color='#34d399'
|
||||
items={toRankItems(analysis.top_hosts)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Status Codes'
|
||||
title={uiText('ui_statusCodes')}
|
||||
color='#f59e0b'
|
||||
items={toRankItems(analysis.status_codes)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top User-Agents'
|
||||
title={uiText('ui_topUserAgents')}
|
||||
color='#818cf8'
|
||||
items={toRankItems(analysis.top_user_agents)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Device Types'
|
||||
title={uiText('ui_deviceTypes')}
|
||||
color='#38bdf8'
|
||||
items={toRankItems(analysis.device_types)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Browsers'
|
||||
title={uiText('ui_topBrowsers')}
|
||||
color='#22c55e'
|
||||
items={toRankItems(analysis.top_browsers)}
|
||||
/>
|
||||
|
||||
@@ -318,6 +318,8 @@ function OverviewContent({
|
||||
data: AccessLogOverview;
|
||||
hours: number;
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('accessLogs');
|
||||
const requestLabels = data.trends.requests.map((point) =>
|
||||
formatOverviewTrendLabel(point.bucket_started_at, hours),
|
||||
@@ -331,7 +333,7 @@ function OverviewContent({
|
||||
<>
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
<SparklineMetricCard
|
||||
title='Total Requests'
|
||||
title={uiText('ui_totalRequests')}
|
||||
value={formatCompactNumber(data.summary.total_requests)}
|
||||
hint={hint}
|
||||
color='#f59e0b'
|
||||
@@ -340,7 +342,7 @@ function OverviewContent({
|
||||
values={requestValues}
|
||||
/>
|
||||
<SparklineMetricCard
|
||||
title='Total Visits'
|
||||
title={uiText('ui_totalVisits')}
|
||||
value={formatCompactNumber(data.summary.total_visits)}
|
||||
hint={t('overview.uniqueVisitors', { hint })}
|
||||
color='#38bdf8'
|
||||
@@ -349,7 +351,7 @@ function OverviewContent({
|
||||
values={visitValues}
|
||||
/>
|
||||
<SparklineMetricCard
|
||||
title='Bandwidth Served'
|
||||
title={uiText('ui_bandwidthServed')}
|
||||
value={formatBytes(data.summary.bandwidth_served)}
|
||||
hint={t('overview.served', { hint })}
|
||||
color='#34d399'
|
||||
@@ -363,7 +365,7 @@ function OverviewContent({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
Requests over time
|
||||
{uiText('ui_requestsOverTime')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{t('overview.requestsHint')}
|
||||
@@ -389,13 +391,13 @@ function OverviewContent({
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<PieDistributionCard
|
||||
title='Requests by device type'
|
||||
title={uiText('ui_requestsByDevice')}
|
||||
description={t('overview.deviceDesc')}
|
||||
items={data.device_types ?? []}
|
||||
emptyMessage={t('overview.deviceEmpty')}
|
||||
/>
|
||||
<PieDistributionCard
|
||||
title='Status code'
|
||||
title={uiText('ui_statusCodes')}
|
||||
description={t('overview.statusDesc')}
|
||||
items={data.status_codes ?? []}
|
||||
emptyMessage={t('overview.statusEmpty')}
|
||||
@@ -404,17 +406,17 @@ function OverviewContent({
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top Paths'
|
||||
title={uiText('ui_topPaths')}
|
||||
description={t('overview.topPathsDesc')}
|
||||
items={toRankItems(data.top_paths)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Hosts'
|
||||
title={uiText('ui_topHosts')}
|
||||
description={t('overview.topHostsDesc')}
|
||||
items={toRankItems(data.top_hosts)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top IPs'
|
||||
title={uiText('ui_topIps')}
|
||||
description={t('overview.topIpsDesc')}
|
||||
items={toRankItems(data.top_ips)}
|
||||
/>
|
||||
@@ -422,17 +424,17 @@ function OverviewContent({
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top browsers'
|
||||
title={uiText('ui_topBrowsers')}
|
||||
description={t('overview.browsersDesc')}
|
||||
items={toRankItems(data.top_browsers)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Operating System'
|
||||
title={uiText('ui_topOperatingSystems')}
|
||||
description={t('overview.osDesc')}
|
||||
items={toRankItems(data.top_operating_systems)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top User-Agent'
|
||||
title={uiText('ui_topUserAgents')}
|
||||
description={t('overview.uaDesc')}
|
||||
items={toRankItems(data.top_user_agents)}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -28,25 +28,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import type { CacheStatus } from '@/lib/services/admin';
|
||||
import services from '@/lib/services';
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
*/
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
|
||||
};
|
||||
import { formatBytes } from '@/lib/utils/metrics';
|
||||
|
||||
interface CacheManagerProps {
|
||||
refreshTrigger: number;
|
||||
@@ -54,6 +36,7 @@ interface CacheManagerProps {
|
||||
|
||||
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const t = useTranslations('admin.database.cache');
|
||||
const format = useFormatter();
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true);
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false);
|
||||
@@ -208,7 +191,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
{t('cacheKeysCount')}
|
||||
</p>
|
||||
<p className='text-xl font-bold tracking-tight text-foreground'>
|
||||
{formatNumber(cacheStatus.keys_count)}{' '}
|
||||
{format.number(cacheStatus.keys_count)}{' '}
|
||||
<span className='text-xs text-muted-foreground font-normal'>
|
||||
{t('files')}
|
||||
</span>
|
||||
|
||||
@@ -142,7 +142,9 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground font-mono'>
|
||||
{dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
|
||||
{t('connected', {
|
||||
database: dbType === 'postgres' ? 'PostgreSQL' : 'SQLite',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { Layers, RefreshCw } from 'lucide-react';
|
||||
|
||||
@@ -33,14 +33,6 @@ import { Button } from '@/components/ui/button';
|
||||
import type { TableDataResponse } from '@/lib/services/db-manage';
|
||||
import { DbManageService } from '@/lib/services/db-manage';
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
@@ -69,6 +61,7 @@ export function TableBrowser({
|
||||
refreshTrigger,
|
||||
}: TableBrowserProps) {
|
||||
const t = useTranslations('admin.database.tableBrowser');
|
||||
const format = useFormatter();
|
||||
const [selectedTable, setSelectedTable] = useState<string>('');
|
||||
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
@@ -222,7 +215,7 @@ export function TableBrowser({
|
||||
<div>
|
||||
{t('totalRows')}:{' '}
|
||||
<span className='font-mono text-foreground font-semibold'>
|
||||
{formatNumber(tableData.total)}
|
||||
{format.number(tableData.total)}
|
||||
</span>{' '}
|
||||
{t('records')}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Activity,
|
||||
@@ -49,16 +49,9 @@ const SQLConsole = dynamic(
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
};
|
||||
|
||||
export function DatabasePageClient() {
|
||||
const t = useTranslations('admin.database');
|
||||
const format = useFormatter();
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null);
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
@@ -275,7 +268,7 @@ export function DatabasePageClient() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-sm font-bold'>
|
||||
{formatNumber(overview.table_count)}
|
||||
{format.number(overview.table_count)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -290,7 +283,7 @@ export function DatabasePageClient() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-sm font-bold'>
|
||||
{formatNumber(overview.connections)}
|
||||
{format.number(overview.connections)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -83,6 +83,8 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
export function FileList() {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('admin.files');
|
||||
const queryClient = useQueryClient();
|
||||
const [keyword, setKeyword] = React.useState('');
|
||||
@@ -565,7 +567,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
Hash (SHA-256)
|
||||
{uiText('ui_fileHash')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
|
||||
{detailTarget.hash}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-sky-500'>
|
||||
{stats.total_count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
{t('stats.countUnit')}
|
||||
{t.raw('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
@@ -260,7 +260,7 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-indigo-500'>
|
||||
+{trendSummary.count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
{t('stats.countUnit')}
|
||||
{t.raw('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
@@ -384,7 +384,7 @@ export function FileStats() {
|
||||
const formattedValue =
|
||||
trendMetric === 'size'
|
||||
? formatFileSize(Number(value))
|
||||
: `${value} ${t('stats.countUnit')}`;
|
||||
: `${value} ${t.raw('stats.countUnit')}`;
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
@@ -494,7 +494,7 @@ export function FileStats() {
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
|
||||
{value} {t('stats.countUnit')}
|
||||
{value} {t.raw('stats.countUnit')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -501,13 +501,15 @@ function ObjectFields({
|
||||
value: ObjectStorageConfig;
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void;
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('admin.files');
|
||||
return (
|
||||
<>
|
||||
{driver === 'r2' && (
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='Account ID'
|
||||
label={uiText('ui_accountId')}
|
||||
value={value.account_id || ''}
|
||||
onChange={(account_id) => onChange({ account_id })}
|
||||
/>
|
||||
@@ -516,7 +518,7 @@ function ObjectFields({
|
||||
{driver !== 's3' && (
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='Endpoint'
|
||||
label={uiText('ui_endpointLabel')}
|
||||
value={value.endpoint}
|
||||
placeholder='https://...'
|
||||
onChange={(endpoint) => onChange({ endpoint })}
|
||||
@@ -524,22 +526,22 @@ function ObjectFields({
|
||||
</div>
|
||||
)}
|
||||
<TextField
|
||||
label='Region'
|
||||
label={uiText('ui_regionLabel')}
|
||||
value={value.region}
|
||||
onChange={(region) => onChange({ region })}
|
||||
/>
|
||||
<TextField
|
||||
label='Bucket'
|
||||
label={uiText('ui_bucketLabel')}
|
||||
value={value.bucket}
|
||||
onChange={(bucket) => onChange({ bucket })}
|
||||
/>
|
||||
<TextField
|
||||
label='Access Key ID'
|
||||
label={uiText('ui_accessKeyId')}
|
||||
value={value.access_key_id}
|
||||
onChange={(access_key_id) => onChange({ access_key_id })}
|
||||
/>
|
||||
<TextField
|
||||
label='Secret Access Key'
|
||||
label={uiText('ui_secretAccessKey')}
|
||||
type='password'
|
||||
value={value.secret_access_key}
|
||||
onChange={(secret_access_key) => onChange({ secret_access_key })}
|
||||
@@ -564,7 +566,7 @@ function ObjectFields({
|
||||
>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<FieldLabel className='text-xs font-semibold cursor-pointer'>
|
||||
Path Style
|
||||
{uiText('ui_pathStyle')}
|
||||
</FieldLabel>
|
||||
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
{t('storage.pathStyleDesc')}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
@@ -14,8 +16,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { isLogStorageDisabledMessage } from './log-storage-error';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -99,6 +99,8 @@ function isClickhouseDisabledError(error: Error) {
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('admin.logs.accessLogs');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
@@ -520,7 +522,7 @@ export function AccessLogs() {
|
||||
<div className='grid gap-2 flex-1'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
Request Headers
|
||||
{uiText('ui_requestHeaders')}
|
||||
</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { locales } from '@/i18n/config';
|
||||
import { getRuntimeTranslator } from '@/i18n/runtime';
|
||||
|
||||
export function isLogStorageDisabledMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
message.includes('ClickHouse') ||
|
||||
(['en', 'zh-CN'] as const).some((locale) =>
|
||||
locales.some((locale) =>
|
||||
normalized.includes(
|
||||
getRuntimeTranslator(locale)('disabled_f95ea7f4').toLowerCase(),
|
||||
),
|
||||
|
||||
@@ -689,7 +689,7 @@ export function SettingsTab() {
|
||||
toast.success(t('loadedBarkTemplate'));
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
{rt('ui_barkApp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,8 @@ async function copyText(value: string) {
|
||||
}
|
||||
|
||||
export function OpenFlareOpsSettings() {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('openflareOps');
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<OpenFlareOpsFields>(
|
||||
@@ -346,7 +348,7 @@ export function OpenFlareOpsSettings() {
|
||||
value={geoIPMutation.data.name || '—'}
|
||||
/>
|
||||
<InfoCell
|
||||
label='ISO Code'
|
||||
label={uiText('ui_isoCode')}
|
||||
value={geoIPMutation.data.iso_code || '—'}
|
||||
/>
|
||||
</div>
|
||||
@@ -427,7 +429,7 @@ export function OpenFlareOpsSettings() {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldInput
|
||||
label='Server URL'
|
||||
label={uiText('ui_serverUrl')}
|
||||
value={fields.server_address}
|
||||
onChange={(value) => updateField('server_address', value)}
|
||||
placeholder='https://yourdomain.com'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { normalizeLocale } from '@/i18n/config';
|
||||
import { formatSystemMetric } from '@/lib/utils/system-metrics';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -34,19 +36,12 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { SystemStatus } from '@/lib/services/admin';
|
||||
import services from '@/lib/services';
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行时系统状态展示与管理组件
|
||||
*/
|
||||
export function SystemStatusManager() {
|
||||
const t = useTranslations('openflareOps.status');
|
||||
const locale = normalizeLocale(useLocale());
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [wavelet, setWavelet] = useState(false);
|
||||
@@ -164,7 +159,7 @@ export function SystemStatusManager() {
|
||||
<span
|
||||
className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ''}`}
|
||||
>
|
||||
{typeof value === 'number' ? formatNumber(value) : value}
|
||||
{formatSystemMetric(value, locale)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,8 @@ export function UserDetailSheet({
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
}: UserDetailSheetProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('admin.users');
|
||||
const displayValue = (value?: string) =>
|
||||
value && value.trim() ? value : '-';
|
||||
@@ -72,7 +74,7 @@ export function UserDetailSheet({
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
|
||||
Admin
|
||||
{uiText('ui_administrator')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,8 @@ export function LogDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LogDetailSheetProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('applyLogs');
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -75,7 +77,7 @@ export function LogDetailSheet({
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<ResultBadge result={log.result} />
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
Node: {log.node_id}
|
||||
{uiText('ui_nodeLabel')} {log.node_id}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
{t('versionBadge', { version: log.version })}
|
||||
|
||||
@@ -130,6 +130,8 @@ function statusVariant(status: TaskExecutionStatus) {
|
||||
}
|
||||
|
||||
export function SyncTasksPanel() {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('cloudflare.tasks');
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -469,7 +471,9 @@ export function SyncTasksPanel() {
|
||||
) : null}
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>Payload</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{uiText('ui_payload')}
|
||||
</div>
|
||||
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
|
||||
{selected.payload || '{}'}
|
||||
</pre>
|
||||
|
||||
@@ -67,6 +67,8 @@ function getGroupIdFromPathname(pathname: string | null): number {
|
||||
}
|
||||
|
||||
export function CloudflareGroupDetailPageClient() {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('cloudflare');
|
||||
const tCommon = useTranslations('common');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -449,7 +451,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
setSelectedMemberIDs(new Set());
|
||||
}
|
||||
}}
|
||||
aria-label='Select all'
|
||||
aria-label={uiText('ui_selectAll')}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t('columns.domain')}</TableHead>
|
||||
|
||||
@@ -64,6 +64,7 @@ const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
const t = useTranslations('nodes');
|
||||
const rt = useTranslations('runtime');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -320,7 +321,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel='Edge'
|
||||
typeLabel={rt('node_type_edge')}
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{
|
||||
|
||||
@@ -64,13 +64,14 @@ export function getFilterDescription(
|
||||
|
||||
export function NodeTypeFilter() {
|
||||
const t = useTranslations('nodes');
|
||||
const rt = useTranslations('runtime');
|
||||
const searchParams = useSearchParams();
|
||||
const activeFilter = getNodeFilter(searchParams);
|
||||
const filterLabels: Record<NodeFilter, string> = {
|
||||
all: t('filter.all'),
|
||||
edge: 'Edge',
|
||||
relay: 'Relay',
|
||||
tunnel: 'Tunnel',
|
||||
edge: rt('node_type_edge'),
|
||||
relay: rt('node_type_relay'),
|
||||
tunnel: rt('node_type_tunnel'),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getFormattingLocale } from '@/i18n/format';
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
import type {
|
||||
ApplyResult,
|
||||
NodeItem,
|
||||
@@ -112,9 +113,9 @@ export function getRelayStatusLabel(
|
||||
}
|
||||
|
||||
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
|
||||
if (nodeType === 'tunnel_relay') return 'Relay';
|
||||
if (nodeType === 'tunnel_client') return 'Tunnel';
|
||||
return 'Edge';
|
||||
if (nodeType === 'tunnel_relay') return runtimeText('node_type_relay');
|
||||
if (nodeType === 'tunnel_client') return runtimeText('node_type_tunnel');
|
||||
return runtimeText('node_type_edge');
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
|
||||
@@ -489,7 +489,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel='Relay'
|
||||
typeLabel={rt('node_type_relay')}
|
||||
typeTone='warning'
|
||||
statusBadges={[
|
||||
{
|
||||
|
||||
@@ -65,6 +65,7 @@ const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
const t = useTranslations('nodes');
|
||||
const rt = useTranslations('runtime');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -313,7 +314,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
<>
|
||||
<NodeDetailShell
|
||||
title={node.name}
|
||||
typeLabel='Tunnel'
|
||||
typeLabel={rt('node_type_tunnel')}
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{
|
||||
|
||||
@@ -92,7 +92,7 @@ function sourceLabel(
|
||||
) {
|
||||
const key = SOURCE_LABEL_KEYS[type];
|
||||
if (key) return t(key);
|
||||
return type === 'remote_url' ? 'Remote URL' : 'GitHub';
|
||||
return type === 'remote_url' ? t('history.source.remote_url') : 'GitHub';
|
||||
}
|
||||
|
||||
function deploymentSnapshot(
|
||||
@@ -230,6 +230,8 @@ export function DeploymentHistory({
|
||||
rootDir = '',
|
||||
entryFile = 'index.html',
|
||||
}: DeploymentHistoryProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -401,7 +403,7 @@ export function DeploymentHistory({
|
||||
<div className='flex flex-col gap-6'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Production</CardTitle>
|
||||
<CardTitle className='text-base'>{uiText('ui_production')}</CardTitle>
|
||||
<CardDescription>{t('history.productionHint')}</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
@@ -440,7 +442,9 @@ export function DeploymentHistory({
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>All deployments</CardTitle>
|
||||
<CardTitle className='text-base'>
|
||||
{uiText('ui_allDeployments')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('history.historyHint')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
|
||||
@@ -94,6 +94,8 @@ export function PagesSourceDialog({
|
||||
initialMode,
|
||||
onActionDispatched,
|
||||
}: PagesSourceDialogProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('pages.source');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -361,10 +363,10 @@ export function PagesSourceDialog({
|
||||
{t('manual')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='remote_url' className='w-full'>
|
||||
Remote URL
|
||||
{uiText('ui_remoteUrl')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='github_release' className='w-full'>
|
||||
GitHub Release
|
||||
{uiText('ui_githubRelease')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
@@ -379,7 +381,9 @@ export function PagesSourceDialog({
|
||||
) : mode === 'remote_url' ? (
|
||||
<>
|
||||
<Field data-invalid={Boolean(urlError)}>
|
||||
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
||||
<FieldLabel htmlFor='pages-remote-url'>
|
||||
{uiText('ui_remoteUrl')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-remote-url'
|
||||
type='url'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
@@ -44,6 +46,8 @@ export function PagesSourceGitHubFields({
|
||||
onChange,
|
||||
onErrorsChange,
|
||||
}: PagesSourceGitHubFieldsProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('pages.source');
|
||||
return (
|
||||
<>
|
||||
@@ -107,7 +111,9 @@ export function PagesSourceGitHubFields({
|
||||
|
||||
{value.releaseSelector === 'tag' ? (
|
||||
<Field data-invalid={Boolean(errors.releaseTag)}>
|
||||
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
|
||||
<FieldLabel htmlFor='pages-github-tag'>
|
||||
{uiText('ui_releaseTag')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-tag'
|
||||
placeholder='v1.2.3'
|
||||
|
||||
@@ -65,6 +65,8 @@ export function ProxySection({
|
||||
onRouteUpdate,
|
||||
onSavingChange,
|
||||
}: ProxySectionProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const reverseProxySchema = z
|
||||
.object({
|
||||
@@ -479,7 +481,7 @@ export function ProxySection({
|
||||
name='origin_host'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Origin Host Header</FormLabel>
|
||||
<FormLabel>{uiText('ui_originHostHeader')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='origin.example.internal' {...field} />
|
||||
</FormControl>
|
||||
|
||||
@@ -116,6 +116,8 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
|
||||
}
|
||||
|
||||
export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('proxyRoutes');
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
@@ -220,7 +222,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-[10px] font-semibold uppercase tracking-wider text-muted-foreground'>
|
||||
Global Rule Group
|
||||
{uiText('ui_globalRuleGroup')}
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-semibold'>
|
||||
{wafQuery.data.global_rule_group.name}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { locales } from '@/i18n/config';
|
||||
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
|
||||
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
@@ -140,7 +141,7 @@ function findMessageId(
|
||||
return [...ids]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((id) =>
|
||||
(['en', 'zh-CN'] as const).some((locale) => {
|
||||
locales.some((locale) => {
|
||||
const t = getRuntimeTranslator(locale);
|
||||
const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
|
||||
const pattern = t
|
||||
|
||||
@@ -14,9 +14,24 @@ export const UA_BROWSER_OPTIONS = [
|
||||
},
|
||||
{ value: 'Postman', label: 'Postman' },
|
||||
{ value: 'CLI', label: 'CLI' },
|
||||
{ value: 'Bot', label: 'Bot' },
|
||||
{ value: 'Unknown', label: 'Unknown' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
{
|
||||
value: 'Bot',
|
||||
get label() {
|
||||
return runtimeText('ui_botLabel');
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'Unknown',
|
||||
get label() {
|
||||
return runtimeText('ui_unknownLabel');
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'Other',
|
||||
get label() {
|
||||
return runtimeText('ui_otherLabel');
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const UA_OS_OPTIONS = [
|
||||
@@ -26,9 +41,24 @@ export const UA_OS_OPTIONS = [
|
||||
{ value: 'macOS', label: 'macOS' },
|
||||
{ value: 'Chrome OS', label: 'Chrome OS' },
|
||||
{ value: 'Linux', label: 'Linux' },
|
||||
{ value: 'Bot', label: 'Bot' },
|
||||
{ value: 'Unknown', label: 'Unknown' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
{
|
||||
value: 'Bot',
|
||||
get label() {
|
||||
return runtimeText('ui_botLabel');
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'Unknown',
|
||||
get label() {
|
||||
return runtimeText('ui_unknownLabel');
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'Other',
|
||||
get label() {
|
||||
return runtimeText('ui_otherLabel');
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const UA_BROWSER_LABELS = new Set<string>(
|
||||
|
||||
@@ -65,6 +65,8 @@ export function QuickCreateZoneDomainDialog({
|
||||
zones?: ZoneItem[];
|
||||
onCreated(domain: ZoneDomainItem): void | Promise<void>;
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const t = useTranslations('websites');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({
|
||||
>
|
||||
{!fixedZoneId ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label>Zone</Label>
|
||||
<Label>{uiText('ui_zone')}</Label>
|
||||
<Select
|
||||
value={form.watch('zone_id') || undefined}
|
||||
onValueChange={(value) => form.setValue('zone_id', value)}
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
html[data-locale-pending] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-locale-pending] body {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#locale-load-fallback[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
+24
-1
@@ -12,10 +12,12 @@ import { AppIntlProvider } from '@/components/providers/intl-provider';
|
||||
import { SiteTitleUpdater } from '@/components/providers/title-updater';
|
||||
import { RobotsMeta } from '@/components/layout/robots-meta';
|
||||
import type { AppLocale } from '@/i18n/config';
|
||||
import { createLocaleBootstrapScript } from '@/i18n/bootstrap';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
@@ -35,6 +37,7 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const locale = (await getLocale()) as AppLocale;
|
||||
const messages = await getMessages();
|
||||
const loading = await getTranslations('languageLoading');
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -42,10 +45,30 @@ export default async function RootLayout({
|
||||
className={`hide-scrollbar font-sans ${inter.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: createLocaleBootstrapScript(locale),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className='hide-scrollbar font-sans antialiased'
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<div
|
||||
id='locale-load-fallback'
|
||||
role='alert'
|
||||
hidden
|
||||
className='visible flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center'
|
||||
>
|
||||
<p data-locale-error-message suppressHydrationWarning>
|
||||
{loading('failed')}
|
||||
</p>
|
||||
<Button type='button' suppressHydrationWarning>
|
||||
{loading('reload')}
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function NotFound() {
|
||||
</h1>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<span className='text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase'>
|
||||
Page Not Found
|
||||
{rt('ui_pageNotFound')}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -347,7 +347,7 @@ export function createApiSections(rt: RuntimeTranslator): PolicySection[] {
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{rt('parameters_ba89685c')}</strong> JSON Body{' '}
|
||||
<strong>{rt('parameters_ba89685c')}</strong> {rt('ui_jsonBody')}{' '}
|
||||
<code className='bg-muted px-1.5 rounded text-xs font-mono'>
|
||||
{rt.raw('name_token_name_is_admin_false_4f3e4868')}
|
||||
</code>
|
||||
|
||||
@@ -262,7 +262,7 @@ X-Access-Token: at_628d022b7a95e26b...`}
|
||||
</p>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>OpenTelemetry Tracing:</strong>{' '}
|
||||
<strong>{rt('ui_openTelemetryTracing')}</strong>{' '}
|
||||
{rt(
|
||||
'tracing_context_is_propagated_automatically_gin_requests_0f549cce',
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,13 @@ import { locales, type AppLocale } from '@/i18n/config';
|
||||
import { switchLocale } from '@/i18n/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import enNames from '@/messages/fragments/languages.en.json';
|
||||
import ruNames from '@/messages/fragments/languages.ru.json';
|
||||
import zhCNNames from '@/messages/fragments/languages.zh-CN.json';
|
||||
|
||||
// Language names always use their own locale, regardless of the active UI.
|
||||
const nativeNames: Record<AppLocale, string> = {
|
||||
en: enNames.languageNames.en,
|
||||
ru: ruNames.languageNames.ru,
|
||||
'zh-CN': zhCNNames.languageNames['zh-CN'],
|
||||
};
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export function AuthSourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Client ID</Label>
|
||||
<Label>{rt('ui_clientId')}</Label>
|
||||
<Input
|
||||
value={form.client_id}
|
||||
onChange={(e) =>
|
||||
@@ -133,7 +133,7 @@ export function AuthSourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Client Secret</Label>
|
||||
<Label>{rt('ui_clientSecret')}</Label>
|
||||
<Input
|
||||
value={form.client_secret}
|
||||
onChange={(e) =>
|
||||
@@ -156,7 +156,7 @@ export function AuthSourceModal({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label>Scopes</Label>
|
||||
<Label>{rt('ui_scopes')}</Label>
|
||||
<Input
|
||||
value={form.scopes}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -117,7 +117,7 @@ export const FooterSection = React.memo(function FooterSection({
|
||||
</div>
|
||||
|
||||
<div className='pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground'>
|
||||
<p>© 2026 Modern Platform. All rights reserved.</p>
|
||||
<p>{rt('ui_copyright')}</p>
|
||||
<div className='flex gap-8'>
|
||||
<Link
|
||||
href='/docs/privacy-policy'
|
||||
|
||||
@@ -147,7 +147,7 @@ export const HeroSection = React.memo(function HeroSection({
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-xs text-muted-foreground mb-2'>
|
||||
Stack
|
||||
{rt('ui_technologyStack')}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<span className='inline-block px-2 py-1 bg-blue-500/10 text-blue-500 rounded text-xs font-medium'>
|
||||
@@ -161,10 +161,10 @@ export const HeroSection = React.memo(function HeroSection({
|
||||
<div className='flex justify-between items-end'>
|
||||
<div>
|
||||
<p className='text-sm text-muted-foreground mb-1'>
|
||||
Ready for
|
||||
{rt('ui_readyFor')}
|
||||
</p>
|
||||
<div className='text-lg font-bold tracking-tight text-foreground flex items-center gap-1'>
|
||||
<span>Develop</span>
|
||||
<span>{rt('ui_development')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-8 w-8 rounded-full bg-primary' />
|
||||
@@ -186,9 +186,11 @@ export const HeroSection = React.memo(function HeroSection({
|
||||
<Zap className='size-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground'>Features</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{rt('ui_features')}
|
||||
</p>
|
||||
<div className='text-sm font-bold'>
|
||||
<span>Modern</span>
|
||||
<span>{rt('ui_modern')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +125,8 @@ function parseMenuDisplayConfig(
|
||||
* @returns {React.ReactNode} 应用侧边栏组件
|
||||
*/
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { user, logout, loading: userLoading } = useUser();
|
||||
const { config } = usePublicConfig();
|
||||
@@ -440,8 +442,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarContent>
|
||||
<SidebarFooter className='mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden'>
|
||||
<div className='border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground'>
|
||||
<div>Version {APP_VERSION}</div>
|
||||
{APP_BUILD_DATE && <div>Build At {APP_BUILD_DATE}</div>}
|
||||
<div>
|
||||
{uiText('ui_versionLabel')} {APP_VERSION}
|
||||
</div>
|
||||
{APP_BUILD_DATE && (
|
||||
<div>
|
||||
{uiText('ui_builtAt')} {APP_BUILD_DATE}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { formatDateTime, formatNumber } from '@/i18n/format';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import enMessages from '@/messages/en.json';
|
||||
import zhCNMessages from '@/messages/zh-CN.json';
|
||||
import ruMessages from '@/messages/ru.json';
|
||||
|
||||
import { AppIntlProvider } from './intl-provider';
|
||||
import { createLocaleBootstrapScript } from '@/i18n/bootstrap';
|
||||
|
||||
function Probe() {
|
||||
const t = useTranslations('common');
|
||||
return <button>{t('save')}</button>;
|
||||
}
|
||||
|
||||
function FormattingProbe() {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<span data-testid='formats'>
|
||||
{locale}|{formatNumber(1234.5)}|{formatDateTime('2026-01-15T00:00:00Z')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const zhSave = (zhCNMessages.common as Record<string, string>).save;
|
||||
const enSave = (enMessages.common as Record<string, string>).save;
|
||||
|
||||
@@ -31,6 +43,7 @@ describe('AppIntlProvider (static export locale resolution)', () => {
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
|
||||
document.documentElement.removeAttribute('data-locale-pending');
|
||||
});
|
||||
|
||||
it('flips to the NEXT_LOCALE cookie locale after mount when SSR locale is stale', async () => {
|
||||
@@ -61,6 +74,51 @@ describe('AppIntlProvider (static export locale resolution)', () => {
|
||||
expect(document.documentElement.lang).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('applies Russian cookies before showing the hydrated English page', () => {
|
||||
document.cookie = 'NEXT_LOCALE=ru; Path=/';
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
|
||||
true,
|
||||
);
|
||||
render(
|
||||
<AppIntlProvider locale='en' messages={enMessages}>
|
||||
<Probe />
|
||||
</AppIntlProvider>,
|
||||
);
|
||||
expect(screen.getByText(ruMessages.common.save)).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe('ru');
|
||||
expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes Russian browser variants when there is no preference', () => {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['ru-RU', 'en-US'],
|
||||
configurable: true,
|
||||
});
|
||||
render(
|
||||
<AppIntlProvider locale='en' messages={enMessages}>
|
||||
<Probe />
|
||||
</AppIntlProvider>,
|
||||
);
|
||||
expect(screen.getByText(ruMessages.common.save)).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe('ru');
|
||||
});
|
||||
|
||||
it('formats existing data in Russian on the first localized render', () => {
|
||||
document.documentElement.lang = 'en';
|
||||
document.cookie = 'NEXT_LOCALE=ru; Path=/';
|
||||
render(
|
||||
<AppIntlProvider locale='en' messages={enMessages}>
|
||||
<FormattingProbe />
|
||||
</AppIntlProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('formats').textContent).toBe(
|
||||
'ru|1\u00a0234,5|15.01.2026, 08:00',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not flip without a cookie preference', () => {
|
||||
render(
|
||||
<AppIntlProvider locale='zh-CN' messages={zhCNMessages}>
|
||||
|
||||
@@ -7,11 +7,13 @@ import type { ReactNode } from 'react';
|
||||
import { resolveClientLocale } from '@/i18n/client';
|
||||
import type { AppLocale } from '@/i18n/config';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import ruMessages from '@/messages/ru.json';
|
||||
import zhCNMessages from '@/messages/zh-CN.json';
|
||||
|
||||
const messagesByLocale: Record<AppLocale, AbstractIntlMessages> = {
|
||||
'zh-CN': zhCNMessages,
|
||||
en: enMessages,
|
||||
ru: ruMessages,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -32,14 +34,25 @@ export function AppIntlProvider({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const resolved = resolveClientLocale();
|
||||
// Formatting utilities run during the context update's render. Publish
|
||||
// its locale first, while the prepaint gate still hides stale markup.
|
||||
document.documentElement.lang = resolved;
|
||||
if (resolved !== ssrLocale) {
|
||||
setClientLocale(resolved);
|
||||
}
|
||||
document.documentElement.lang = resolved;
|
||||
}, [ssrLocale]);
|
||||
|
||||
const locale = clientLocale ?? ssrLocale;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (locale === resolveClientLocale()) {
|
||||
document.documentElement.lang = locale;
|
||||
const fallback = document.getElementById('locale-load-fallback');
|
||||
if (fallback) fallback.hidden = true;
|
||||
document.documentElement.removeAttribute('data-locale-pending');
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider
|
||||
locale={locale}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
@@ -5,7 +8,15 @@ import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={uiText('ui_breadcrumb')}
|
||||
data-slot='breadcrumb'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
@@ -84,6 +95,8 @@ function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot='breadcrumb-ellipsis'
|
||||
@@ -93,7 +106,7 @@ function BreadcrumbEllipsis({
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className='size-4' />
|
||||
<span className='sr-only'>More</span>
|
||||
<span className='sr-only'>{uiText('ui_more')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Calendar } from './calendar';
|
||||
|
||||
describe('Calendar accessibility translations', () => {
|
||||
it('localizes Russian navigation, dropdowns and selected/today labels', () => {
|
||||
const date = new Date(2026, 0, 15);
|
||||
render(
|
||||
<NextIntlClientProvider locale='ru' messages={{}}>
|
||||
<Calendar
|
||||
mode='single'
|
||||
defaultMonth={date}
|
||||
today={date}
|
||||
selected={date}
|
||||
captionLayout='dropdown'
|
||||
/>
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Перейти к предыдущему месяцу' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Перейти к следующему месяцу' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'Выберите месяц' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'Выберите год' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Сегодня.*выбрано/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('localizes Chinese navigation too', () => {
|
||||
render(
|
||||
<NextIntlClientProvider locale='zh-CN' messages={{}}>
|
||||
<Calendar defaultMonth={new Date(2026, 0, 1)} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '前往上个月' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '前往下个月' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { enUS, zhCN } from 'date-fns/locale';
|
||||
import { enUS, ru, zhCN } from 'react-day-picker/locale';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
@@ -27,7 +27,9 @@ function Calendar({
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const uiLocale = useLocale();
|
||||
const locale = localeOverride ?? (uiLocale === 'zh-CN' ? zhCN : enUS);
|
||||
const locale =
|
||||
localeOverride ??
|
||||
(uiLocale === 'zh-CN' ? zhCN : uiLocale === 'ru' ? ru : enUS);
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import * as React from 'react';
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
@@ -177,6 +179,8 @@ function CarouselPrevious({
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
@@ -196,7 +200,7 @@ function CarouselPrevious({
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className='sr-only'>Previous slide</span>
|
||||
<span className='sr-only'>{uiText('ui_previousSlide')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -207,6 +211,8 @@ function CarouselNext({
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
@@ -226,7 +232,7 @@ function CarouselNext({
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className='sr-only'>Next slide</span>
|
||||
<span className='sr-only'>{uiText('ui_nextSlide')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
@@ -31,8 +32,8 @@ function Command({
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
@@ -45,11 +46,14 @@ function CommandDialog({
|
||||
showCloseButton?: boolean;
|
||||
shouldFilter?: boolean;
|
||||
}) {
|
||||
const t = useTranslations('runtime');
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className='sr-only'>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogTitle>{title ?? t('ui_commandPalette')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description ?? t('ui_commandSearch')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn('overflow-hidden p-0 shadow-2xl', className)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
@@ -54,6 +56,8 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
// 统一小屏样式:全宽留白、适度圆角、滚动保护
|
||||
const mobileBase = 'w-full max-w-[calc(100%-1.5rem)]';
|
||||
const desktopBase = 'sm:max-w-lg md:max-w-xl';
|
||||
@@ -75,7 +79,7 @@ function DialogContent({
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className='sr-only'>Close</span>
|
||||
<span className='sr-only'>{uiText('ui_closeLabel')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { CropIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import {
|
||||
@@ -235,6 +237,8 @@ export const ImageCropContent = ({
|
||||
style,
|
||||
className,
|
||||
}: ImageCropContentProps) => {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const {
|
||||
imgSrc,
|
||||
crop,
|
||||
@@ -262,7 +266,7 @@ export const ImageCropContent = ({
|
||||
{imgSrc && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt='crop'
|
||||
alt={uiText('ui_imageCrop')}
|
||||
className='size-full'
|
||||
height={400}
|
||||
onLoad={onImageLoad}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
@@ -9,10 +12,12 @@ import { cn } from '@/lib/utils';
|
||||
import { buttonVariants, type Button } from '@/components/ui/button';
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<nav
|
||||
role='navigation'
|
||||
aria-label='pagination'
|
||||
aria-label={uiText('ui_pagination')}
|
||||
data-slot='pagination'
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
@@ -69,15 +74,17 @@ function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label='Go to previous page'
|
||||
aria-label={uiText('ui_previousPage')}
|
||||
size='default'
|
||||
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className='hidden sm:block'>Previous</span>
|
||||
<span className='hidden sm:block'>{uiText('ui_previousLabel')}</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
@@ -86,14 +93,16 @@ function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label='Go to next page'
|
||||
aria-label={uiText('ui_nextPage')}
|
||||
size='default'
|
||||
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className='hidden sm:block'>Next</span>
|
||||
<span className='hidden sm:block'>{uiText('ui_nextLabel')}</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
@@ -103,6 +112,8 @@ function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -111,7 +122,7 @@ function PaginationEllipsis({
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className='size-4' />
|
||||
<span className='sr-only'>More pages</span>
|
||||
<span className='sr-only'>{uiText('ui_morePages')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
@@ -52,6 +54,8 @@ function SheetContent({
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -74,7 +78,7 @@ function SheetContent({
|
||||
{children}
|
||||
<SheetPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none'>
|
||||
<XIcon className='size-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
<span className='sr-only'>{uiText('ui_closeLabel')}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
@@ -164,6 +166,8 @@ function Sidebar({
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
@@ -197,8 +201,10 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>{uiText('ui_sidebarLabel')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{uiText('ui_mobileSidebarDescription')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex h-full w-full flex-col'>{children}</div>
|
||||
</SheetContent>
|
||||
@@ -261,6 +267,8 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
@@ -277,22 +285,24 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className='sr-only'>Toggle Sidebar</span>
|
||||
<span className='sr-only'>{uiText('ui_toggleSidebar')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar='rail'
|
||||
data-slot='sidebar-rail'
|
||||
aria-label='Toggle Sidebar'
|
||||
aria-label={uiText('ui_toggleSidebar')}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title='Toggle Sidebar'
|
||||
title={uiText('ui_toggleSidebar')}
|
||||
className={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Toaster as Sonner, type ToasterProps, useSonner } from 'sonner';
|
||||
import { useBellRing } from '@/contexts/bell-ring-context';
|
||||
|
||||
@@ -34,6 +35,7 @@ function ToastObserver() {
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
const t = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,6 +43,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className='toaster group'
|
||||
containerAriaLabel={t('ui_toastRegion')}
|
||||
toastOptions={{ closeButtonAriaLabel: t('ui_closeToast') }}
|
||||
icons={{
|
||||
success: <CircleCheckIcon className='size-4' />,
|
||||
info: <InfoIcon className='size-4' />,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
const uiText = useTranslations('runtime');
|
||||
|
||||
return (
|
||||
<Loader2Icon
|
||||
role='status'
|
||||
aria-label='Loading'
|
||||
aria-label={uiText('ui_loadingLabel')}
|
||||
className={cn('size-4 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import english from '@/messages/fragments/languages.en.json';
|
||||
import russian from '@/messages/fragments/languages.ru.json';
|
||||
import { createLocaleBootstrapScript } from './bootstrap';
|
||||
import { resolveClientLocale } from './client';
|
||||
|
||||
afterEach(async () => {
|
||||
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
|
||||
document.documentElement.removeAttribute('data-locale-pending');
|
||||
document.getElementById('locale-load-fallback')?.remove();
|
||||
await Promise.resolve();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('static locale first paint', () => {
|
||||
function setupFallback() {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.id = 'locale-load-fallback';
|
||||
fallback.hidden = true;
|
||||
const message = document.createElement('p');
|
||||
message.setAttribute('data-locale-error-message', '');
|
||||
message.textContent = english.languageLoading.failed;
|
||||
const retry = document.createElement('button');
|
||||
retry.textContent = english.languageLoading.reload;
|
||||
fallback.append(message, retry);
|
||||
document.body.append(fallback);
|
||||
document.cookie = 'NEXT_LOCALE=ru; Path=/';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
it('shows an independent Russian recovery action if hydration never completes', () => {
|
||||
vi.useFakeTimers();
|
||||
const fallback = setupFallback();
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(fallback.hidden).toBe(false);
|
||||
expect(fallback.textContent).toContain(russian.languageLoading.failed);
|
||||
expect(fallback.querySelector('button')?.textContent).toBe(
|
||||
russian.languageLoading.reload,
|
||||
);
|
||||
expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows recovery immediately when an application script fails', () => {
|
||||
vi.useFakeTimers();
|
||||
const fallback = setupFallback();
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
const script = document.createElement('script');
|
||||
document.body.append(script);
|
||||
script.dispatchEvent(new Event('error'));
|
||||
script.remove();
|
||||
expect(fallback.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the deadline when the provider reveals the localized page', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fallback = setupFallback();
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
document.documentElement.removeAttribute('data-locale-pending');
|
||||
await Promise.resolve();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(fallback.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('waits for the body when a script fails during HTML parsing', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
|
||||
document.cookie = 'NEXT_LOCALE=ru; Path=/';
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
const script = document.createElement('script');
|
||||
document.head.append(script);
|
||||
script.dispatchEvent(new Event('error'));
|
||||
script.remove();
|
||||
expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
|
||||
true,
|
||||
);
|
||||
const fallback = setupFallback();
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
expect(fallback.hidden).toBe(false);
|
||||
expect(fallback.textContent).toContain(russian.languageLoading.failed);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ru', ['en-US'], 'ru', true],
|
||||
['en', ['ru-RU'], 'en', false],
|
||||
['', ['de-DE', 'ru-RU'], 'ru', true],
|
||||
['', ['de-DE', 'en-US'], 'en', false],
|
||||
['zh-CN', ['en-US'], 'zh-CN', true],
|
||||
['unsupported', ['ru-RU'], 'en', false],
|
||||
['%broken', ['ru-RU'], 'ru', true],
|
||||
] as const)(
|
||||
'matches provider resolution for cookie %s and browser %s',
|
||||
(cookie, languages, locale, pending) => {
|
||||
document.cookie = `NEXT_LOCALE=${cookie}; Path=/`;
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: languages,
|
||||
configurable: true,
|
||||
});
|
||||
new Function(createLocaleBootstrapScript('en'))();
|
||||
expect(resolveClientLocale()).toBe(locale);
|
||||
expect(document.documentElement.hasAttribute('data-locale-pending')).toBe(
|
||||
pending,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
defaultLocale,
|
||||
localeCookieName,
|
||||
locales,
|
||||
type AppLocale,
|
||||
} from './config';
|
||||
import english from '@/messages/fragments/languages.en.json';
|
||||
import chinese from '@/messages/fragments/languages.zh-CN.json';
|
||||
import russian from '@/messages/fragments/languages.ru.json';
|
||||
|
||||
// Runs in <head>, before static English markup can paint. Keep this script
|
||||
// self-contained: it must not depend on a loaded application bundle.
|
||||
export function createLocaleBootstrapScript(serverLocale: AppLocale): string {
|
||||
const settings = JSON.stringify({
|
||||
serverLocale,
|
||||
defaultLocale,
|
||||
localeCookieName,
|
||||
locales,
|
||||
fallbacks: {
|
||||
en: english.languageLoading,
|
||||
'zh-CN': chinese.languageLoading,
|
||||
ru: russian.languageLoading,
|
||||
},
|
||||
}).replaceAll('<', '\\u003c');
|
||||
return String.raw`(() => {
|
||||
const settings = ${settings};
|
||||
const normalize = (input) => {
|
||||
const family = input.trim().replaceAll('_', '-').toLowerCase().split('-')[0];
|
||||
return settings.locales.find((locale) => locale.toLowerCase().split('-')[0] === family);
|
||||
};
|
||||
const cookie = document.cookie.split(';').map((part) => part.trim()).find((part) => part.startsWith(settings.localeCookieName + '='));
|
||||
let preferred;
|
||||
if (cookie) {
|
||||
try {
|
||||
const value = decodeURIComponent(cookie.slice(settings.localeCookieName.length + 1));
|
||||
if (value) preferred = normalize(value) || settings.defaultLocale;
|
||||
} catch { /* Invalid cookie: use browser preference, as the provider does. */ }
|
||||
}
|
||||
if (!preferred) {
|
||||
for (const language of navigator.languages?.length ? navigator.languages : [navigator.language]) {
|
||||
preferred = normalize(language || '');
|
||||
if (preferred) break;
|
||||
}
|
||||
}
|
||||
if ((preferred || settings.defaultLocale) !== settings.serverLocale) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-locale-pending', '');
|
||||
const showFailure = () => {
|
||||
if (!root.hasAttribute('data-locale-pending')) return;
|
||||
const fallback = document.getElementById('locale-load-fallback');
|
||||
if (!fallback) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', showFailure, {once: true});
|
||||
} else {
|
||||
root.removeAttribute('data-locale-pending');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!fallback.hidden) return;
|
||||
const copy = settings.fallbacks[preferred || settings.defaultLocale];
|
||||
const message = fallback.querySelector('[data-locale-error-message]');
|
||||
const retry = fallback.querySelector('button');
|
||||
if (message) message.textContent = copy.failed;
|
||||
if (retry) {
|
||||
retry.textContent = copy.reload;
|
||||
retry.addEventListener('click', () => window.location.reload(), {once: true});
|
||||
}
|
||||
fallback.hidden = false;
|
||||
};
|
||||
const timeout = setTimeout(showFailure, 10000);
|
||||
const onError = (event) => {
|
||||
if (event.target?.tagName === 'SCRIPT') showFailure();
|
||||
};
|
||||
window.addEventListener('error', onError, true);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!root.hasAttribute('data-locale-pending')) {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener('error', onError, true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(root, {attributes: true, attributeFilter: ['data-locale-pending']});
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import english from '@/messages/fragments/builtin-metadata.en.json';
|
||||
import chinese from '@/messages/fragments/builtin-metadata.zh-CN.json';
|
||||
import russian from '@/messages/fragments/builtin-metadata.ru.json';
|
||||
import {
|
||||
localizeConfigDescription,
|
||||
localizeTemplateMetadata,
|
||||
@@ -23,6 +24,16 @@ describe('built-in metadata presentation', () => {
|
||||
chinese.builtinMetadata.configDescriptions.site_name,
|
||||
);
|
||||
expect(original.value).toBe('My site');
|
||||
expect(
|
||||
localizeConfigDescription(original.key, original.description, 'ru'),
|
||||
).toBe(russian.builtinMetadata.configDescriptions.site_name);
|
||||
expect(
|
||||
localizeConfigDescription(
|
||||
original.key,
|
||||
russian.builtinMetadata.configDescriptions.site_name,
|
||||
'en',
|
||||
),
|
||||
).toBe(english.builtinMetadata.configDescriptions.site_name);
|
||||
});
|
||||
|
||||
it('preserves custom descriptions, edited values and unknown configuration keys', () => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import english from '@/messages/fragments/builtin-metadata.en.json';
|
||||
import chinese from '@/messages/fragments/builtin-metadata.zh-CN.json';
|
||||
import russian from '@/messages/fragments/builtin-metadata.ru.json';
|
||||
|
||||
function localizedDefault(
|
||||
value: string,
|
||||
en: string | undefined,
|
||||
zh: string | undefined,
|
||||
ru: string | undefined,
|
||||
locale: string,
|
||||
): string {
|
||||
// Only known defaults are translated. Custom and unknown text stays intact.
|
||||
if (!en || !zh || (value !== en && value !== zh)) return value;
|
||||
return locale === 'zh-CN' ? zh : en;
|
||||
if (!en || !zh || !ru || ![en, zh, ru].includes(value)) return value;
|
||||
return locale === 'zh-CN' ? zh : locale === 'ru' ? ru : en;
|
||||
}
|
||||
|
||||
export function localizeConfigDescription(
|
||||
@@ -25,7 +27,8 @@ export function localizeConfigDescription(
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return localizedDefault(value, en[key], zh[key], locale);
|
||||
const ru = russian.builtinMetadata.configDescriptions as typeof en;
|
||||
return localizedDefault(value, en[key], zh[key], ru[key], locale);
|
||||
}
|
||||
|
||||
export function localizeTemplateMetadata(
|
||||
@@ -41,5 +44,12 @@ export function localizeTemplateMetadata(
|
||||
{ name: string; description: string }
|
||||
>;
|
||||
const zh = chinese.builtinMetadata.templates as typeof en;
|
||||
return localizedDefault(value, en[key]?.[field], zh[key]?.[field], locale);
|
||||
const ru = russian.builtinMetadata.templates as typeof en;
|
||||
return localizedDefault(
|
||||
value,
|
||||
en[key]?.[field],
|
||||
zh[key]?.[field],
|
||||
ru[key]?.[field],
|
||||
locale,
|
||||
);
|
||||
}
|
||||
|
||||
+11
-2
@@ -17,7 +17,11 @@ function readCookie(name: string): string | null {
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${name}=`));
|
||||
if (!match) return null;
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
try {
|
||||
return decodeURIComponent(match.slice(name.length + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getClientLocalePreference(): AppLocale | null {
|
||||
@@ -35,7 +39,12 @@ export function detectBrowserLocale(): AppLocale {
|
||||
: [navigator.language];
|
||||
for (const lang of languages) {
|
||||
const lower = (lang ?? '').toLowerCase();
|
||||
if (lower.startsWith('zh') || lower.startsWith('en')) {
|
||||
if (
|
||||
lower.startsWith('zh') ||
|
||||
lower.startsWith('en') ||
|
||||
lower === 'ru' ||
|
||||
lower.startsWith('ru-')
|
||||
) {
|
||||
return normalizeLocale(lang);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -1,4 +1,4 @@
|
||||
export const locales = ['en', 'zh-CN'] as const;
|
||||
export const locales = ['en', 'zh-CN', 'ru'] as const;
|
||||
|
||||
export type AppLocale = (typeof locales)[number];
|
||||
|
||||
@@ -9,7 +9,7 @@ export const localeCookieName = 'NEXT_LOCALE';
|
||||
export function isAppLocale(
|
||||
value: string | undefined | null,
|
||||
): value is AppLocale {
|
||||
return value === 'zh-CN' || value === 'en';
|
||||
return value === 'zh-CN' || value === 'en' || value === 'ru';
|
||||
}
|
||||
|
||||
/** Normalize BCP 47 / browser tags into a supported app locale. */
|
||||
@@ -29,6 +29,9 @@ export function normalizeLocale(input?: string | null): AppLocale {
|
||||
if (lower === 'en' || lower.startsWith('en-')) {
|
||||
return 'en';
|
||||
}
|
||||
if (lower === 'ru' || lower.startsWith('ru-')) {
|
||||
return 'ru';
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
@@ -46,14 +49,19 @@ export function pickLocaleFromAcceptLanguage(
|
||||
const q = qParam ? Number.parseFloat(qParam.split('=')[1] ?? '1') : 1;
|
||||
return { tag: tag?.trim() ?? '', q: Number.isFinite(q) ? q : 0 };
|
||||
})
|
||||
.filter((c) => c.tag)
|
||||
.filter((c) => c.tag && c.q > 0)
|
||||
.sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeLocale(candidate.tag);
|
||||
// Only accept if the raw tag roughly matches a supported family
|
||||
const lower = candidate.tag.toLowerCase();
|
||||
if (lower.startsWith('zh') || lower.startsWith('en')) {
|
||||
if (
|
||||
lower.startsWith('zh') ||
|
||||
lower.startsWith('en') ||
|
||||
lower === 'ru' ||
|
||||
lower.startsWith('ru-')
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { formatDateTime, getFormattingLocale } from './format';
|
||||
import { formatDateTime, formatNumber, getFormattingLocale } from './format';
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.lang = '';
|
||||
@@ -12,6 +12,11 @@ describe('locale-aware formatting', () => {
|
||||
expect(formatDateTime('2026-01-15T00:00:00Z')).toContain('01/15/2026');
|
||||
document.documentElement.lang = 'zh-CN';
|
||||
expect(formatDateTime('2026-01-15T00:00:00Z')).toContain('2026/01/15');
|
||||
document.documentElement.lang = 'ru';
|
||||
expect(formatDateTime('2026-01-15T00:00:00Z')).toContain('15.01.2026');
|
||||
expect(formatNumber(1234.5)).toBe(
|
||||
new Intl.NumberFormat('ru-RU').format(1234.5),
|
||||
);
|
||||
expect(formatDateTime('自定义文本')).toBe('自定义文本');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export function getFormattingLocale(): AppLocale {
|
||||
const localeToIntl: Record<AppLocale, string> = {
|
||||
'zh-CN': 'zh-CN',
|
||||
en: 'en-US',
|
||||
ru: 'ru-RU',
|
||||
};
|
||||
|
||||
export function formatDateTime(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { messageContractTokens } from '../scripts/message-contract.mjs';
|
||||
import english from '@/messages/en.json';
|
||||
import chinese from '@/messages/zh-CN.json';
|
||||
import russian from '@/messages/ru.json';
|
||||
|
||||
describe('translation rendering contracts', () => {
|
||||
it('distinguishes translated renderer identifiers from translated prose', () => {
|
||||
const source = '$title <websites>Websites</websites> $content';
|
||||
expect(
|
||||
messageContractTokens('$title <websites>Сайты</websites> $content'),
|
||||
).toEqual(messageContractTokens(source));
|
||||
expect(
|
||||
messageContractTokens('$заголовок <websites>Сайты</websites> $content'),
|
||||
).not.toEqual(messageContractTokens(source));
|
||||
expect(
|
||||
messageContractTokens('$title <веб-сайты>Сайты</веб-сайты> $content'),
|
||||
).not.toEqual(messageContractTokens(source));
|
||||
});
|
||||
|
||||
it('keeps examples accepted by rate and duration validators copyable', () => {
|
||||
const contracts = {
|
||||
'proxyRoutes.rateLimitPlaceholder': ['512k', '1m'],
|
||||
'proxyRoutes.rateLimitHint': ['512k', '1m'],
|
||||
'proxyRoutes.reqLimitPlaceholder': ['10r/s', '100r/m'],
|
||||
'proxyRoutes.reqLimitHint': ['10r/s', '100r/m'],
|
||||
'proxyRoutes.validation.invalidLimitReq': ['10r/s', '100r/m'],
|
||||
'rateLimits.config.limitRateHint': ['512k', '1m'],
|
||||
'rateLimits.config.reqPerIpHint': ['10r/s', '100r/m'],
|
||||
'rateLimits.config.invalidReq': ['10r/s', '100r/m'],
|
||||
'builtinMetadata.configDescriptions.openresty_default_limit_req_per_ip': [
|
||||
'10r/s',
|
||||
'100r/m',
|
||||
],
|
||||
};
|
||||
for (const [locale, messages] of Object.entries({
|
||||
en: english,
|
||||
'zh-CN': chinese,
|
||||
ru: russian,
|
||||
})) {
|
||||
for (const [key, tokens] of Object.entries(contracts)) {
|
||||
let value: unknown = messages;
|
||||
for (const part of key.split('.'))
|
||||
value =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)[part]
|
||||
: undefined;
|
||||
for (const token of tokens)
|
||||
expect(value, `${locale}:${key}`).toContain(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
let proxy: typeof import('../proxy').proxy;
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv('WAVELET_RATE_LIMIT_ENABLED', 'true');
|
||||
({ proxy } = await import('../proxy'));
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('localized proxy rate limits', () => {
|
||||
it.each([
|
||||
['ru', 'en', 'Слишком много запросов. Повторите через 5 секунд.'],
|
||||
['en', 'ru', 'Too many requests; retry in 5 seconds'],
|
||||
[
|
||||
'',
|
||||
'de,ru-RU;q=0.9,en;q=0.8',
|
||||
'Слишком много запросов. Повторите через 5 секунд.',
|
||||
],
|
||||
])(
|
||||
'prefers cookie %s over Accept-Language %s',
|
||||
async (cookie, language, message) => {
|
||||
const request = new NextRequest('http://localhost/api/v1/oauth/login', {
|
||||
headers: {
|
||||
cookie: `NEXT_LOCALE=${cookie}`,
|
||||
'accept-language': language,
|
||||
'x-real-ip': `review-${cookie}-${language}`,
|
||||
},
|
||||
});
|
||||
expect(proxy(request).status).toBe(200);
|
||||
const limited = proxy(request);
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.headers.get('retry-after')).toBe('5');
|
||||
expect(await limited.json()).toEqual({
|
||||
error_code: 'RATE_LIMITED',
|
||||
error_msg: message,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createTranslator } from 'next-intl';
|
||||
import english from '@/messages/fragments/runtime.en.json';
|
||||
import chinese from '@/messages/fragments/runtime.zh-CN.json';
|
||||
import russian from '@/messages/fragments/runtime.ru.json';
|
||||
import type { AppLocale } from './config';
|
||||
|
||||
export type RuntimeKey = keyof typeof english.runtime;
|
||||
|
||||
const translators = {
|
||||
en: createTranslator({
|
||||
locale: 'en',
|
||||
messages: english,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
'zh-CN': createTranslator({
|
||||
locale: 'zh-CN',
|
||||
messages: chinese,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
ru: createTranslator({
|
||||
locale: 'ru',
|
||||
messages: russian,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
};
|
||||
|
||||
export function getRuntimeTranslator(locale: AppLocale) {
|
||||
return translators[locale];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { isLogStorageDisabledMessage } from '@/app/(main)/admin/logs/components/
|
||||
import { searchItems } from '@/lib/utils/search-data';
|
||||
import english from '@/messages/en.json';
|
||||
import chinese from '@/messages/zh-CN.json';
|
||||
import { locales } from './config';
|
||||
import { getRuntimeTranslator } from './runtime';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -78,6 +79,7 @@ describe('imperative runtime translations', () => {
|
||||
}
|
||||
for (const message of [
|
||||
'Edge edge-1 has invalid source',
|
||||
'Ребро edge-1 ссылается на отсутствующий исходный узел other',
|
||||
'边 ID edge-1 的端点无效',
|
||||
]) {
|
||||
expect(findGraphErrorTarget(new Error(message), [], ['edge-1'])).toEqual({
|
||||
@@ -108,17 +110,17 @@ describe('imperative runtime translations', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('formats interpolated messages in both catalogs', () => {
|
||||
for (const locale of ['en', 'zh-CN'] as const) {
|
||||
it('formats interpolated messages in all catalogs', () => {
|
||||
for (const locale of locales) {
|
||||
const t = getRuntimeTranslator(locale);
|
||||
for (const [key, text] of Object.entries(english.runtime)) {
|
||||
if (/\{value\d+\}/.test(text)) {
|
||||
if (/\{value\d+(?:\}|,)/.test(text)) {
|
||||
const translated = t(key as keyof typeof english.runtime, {
|
||||
value0: 'CUSTOM',
|
||||
value0: 42,
|
||||
value1: 'VALUE',
|
||||
value2: 42,
|
||||
});
|
||||
expect(translated).toContain('CUSTOM');
|
||||
expect(translated).toContain('42');
|
||||
expect(translated).not.toContain('{value0}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { createTranslator, type useTranslations } from 'next-intl';
|
||||
import english from '@/messages/fragments/runtime.en.json';
|
||||
import chinese from '@/messages/fragments/runtime.zh-CN.json';
|
||||
import type { useTranslations } from 'next-intl';
|
||||
import { resolveClientLocale } from './client';
|
||||
import type { AppLocale } from './config';
|
||||
import { getRuntimeTranslator, type RuntimeKey } from './runtime-translators';
|
||||
|
||||
export { getRuntimeTranslator } from './runtime-translators';
|
||||
|
||||
export type RuntimeTranslator = ReturnType<typeof useTranslations>;
|
||||
type RuntimeKey = keyof typeof english.runtime;
|
||||
|
||||
const translators = {
|
||||
en: createTranslator({
|
||||
locale: 'en',
|
||||
messages: english,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
'zh-CN': createTranslator({
|
||||
locale: 'zh-CN',
|
||||
messages: chinese,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
};
|
||||
|
||||
export function getRuntimeTranslator(locale: AppLocale) {
|
||||
return translators[locale];
|
||||
}
|
||||
|
||||
// Imperative browser code (network errors, form utilities) has no React hook
|
||||
// context. Resolve the current preference when creating a message, not at import.
|
||||
export const runtimeText = Object.assign(
|
||||
(key: RuntimeKey, values?: Record<string, string | number>) =>
|
||||
translators[resolveClientLocale()](key, values),
|
||||
getRuntimeTranslator(resolveClientLocale())(key, values),
|
||||
{
|
||||
raw: (key: RuntimeKey): string =>
|
||||
translators[resolveClientLocale()].raw(key),
|
||||
getRuntimeTranslator(resolveClientLocale()).raw(key),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createTranslator, type AbstractIntlMessages } from 'next-intl';
|
||||
import english from '@/messages/en.json';
|
||||
import russian from '@/messages/ru.json';
|
||||
import { normalizeLocale, pickLocaleFromAcceptLanguage } from './config';
|
||||
import { effectiveOfflinePageHTML } from '@/lib/openflare/offline-page-templates';
|
||||
import { getOriginErrorPageTemplate } from '@/lib/openflare/origin-error-page-templates';
|
||||
|
||||
function flatten(
|
||||
messages: AbstractIntlMessages,
|
||||
prefix = '',
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(messages).flatMap(([key, value]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
return typeof value === 'string'
|
||||
? [[path, value]]
|
||||
: Object.entries(flatten(value, path));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('Russian UI', () => {
|
||||
it('negotiates Russian variants, quality values and English fallback', () => {
|
||||
expect(normalizeLocale('ru_RU')).toBe('ru');
|
||||
expect(normalizeLocale('ru-BY')).toBe('ru');
|
||||
expect(pickLocaleFromAcceptLanguage('de,ru-RU;q=0.9,en;q=0.7')).toBe('ru');
|
||||
expect(pickLocaleFromAcceptLanguage('ru;q=0,en;q=0.5')).toBe('en');
|
||||
expect(pickLocaleFromAcceptLanguage('de,fr')).toBe('en');
|
||||
});
|
||||
|
||||
it('keeps every message and named argument, and formats Russian ICU messages', () => {
|
||||
const source = flatten(english);
|
||||
const translated = flatten(russian);
|
||||
expect(Object.keys(translated).sort()).toEqual(Object.keys(source).sort());
|
||||
const errors: string[] = [];
|
||||
const t = createTranslator({
|
||||
locale: 'ru',
|
||||
messages: russian,
|
||||
onError: (error) => errors.push(error.message),
|
||||
});
|
||||
for (const [key, text] of Object.entries(source)) {
|
||||
const args = (value: string) =>
|
||||
[
|
||||
...new Set(
|
||||
[...value.matchAll(/\{(\w+)(?:\s*[,}])/g)].map((match) => match[1]),
|
||||
),
|
||||
].sort();
|
||||
expect(args(translated[key]), key).toEqual(args(text));
|
||||
// HTML, code examples and Go templates are intentionally read with t.raw.
|
||||
if (
|
||||
!text ||
|
||||
/\{\{|\{\w+:|className=|function |<\/?[a-zA-Z]|^\s*[{[]/.test(text)
|
||||
)
|
||||
continue;
|
||||
const numericArguments = new Set(
|
||||
[
|
||||
...translated[key].matchAll(
|
||||
/\{(\w+),\s*(?:plural|selectordinal|number)/g,
|
||||
),
|
||||
].map((match) => match[1]),
|
||||
);
|
||||
const values = Object.fromEntries(
|
||||
args(text).map((name) => [
|
||||
name,
|
||||
/^(count|total|current|page|pages|totalPages|value\d+)$/.test(name) ||
|
||||
numericArguments.has(name)
|
||||
? 2
|
||||
: 'CUSTOM',
|
||||
]),
|
||||
);
|
||||
t(key as Parameters<typeof t>[0], values);
|
||||
expect(errors, key).toEqual([]);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses Russian plural forms for 1, 2, 5, 21 and fractional counts', () => {
|
||||
const t = createTranslator({ locale: 'ru', messages: russian });
|
||||
for (const [count, expected] of [
|
||||
[1, '1 минута'],
|
||||
[2, '2 минуты'],
|
||||
[5, '5 минут'],
|
||||
[21, '21 минута'],
|
||||
[1.5, '1,5 минуты'],
|
||||
] as const) {
|
||||
expect(t('pages.source.intervalMinutes', { count })).toBe(expected);
|
||||
}
|
||||
expect(
|
||||
t('admin.files.stats.maxCountHint', { name: 'PDF', count: 2 }),
|
||||
).toContain('2 файла');
|
||||
});
|
||||
|
||||
it('formats certificate expiry and retry seconds across Russian plural boundaries', () => {
|
||||
const t = createTranslator({ locale: 'ru', messages: russian });
|
||||
for (const count of [
|
||||
0, 1, 2, 4, 5, 11, 12, 13, 14, 21, 22, 23, 24, 25, 101, 111,
|
||||
]) {
|
||||
const last = count % 10;
|
||||
const teen = count % 100 >= 11 && count % 100 <= 14;
|
||||
const day =
|
||||
!teen && last === 1
|
||||
? 'день'
|
||||
: !teen && last >= 2 && last <= 4
|
||||
? 'дня'
|
||||
: 'дней';
|
||||
const second =
|
||||
!teen && last === 1
|
||||
? 'секунду'
|
||||
: !teen && last >= 2 && last <= 4
|
||||
? 'секунды'
|
||||
: 'секунд';
|
||||
expect(
|
||||
t('certificates.certStatus.expiresInDays', { days: count }),
|
||||
).toContain(`${count} ${day}`);
|
||||
expect(
|
||||
t('runtime.too_many_requests_retry_in_value0_seconds_ab5e78f1', {
|
||||
value0: count,
|
||||
}),
|
||||
).toContain(`${count} ${second}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the empty-domain hint as rich text with its website link', () => {
|
||||
const errors: string[] = [];
|
||||
const t = createTranslator({
|
||||
locale: 'ru',
|
||||
messages: russian,
|
||||
onError: (error) => errors.push(error.message),
|
||||
});
|
||||
const result = t.rich('proxyRoutes.noZoneDomains', {
|
||||
websites: (chunks) => `[LINK]${chunks}[/LINK]`,
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
expect(result).toContain('[LINK]');
|
||||
expect(result).not.toBe('proxyRoutes.noZoneDomains');
|
||||
});
|
||||
|
||||
it('localizes built-in page templates while retaining custom HTML and placeholders', () => {
|
||||
document.cookie = 'NEXT_LOCALE=ru; Path=/';
|
||||
try {
|
||||
expect(effectiveOfflinePageHTML('')).toContain('lang="ru"');
|
||||
for (const id of ['minimalist', 'line-style', 'baohaus']) {
|
||||
const html = getOriginErrorPageTemplate(id)?.html;
|
||||
expect(html).toContain('lang="ru"');
|
||||
const originals: Record<string, string> = {
|
||||
minimalist: english.runtime.origin_error_page_minimalist,
|
||||
'line-style': english.runtime.origin_error_page_line_style,
|
||||
baohaus: english.runtime.origin_error_page_baohaus,
|
||||
};
|
||||
expect(html?.match(/\{\{\w+\}\}/g)).toEqual(
|
||||
originals[id].match(/\{\{\w+\}\}/g),
|
||||
);
|
||||
}
|
||||
expect(effectiveOfflinePageHTML('<h1>Custom site</h1>')).toBe(
|
||||
'<h1>Custom site</h1>',
|
||||
);
|
||||
} finally {
|
||||
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,293 +1,5 @@
|
||||
/** 内置错误页模板:baohaus(包豪斯风格) */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{status}} | OpenFlare</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-red: #E53935;
|
||||
--color-yellow: #FDD835;
|
||||
--color-blue: #1E88E5;
|
||||
--color-black: #212121;
|
||||
--color-white: #FAFAFA;
|
||||
--color-grey: #9E9E9E;
|
||||
--font-primary: 'Futura', 'Century Gothic', 'Tw Cen MT', sans-serif;
|
||||
}
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-black);
|
||||
font-family: var(--font-primary);
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: radial-gradient(var(--color-grey) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.15;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.618fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-left {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 3;
|
||||
border-right: 4px solid var(--color-black);
|
||||
border-bottom: 4px solid var(--color-black);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-white);
|
||||
padding: 2rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.decor-circle {
|
||||
position: absolute;
|
||||
width: 40vh;
|
||||
height: 40vh;
|
||||
background-color: var(--color-yellow);
|
||||
border-radius: 50%;
|
||||
top: 15%;
|
||||
left: 10%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.error-number {
|
||||
font-size: clamp(6rem, 12vw, 14rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--color-black);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brand-footer {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--color-black);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.zone-top-right {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
border-bottom: 4px solid var(--color-black);
|
||||
background-color: var(--color-white);
|
||||
padding: 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.error-desc {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
max-width: 450px;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.meta-data {
|
||||
margin-top: 2rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-grey);
|
||||
border-top: 1px solid var(--color-grey);
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.zone-bottom-right {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
background-color: var(--color-white);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.geo-line {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background-color: var(--color-black);
|
||||
top: 50%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.geo-triangle {
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
bottom: 15%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 80px solid transparent;
|
||||
border-right: 80px solid transparent;
|
||||
border-bottom: 140px solid var(--color-blue);
|
||||
}
|
||||
|
||||
.geo-rect {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 20%;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
background-color: var(--color-red);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.zone-left {
|
||||
border-right: none;
|
||||
border-bottom: 4px solid var(--color-black);
|
||||
min-height: 50vh;
|
||||
padding-top: 4rem;
|
||||
}
|
||||
|
||||
.decor-circle {
|
||||
width: 30vh;
|
||||
height: 30vh;
|
||||
top: 10%;
|
||||
}
|
||||
|
||||
.zone-top-right {
|
||||
border-bottom: 4px solid var(--color-black);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.zone-bottom-right {
|
||||
min-height: 30vh;
|
||||
}
|
||||
|
||||
.geo-triangle {
|
||||
border-left-width: 50px;
|
||||
border-right-width: 50px;
|
||||
border-bottom-width: 90px;
|
||||
right: 10%;
|
||||
}
|
||||
|
||||
.geo-rect {
|
||||
width: 100px;
|
||||
height: 20px;
|
||||
left: 15%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout-container">
|
||||
<div class="zone-left">
|
||||
<div class="decor-circle"></div>
|
||||
<div class="error-number" aria-label="HTTP status">{{status}}</div>
|
||||
|
||||
<div class="brand-footer">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
<span class="brand-text">OpenFlare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-top-right">
|
||||
<h1 class="error-title">Origin Unavailable</h1>
|
||||
<p class="error-desc">
|
||||
The server received an invalid response from the upstream server.
|
||||
<br><br>
|
||||
<strong>Form follows function:</strong> The connection path is broken. Please verify the origin server status.
|
||||
</p>
|
||||
<div class="meta-data">
|
||||
<div>ERR_CODE: {{status}}</div>
|
||||
<div>HOST: {{host}}</div>
|
||||
<div>PROXY: OPENFLARE_EDGE</div>
|
||||
<div id="timestamp">--:--:-- UTC</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-bottom-right">
|
||||
<div class="geo-line"></div>
|
||||
<div class="geo-rect"></div>
|
||||
<div class="geo-triangle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tsElement = document.getElementById('timestamp');
|
||||
if (tsElement) {
|
||||
const now = new Date();
|
||||
tsElement.textContent = now.toISOString().split('T')[1].split('.')[0] + ' UTC';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default html;
|
||||
export default function getHTML() {
|
||||
return runtimeText.raw('origin_error_page_baohaus');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/**
|
||||
* 内置源站错误页 HTML 模板目录。
|
||||
* 每个模板对应本目录下一个独立 `.ts` 模块(export default 完整 HTML 字符串)。
|
||||
* 每个模板对应本目录下一个独立 `.ts` 模块(export default 返回完整 HTML 的函数)。
|
||||
* 新增模板:新增 `*.ts` 并在下方数组注册。
|
||||
*
|
||||
* 模板须使用占位符 {{status}} / {{host}},禁止写死状态码。
|
||||
@@ -25,21 +25,27 @@ export const ORIGIN_ERROR_PAGE_TEMPLATES: OriginErrorPageTemplate[] = [
|
||||
get name() {
|
||||
return runtimeText('minimal_white_5636e8a9');
|
||||
},
|
||||
html: minimalistHtml,
|
||||
get html() {
|
||||
return minimalistHtml();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'line-style',
|
||||
get name() {
|
||||
return runtimeText('line_topology_22ad450c');
|
||||
},
|
||||
html: lineStyleHtml,
|
||||
get html() {
|
||||
return lineStyleHtml();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'baohaus',
|
||||
get name() {
|
||||
return runtimeText('bauhaus_5cc69781');
|
||||
},
|
||||
html: baohausHtml,
|
||||
get html() {
|
||||
return baohausHtml();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,309 +1,5 @@
|
||||
/** 内置错误页模板:line-style */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{status}} | OpenFlare</title>
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
|
||||
:root {
|
||||
--color-bg: #fcfcfc;
|
||||
--color-line: #e0e0e0;
|
||||
--color-line-hover: #1a1a1a;
|
||||
--color-text-main: #1a1a1a;
|
||||
--color-text-sub: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-accent: #d4d4d4;
|
||||
--font-geo: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-geo);
|
||||
background-color: var(--color-bg);
|
||||
background-image: radial-gradient(circle, var(--color-accent) 0.8px, transparent 0.8px);
|
||||
background-size: 24px 24px;
|
||||
color: var(--color-text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.line-box {
|
||||
border: 1px solid var(--color-line);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 32px;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.line-box:hover {
|
||||
border-color: var(--color-line-hover);
|
||||
box-shadow: 0 0 0 1px var(--color-line-hover);
|
||||
}
|
||||
|
||||
.line-box:active {
|
||||
transform: scale(0.995);
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
|
||||
.error-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text-main);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-sub);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.error-label::before,
|
||||
.error-label::after {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background-color: var(--color-line);
|
||||
}
|
||||
|
||||
.topology-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topology-svg {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-circle {
|
||||
fill: var(--color-bg);
|
||||
stroke: var(--color-text-main);
|
||||
stroke-width: 1.5;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.node-circle.error {
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.flow-line {
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.5;
|
||||
fill: none;
|
||||
transition: stroke var(--transition-fast);
|
||||
}
|
||||
|
||||
.flow-line.error {
|
||||
stroke: var(--color-text-muted);
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
font-family: var(--font-geo);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
fill: var(--color-text-sub);
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: var(--font-geo);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
fill: var(--color-text-muted);
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
fill: #b0b0b0;
|
||||
}
|
||||
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-sub);
|
||||
font-weight: 400;
|
||||
max-width: 480px;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
padding-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-key {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.meta-val {
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace, var(--font-geo);
|
||||
color: var(--color-text-main);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
transition: color var(--transition-fast);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.footer-brand:hover {
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
.error-code {
|
||||
font-size: 48px;
|
||||
}
|
||||
.line-box {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<div class="line-box error-header">
|
||||
<div class="error-code" aria-label="HTTP status">{{status}}</div>
|
||||
<div class="error-label">Origin Unavailable</div>
|
||||
</div>
|
||||
|
||||
<div class="line-box topology-wrapper">
|
||||
<svg class="topology-svg" viewBox="0 0 600 140" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 8 5 L 0 9 z" fill="#d4d4d4" />
|
||||
</marker>
|
||||
<marker id="arrow-error" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 1 L 8 5 L 0 9 z" fill="#999999" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<line x1="135" y1="70" x2="265" y2="70" class="flow-line" marker-end="url(#arrow)" />
|
||||
<line x1="335" y1="70" x2="465" y2="70" class="flow-line error" marker-end="url(#arrow-error)" />
|
||||
|
||||
<circle cx="110" cy="70" r="20" class="node-circle" />
|
||||
<text x="110" y="74" class="node-text" style="font-size: 8px; fill: #1a1a1a;">CLI</text>
|
||||
<text x="110" y="115" class="node-text">CLIENT</text>
|
||||
<text x="110" y="130" class="status-text">REQ_SENT</text>
|
||||
|
||||
<circle cx="300" cy="70" r="20" class="node-circle" />
|
||||
<path d="M296 70 L304 64 L301 70 L305 70 L296 76 L299 70 Z" fill="none" stroke="#1a1a1a" stroke-width="1" stroke-linejoin="round"/>
|
||||
<text x="300" y="115" class="node-text">OPENFLARE</text>
|
||||
<text x="300" y="130" class="status-text">EDGE_PROXY</text>
|
||||
|
||||
<circle cx="490" cy="70" r="20" class="node-circle error" />
|
||||
<line x1="484" y1="64" x2="496" y2="76" stroke="#999" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="496" y1="64" x2="484" y2="76" stroke="#999" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<text x="490" y="115" class="node-text" style="fill: #999;">ORIGIN</text>
|
||||
<text x="490" y="130" class="status-text error">UNAVAILABLE</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="footer-brand">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
<span>OpenFlare</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const tsEl = document.getElementById('timestamp');
|
||||
if (tsEl) {
|
||||
const now = new Date();
|
||||
tsEl.textContent = now.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default html;
|
||||
export default function getHTML() {
|
||||
return runtimeText.raw('origin_error_page_line_style');
|
||||
}
|
||||
|
||||
@@ -1,109 +1,5 @@
|
||||
/** 内置错误页模板:minimalist(默认) */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{status}} | OpenFlare</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #333333;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
color: #666666;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.host {
|
||||
font-size: 14px;
|
||||
color: #999999;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.error-code {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="error-code" aria-label="HTTP status">{{status}}</h1>
|
||||
<p class="error-description">
|
||||
The upstream server is unreachable. Please try again later or contact the site administrator if the problem persists.
|
||||
</p>
|
||||
<p class="host">{{host}}</p>
|
||||
<div class="footer">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
|
||||
</svg>
|
||||
<span>OpenFlare</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default html;
|
||||
export default function getHTML() {
|
||||
return runtimeText.raw('origin_error_page_minimalist');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { locales } from '@/i18n/config';
|
||||
import { getRuntimeTranslator, runtimeText } from '@/i18n/runtime';
|
||||
/**
|
||||
* API 错误类型定义
|
||||
@@ -126,7 +127,7 @@ export function isCancelError(error: unknown): boolean {
|
||||
(('__CANCEL__' in error &&
|
||||
(error as { __CANCEL__?: boolean }).__CANCEL__ === true) ||
|
||||
('message' in error &&
|
||||
(['en', 'zh-CN'] as const).some(
|
||||
locales.some(
|
||||
(locale) =>
|
||||
(error as { message?: string }).message ===
|
||||
getRuntimeTranslator(locale)('request_canceled_5892f9b1'),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatSystemMetric } from './system-metrics';
|
||||
|
||||
describe('system metric presentation', () => {
|
||||
it('formats live values and legacy concatenated English durations', () => {
|
||||
expect(formatSystemMetric(50425, 'ru')).toBe('50\u00a0425');
|
||||
expect(formatSystemMetric('28 minutes41 seconds', 'ru')).toBe(
|
||||
'28 мин 41 с',
|
||||
);
|
||||
expect(formatSystemMetric('1 day 2 hours', 'ru')).toBe('1 дн. 2 ч');
|
||||
expect(formatSystemMetric('28 minutes41 seconds', 'en')).toBe('28m 41s');
|
||||
expect(formatSystemMetric('43 seconds', 'zh-CN')).toBe('43秒');
|
||||
expect(formatSystemMetric('5.8 MiB', 'ru')).toBe('5,8 MiB');
|
||||
expect(formatSystemMetric('0.001s', 'ru')).toBe('0,001 с');
|
||||
expect(formatSystemMetric('None', 'ru')).toBe('Нет');
|
||||
expect(formatSystemMetric('custom metric 42', 'ru')).toBe(
|
||||
'custom metric 42',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AppLocale } from '@/i18n/config';
|
||||
import { getRuntimeTranslator } from '@/i18n/runtime';
|
||||
|
||||
const durationKeys = {
|
||||
day: 'system_metric_day',
|
||||
hour: 'system_metric_hour',
|
||||
minute: 'system_metric_minute',
|
||||
second: 'system_metric_second',
|
||||
} as const;
|
||||
|
||||
// The status API owns these English diagnostic values. Localize their display
|
||||
// without changing the raw values used by memory calculations or other clients.
|
||||
export function formatSystemMetric(
|
||||
value: string | number,
|
||||
locale: AppLocale,
|
||||
): string {
|
||||
const number = (n: number, digits = 0) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: Math.max(digits, 3),
|
||||
}).format(n);
|
||||
if (typeof value === 'number') return number(value);
|
||||
const t = getRuntimeTranslator(locale);
|
||||
if (value === 'Unknown') return t('system_metric_unknown');
|
||||
if (value === 'None') return t('system_metric_none');
|
||||
const size = /^(\d+(?:\.\d+)?) (B|KiB|MiB|GiB|TiB)$/.exec(value);
|
||||
if (size) return `${number(Number(size[1]))} ${size[2]}`;
|
||||
const seconds = /^(\d+(?:\.\d+)?)s$/.exec(value);
|
||||
if (seconds) {
|
||||
const digits = seconds[1].split('.')[1]?.length ?? 0;
|
||||
return t('system_metric_second', {
|
||||
count: number(Number(seconds[1]), digits),
|
||||
});
|
||||
}
|
||||
const parts = [
|
||||
...value.matchAll(/(\d+)\s*(days?|hours?|minutes?|seconds?)/g),
|
||||
];
|
||||
if (
|
||||
!parts.length ||
|
||||
parts
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.replaceAll(/\s/g, '') !== value.replaceAll(/\s/g, '')
|
||||
)
|
||||
return value;
|
||||
return parts
|
||||
.map((part) => {
|
||||
const unit = part[2].replace(/s$/, '') as keyof typeof durationKeys;
|
||||
return t(durationKeys[unit], { count: number(Number(part[1])) });
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
@@ -1146,7 +1146,8 @@
|
||||
"unknownExecError": "Unknown execution error",
|
||||
"clearEditor": "Clear editor",
|
||||
"dragResize": "Drag to resize",
|
||||
"execErrorTitle": "SQL execution error:"
|
||||
"execErrorTitle": "SQL execution error:",
|
||||
"connected": "Connected to {database}"
|
||||
},
|
||||
"tableBrowser": {
|
||||
"tableBrowser": "Table browser",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1146,7 +1146,8 @@
|
||||
"unknownExecError": "未知执行错误",
|
||||
"clearEditor": "清空编辑器",
|
||||
"dragResize": "拖动调整大小",
|
||||
"execErrorTitle": "SQL 执行报错 (Error):"
|
||||
"execErrorTitle": "SQL 执行报错 (Error):",
|
||||
"connected": "已连接 {database}"
|
||||
},
|
||||
"tableBrowser": {
|
||||
"tableBrowser": "数据表浏览器",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"builtinMetadata": {
|
||||
"configDescriptions": {
|
||||
"openresty_trusted_proxy_cidrs": "CIDR доверенных прокси-серверов IPv4/IPv6 (массив JSON)",
|
||||
"metric_retention_days": "Срок хранения для CPU, показателей памяти, диска и сети (дни, общие для всех баз данных журналов)",
|
||||
"log_retention_days_sqlite": "SQLite срок хранения журнала (дней)",
|
||||
"log_retention_days_clickhouse": "ClickHouse срок хранения журнала (дней)",
|
||||
"log_retention_days_postgres": "PostgreSQL срок хранения журналов доступа и данных наблюдения (дни)",
|
||||
"sw_offline_domains": "Домены, использующие автономный резервный вариант Service Worker (массив JSON; отсутствие доменов означает, что резервный вариант неактивен).",
|
||||
"sw_offline_enabled": "Включение автономного резервного режима Service Worker",
|
||||
"sw_offline_html": "Пользовательский HTML для страницы офлайн-контактов; оставьте пустым, чтобы использовать встроенный шаблон",
|
||||
"origin_error_page_get_only": "Показывать страницу ошибки сервера-источника только для GET-запросов. Остальные методы передаются без подмены ответа.",
|
||||
"origin_error_page_enabled": "Показывать страницу ошибки сервера-источника",
|
||||
"origin_error_page_status_codes": "Теги кода состояния, которые запускают страницу исходной ошибки (массив JSON)",
|
||||
"origin_error_page_html": "Пользовательский HTML для исходной страницы ошибки; оставьте пустым, чтобы использовать встроенный шаблон",
|
||||
"openresty_default_limit_req_per_ip": "Частота запросов на один IP по умолчанию, например 10r/s или 100r/m. Пустое значение отключает ограничение.",
|
||||
"openresty_default_limit_conn_per_server": "Ограничение количества одновременных подключений по умолчанию для каждого сайта; 0 отключает ограничение",
|
||||
"openresty_default_limit_conn_per_ip": "Ограничение количества одновременных подключений по умолчанию на IP; 0 отключает ограничение",
|
||||
"openresty_default_limit_rate": "Ограничение пропускной способности по умолчанию для каждого запроса; оставьте пустым, чтобы отключить",
|
||||
"pages_max_history_count": "Максимальное количество исторических развертываний, сохраненных для каждого проекта Pages; 0 означает неограниченный",
|
||||
"pages_max_package_size_mb": "Максимальный размер загружаемого пакета развертывания Pages (МиБ)",
|
||||
"openresty_keepalive_requests": "Максимальное количество запросов на одно соединение Keepalive",
|
||||
"openresty_client_header_timeout": "Тайм-аут заголовка запроса клиента (секунды)",
|
||||
"openresty_client_body_timeout": "Тайм-аут тела запроса клиента (секунды)",
|
||||
"openresty_client_max_body_size": "Максимальный размер тела запроса клиента",
|
||||
"openresty_large_client_header_buffers": "Буферы для больших заголовков клиентских запросов.",
|
||||
"openresty_send_timeout": "Таймаут отправки ответа (секунды)",
|
||||
"openresty_proxy_connect_timeout": "Тайм-аут подключения к серверу-источнику (секунды)",
|
||||
"openresty_proxy_send_timeout": "Тайм-аут отправки запроса серверу-источнику (секунды)",
|
||||
"openresty_proxy_read_timeout": "Тайм-аут чтения ответа сервера-источника (секунды)",
|
||||
"openresty_websocket_enabled": "Включить поддержку WebSocket",
|
||||
"openresty_http3_enabled": "Включить поддержку HTTP/3",
|
||||
"openresty_proxy_buffering_enabled": "Включить буферизацию ответов прокси",
|
||||
"openresty_proxy_buffers": "Буферы ответов прокси",
|
||||
"openresty_proxy_buffer_size": "Размер буфера ответа прокси",
|
||||
"openresty_proxy_busy_buffers_size": "Максимальный размер буферов ответа занятого прокси-сервера",
|
||||
"openresty_gzip_enabled": "Включить сжатие Gzip",
|
||||
"openresty_gzip_min_length": "Минимальный размер ответа для сжатия Gzip",
|
||||
"openresty_gzip_comp_level": "Уровень сжатия Gzip",
|
||||
"openresty_cache_enabled": "Включить кэширование",
|
||||
"openresty_cache_levels": "Уровни каталогов кэша",
|
||||
"openresty_cache_inactive": "Тайм-аут бездействия кэша",
|
||||
"openresty_cache_key_template": "Шаблон ключа кэша",
|
||||
"openresty_cache_lock_enabled": "Включить блокировку кэша",
|
||||
"openresty_cache_lock_timeout": "Тайм-аут блокировки кэша",
|
||||
"openresty_cache_use_stale": "Условия предоставления устаревших кэшированных ответов",
|
||||
"agent_heartbeat_interval": "Интервал контрольного сигнала агента (миллисекунды)",
|
||||
"node_offline_threshold": "Время без контрольного сигнала, прежде чем узел будет считаться отключенным (миллисекунды)",
|
||||
"openresty_cache_max_size": "Максимальный размер кэша",
|
||||
"agent_websocket_upgrade_enabled": "Включить обновления агента WebSocket",
|
||||
"agent_update_repo": "Репозиторий обновлений Агента",
|
||||
"geoip_provider": "ГеоIP-провайдер",
|
||||
"uptime_kuma_enabled": "Включить интеграцию Uptime Kuma",
|
||||
"uptime_kuma_monitor_scope": "Uptime Kuma область мониторинга",
|
||||
"uptime_kuma_sync_interval": "Uptime Kuma интервал синхронизации (минуты)",
|
||||
"uptime_kuma_interval": "Uptime Kuma интервал мониторинга (секунды)",
|
||||
"uptime_kuma_retry": "Uptime Kuma количество повторов",
|
||||
"uptime_kuma_retry_interval": "Uptime Kuma интервал повтора (секунды)",
|
||||
"uptime_kuma_timeout": "Uptime Kuma тайм-аут (секунды)",
|
||||
"openresty_default_server_return_status": "Код состояния HTTP, возвращаемый сервером по умолчанию",
|
||||
"openresty_worker_processes": "Количество рабочих процессов",
|
||||
"openresty_worker_connections": "Максимум подключений на рабочий процесс",
|
||||
"openresty_worker_rlimit_nofile": "Ограничение дескриптора открытого рабочего файла",
|
||||
"openresty_events_use": "Метод обработки событий",
|
||||
"openresty_events_multi_accept_enabled": "Включить прием нескольких подключений одновременно",
|
||||
"openresty_keepalive_timeout": "Тайм-аут поддержки активности (секунды)",
|
||||
"openresty_proxy_request_buffering_enabled": "Включить буферизацию прокси-запросов",
|
||||
"relay_frps_web_ui_port": "Порт для встроенного интерфейса веб-администрирования FRPS",
|
||||
"relay_frps_web_ui_enabled": "Включите встроенный интерфейс веб-администрирования FRPS.",
|
||||
"max_api_keys_per_user": "Максимальное количество ключей API, которые может создать каждый обычный пользователь.",
|
||||
"log_db_migration": "Флаг, указывающий, что записи в базу данных журналов заморожены во время миграции.",
|
||||
"login_session_ttl_hours": "Время жизни сеанса входа в систему в часах; 0 завершает сеанс при закрытии браузера, -1 не истекает никогда.",
|
||||
"disk_cache_lru_enabled": "Удалять из кэша элементы, к которым дольше всего не обращались (LRU)",
|
||||
"disk_cache_ttl_minutes": "Время жизни дискового кэша по умолчанию (минуты)",
|
||||
"disk_cache_max_size_mb": "Максимальный размер дискового кэша (MB)",
|
||||
"file_access_whitelist": "Бизнес-типы файлов, доступные без входа в систему (массив JSON)",
|
||||
"smtp_host": "SMTP адрес сервера, например smtp.example.com",
|
||||
"smtp_port": "SMTP порт, например 587 или 465",
|
||||
"smtp_username": "счет SMTP, например sender@example.com",
|
||||
"smtp_password": "SMTP учетные данные (код авторизации или пароль)",
|
||||
"upload_allowed_extensions": "Разрешенные расширения для загрузки изображений (через запятую)",
|
||||
"site_name": "Отображаемое имя платформы",
|
||||
"password_login_enabled": "Разрешить вход по имени пользователя и паролю",
|
||||
"registration_enabled": "Разрешить пользователям регистрировать свои собственные учетные записи (true/false)",
|
||||
"cap_login_enabled": "Включить вход CAPTCHA (true/false)",
|
||||
"oidc_login_enabled": "Разрешить вход через сторонних поставщиков OIDC",
|
||||
"email_login_verification_enabled": "Требовать подтверждение электронной почты для входа (true/false)",
|
||||
"email_register_verification_enabled": "Требовать подтверждения электронной почты для регистрации (true/false)",
|
||||
"menu_display_config": "Конфигурация видимости меню (строка JSON сопоставляет URL-адреса с включенными состояниями)",
|
||||
"search_engine_indexing_enabled": "Разрешить поисковым системам сканировать и индексировать этот сайт (true/false)",
|
||||
"update_upstream_repository": "Исходный репозиторий обновлений GitHub Actions (владелец/репозиторий или URL GitHub)",
|
||||
"storage_config": "Драйвер файлового хранилища и настройки подключения (JSON)",
|
||||
"password_register_enabled": "Разрешить создание локальных учетных записей с паролем",
|
||||
"cap_auto_solve": "Начните решать задачу, когда страница загрузится; в противном случае потребуется щелчок пользователя",
|
||||
"cap_challenge_count": "Количество задач PoW, которые должен решить клиент; по умолчанию 1, рекомендуется от 1 до 5",
|
||||
"cap_challenge_size": "CAPTCHA длина соли",
|
||||
"cap_challenge_difficulty": "CAPTCHA Сложность PoW (длина целевого префикса)",
|
||||
"cap_challenge_ttl_seconds": "CAPTCHA Время жизни вызова (секунды)",
|
||||
"cap_token_ttl_seconds": "CAPTCHA Срок действия токена подтверждения (секунды)",
|
||||
"server_address": "URL сервера для ограничения источников запросов (Origin). Пустое значение разрешает любой источник."
|
||||
},
|
||||
"templates": {
|
||||
"login_email": {
|
||||
"name": "Электронная почта для подтверждения входа",
|
||||
"description": "Письмо с подтверждением отправляется во время входа в систему с паролем. Доступная переменная: {{.Code}}"
|
||||
},
|
||||
"register_email": {
|
||||
"name": "Электронная почта для подтверждения регистрации",
|
||||
"description": "Письмо с подтверждением отправлено во время регистрации. Доступная переменная: {{.Code}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"cloudflare": {
|
||||
"move": "Переместить",
|
||||
"batchMove": "Пакетное перемещение",
|
||||
"batchRemove": "Пакетное удаление",
|
||||
"selectedCount": "Выбрано: {count}",
|
||||
"clearSelection": "Очистить выбор",
|
||||
"moveDialog": {
|
||||
"titleSingle": "Переместить домен «{domain}»",
|
||||
"titleBatch": "Переместить домены ({count})",
|
||||
"targetGroupLabel": "Целевая группа наведения",
|
||||
"targetGroupPlaceholder": "Выберите целевую группу",
|
||||
"noOtherGroups": "Других групп указания нет. Пожалуйста, сначала создайте его.",
|
||||
"submit": "Подтвердить перемещение",
|
||||
"moving": "Переезд..."
|
||||
},
|
||||
"batchRemoveDialog": {
|
||||
"title": "Подтвердить пакетное удаление домена",
|
||||
"desc": "Удалить выбранные домены ({count}) из текущей группы? Их записи DNS в Cloudflare также будут удалены.",
|
||||
"confirm": "Подтвердить удаление",
|
||||
"removing": "Удаление..."
|
||||
},
|
||||
"memberMoved": "Домен успешно перемещен",
|
||||
"batchMoved": "Перемещено доменов: {count}",
|
||||
"batchRemoved": "Удалено доменов: {count}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить",
|
||||
"delete": "Удалить",
|
||||
"loading": "Загрузка…",
|
||||
"retry": "Повторить",
|
||||
"optional": "(необязательно)",
|
||||
"settings": "Настройки",
|
||||
"search": "Поиск",
|
||||
"unknownError": "Что-то пошло не так. Пожалуйста, попробуйте еще раз.",
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"previousPage": "Предыдущая страница",
|
||||
"nextPage": "Следующая страница"
|
||||
},
|
||||
"contexts": {
|
||||
"adminUsers": {
|
||||
"userStatusChanged": "Пользователь {username}: {action}",
|
||||
"enabled": "доступ разрешён",
|
||||
"disabled": "доступ заблокирован",
|
||||
"updateStatusFailed": "Не удалось обновить статус",
|
||||
"userCreated": "Пользователь {username} успешно создан.",
|
||||
"createUserFailed": "Не удалось создать пользователя",
|
||||
"userDeleted": "Пользователь {username} удален.",
|
||||
"deleteUserFailed": "Не удалось удалить пользователя",
|
||||
"updateUserSuccess": "Информация о пользователе обновлена",
|
||||
"updateUserFailed": "Не удалось обновить информацию о пользователе."
|
||||
},
|
||||
"admin": {
|
||||
"loadConfigFailed": "Не удалось загрузить конфигурацию системы.",
|
||||
"updateConfigFailed": "Не удалось обновить конфигурацию системы."
|
||||
},
|
||||
"user": {
|
||||
"getUserInfoFailed": "Не удалось получить информацию о пользователе.",
|
||||
"logoutFailed": "Выход из системы не выполнен"
|
||||
}
|
||||
},
|
||||
"docs": {
|
||||
"backToHome": "На главную",
|
||||
"sectionCount": "Разделов: {count}",
|
||||
"lastUpdated": "Последнее обновление",
|
||||
"copied": "Скопировано",
|
||||
"copyMarkdown": "Копировать Markdown",
|
||||
"toc": "Оглавление",
|
||||
"haveQuestions": "Есть вопросы?",
|
||||
"contactSupport": "Связаться со службой поддержки",
|
||||
"api": {
|
||||
"title": "API документация",
|
||||
"description": "Полный справочник по серверной части платформы Wavelet API, охватывающий аутентификацию, пользователей, токены и управление конфигурацией.",
|
||||
"field": "Поле",
|
||||
"type": "Тип",
|
||||
"descriptionLabel": "Описание",
|
||||
"endpoint": "Конечная точка",
|
||||
"desc": "Описание",
|
||||
"param": "Параметр",
|
||||
"required": "Требуется",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"optional": "Необязательно",
|
||||
"default": "По умолчанию",
|
||||
"section1": {
|
||||
"title": "Общие соглашения",
|
||||
"desc": "Все ответы API имеют единый формат конверта.",
|
||||
"responseFormat": "Формат ответа",
|
||||
"responseFormatDesc": "Все конечные точки возвращают следующую структуру JSON:",
|
||||
"errorMsgDesc": "Пустая строка в случае успеха, удобочитаемое сообщение об ошибке в случае неудачи.",
|
||||
"dataDesc": "Бизнес-данные; структура зависит от конечной точки",
|
||||
"successExample": "Пример успешного ответа",
|
||||
"failExample": "Пример ответа на отказ",
|
||||
"failExampleErrorMsg": "Сообщение об ошибке",
|
||||
"authentication": "Аутентификация",
|
||||
"authenticationDesc": "Система поддерживает два метода аутентификации. Для всех защищенных конечных точек требуется один из них:",
|
||||
"sessionCredential": "Учетные данные сеанса",
|
||||
"sessionCredentialDesc": "Возвращается через Set-Cookie после входа в систему. Браузеры переносят его автоматически.",
|
||||
"accessTokenCredential": "Учетные данные AccessToken",
|
||||
"accessTokenCredentialDesc": "Передаётся в заголовке запроса. Подходит для вызовов API и сторонних интеграций:",
|
||||
"supportedHeaders": "Поддерживаемые заголовки"
|
||||
},
|
||||
"section2": {
|
||||
"title": "Конечные точки аутентификации",
|
||||
"register": "Зарегистрироваться",
|
||||
"registerDesc": "Создайте новую учетную запись пользователя и войдите в систему автоматически.",
|
||||
"usernameDesc": "Имя пользователя, уникальное, минимум 3 символа",
|
||||
"passwordDesc": "Пароль, не менее 8 символов",
|
||||
"nicknameDesc": "Псевдоним (необязательно)",
|
||||
"passwordLogin": "Пароль для входа",
|
||||
"passwordLoginDesc": "Войдите в систему, используя адрес электронной почты/имя пользователя + пароль. Устанавливает cookie в случае успеха.",
|
||||
"logout": "Выход из системы",
|
||||
"logoutDesc": "Уничтожить текущий сеанс.",
|
||||
"getProfile": "Получить текущую информацию о пользователе",
|
||||
"getProfileDesc": "Возвращает полный профиль пользователя, вошедшего в систему."
|
||||
},
|
||||
"section3": {
|
||||
"title": "Управление токенами доступа",
|
||||
"desc": "Управляйте личными токенами доступа API (AccessToken) для использования API без использования браузера.",
|
||||
"listTokens": "Список токенов",
|
||||
"listTokensDesc": "Получите все токены активного доступа для текущего пользователя.",
|
||||
"createToken": "Создать токен",
|
||||
"tokenName": "Имя токена",
|
||||
"createTokenDesc": "Создайте новый токен доступа. Возвращает секрет один раз.",
|
||||
"isAdminDesc": "Предоставлять ли права администратора (по умолчанию false)",
|
||||
"endpoint": "Конечная точка",
|
||||
"successResponseExample": "Пример успешного ответа",
|
||||
"deleteToken": "Удалить токен",
|
||||
"deleteTokenDesc": "Отозвать указанный токен доступа.",
|
||||
"rotateToken": "Заменить секрет токена",
|
||||
"rotateTokenDesc": "Создайте новый секрет для указанного токена. Старый секрет немедленно аннулируется."
|
||||
},
|
||||
"section4": {
|
||||
"title": "Управление конфигурацией системы",
|
||||
"publicConfig": "Получить общедоступную конфигурацию",
|
||||
"publicConfigDesc": "Возвращает все элементы конфигурации системы, помеченные как общедоступные. Никакой аутентификации не требуется.",
|
||||
"responseExample": "Пример ответа",
|
||||
"adminConfigs": "Конечные точки конфигурации администратора",
|
||||
"adminConfigsDesc": "Следующие конечные точки требуют разрешений администратора.",
|
||||
"getConfigList": "Получить список конфигурации",
|
||||
"createConfig": "Создать конфигурацию",
|
||||
"updateConfig": "Обновить конфигурацию",
|
||||
"deleteConfig": "Удалить конфигурацию"
|
||||
}
|
||||
},
|
||||
"howToUse": {
|
||||
"title": "Руководство пользователя",
|
||||
"description": "Краткое руководство по работе с платформой Wavelet, охватывающее аутентификацию, токены, конфигурацию, задачи и возможность наблюдения.",
|
||||
"section1": {
|
||||
"title": "Обзор архитектуры системы",
|
||||
"desc": "Платформа Wavelet использует архитектуру, разделенную на Go + Next.js.",
|
||||
"architecture": "Технический стек",
|
||||
"architectureDesc": "Go 1.25+ / Gin / GORM / PostgreSQL / Redis / Asynq",
|
||||
"authSystem": "Система аутентификации",
|
||||
"authSystemDesc": "Поддерживает вход по паролю + стороннюю аутентификацию OIDC",
|
||||
"accessToken": "Токены доступа",
|
||||
"accessTokenDesc": "Личные токены доступа для вызовов API",
|
||||
"observability": "Наблюдаемость",
|
||||
"observabilityDesc": "OpenTelemetry + структурированное журналирование"
|
||||
},
|
||||
"section2": {
|
||||
"title": "Аутентификация и регистрация",
|
||||
"desc": "Система поддерживает несколько методов аутентификации, настраиваемых через панель администратора.",
|
||||
"passwordAuth": "Аутентификация по паролю",
|
||||
"selfRegister": "Самостоятельная регистрация",
|
||||
"selfRegisterDesc": "Пользователи могут создавать учетные записи через страницу регистрации.",
|
||||
"bcryptDesc": "Пароли хранятся с использованием шифрования bcrypt.",
|
||||
"switchControl": "Переключить элементы управления",
|
||||
"switchControlDesc": "Администраторы могут самостоятельно контролировать:",
|
||||
"oidc": "OIDC сторонний вход",
|
||||
"oidcDesc": "Поддерживает настройку любого источника аутентификации, совместимого с OIDC- (например, Google, GitHub, Enterprise SSO).",
|
||||
"oidcStep1": "Добавьте источник аутентификации в «Настройки системы → Безопасность»",
|
||||
"oidcStep2": "Введите Discovery URL, Client ID, Секрет клиента.",
|
||||
"oidcStep3": "После включения на странице входа появится соответствующая кнопка."
|
||||
},
|
||||
"section3": {
|
||||
"title": "Токены доступа (AccessToken)",
|
||||
"desc": "Используется для вызовов API без использования браузера.",
|
||||
"generation": "Генерация и управление",
|
||||
"oneTimeDisplay": "Одноразовый показ",
|
||||
"oneTimeDisplayDesc": "Секрет отображается только один раз при создании токена.",
|
||||
"oneTimeDisplayHint": "Скопируйте и сохраните его сразу после создания.",
|
||||
"secureHash": "Безопасное хранение",
|
||||
"secureHashDesc": "Система хранит только хеш токена SHA-256. Исходный секрет не может быть восстановлен.",
|
||||
"headerUsage": "Использование",
|
||||
"headerUsageDesc": "Включите токен в заголовок запроса:",
|
||||
"bearerHeader": "Метод токена на предъявителя",
|
||||
"customHeader": "Пользовательский метод заголовка"
|
||||
},
|
||||
"section4": {
|
||||
"title": "Конфигурация системы",
|
||||
"desc": "Динамически управляйте конфигурацией системы через панель администратора или API.",
|
||||
"cacheAccel": "Ускорение кэша",
|
||||
"cacheAccelDesc": "Общедоступные элементы конфигурации поддерживают многоуровневое кэширование (память → Redis → база данных), хорошо работая в сценариях с высокой частотой чтения.",
|
||||
"coreConfigs": "Основные элементы конфигурации",
|
||||
"siteName": "Название сайта",
|
||||
"passwordLoginEnabled": "Переключение входа в систему с паролем",
|
||||
"registrationEnabled": "Переключение регистрации",
|
||||
"maxApiKeys": "Максимум ключей API"
|
||||
},
|
||||
"section5": {
|
||||
"title": "Асинхронные задачи",
|
||||
"desc": "Распределенная система очередей задач на основе Asynq.",
|
||||
"scheduler": "Планировщик",
|
||||
"schedulerDesc": "Поддерживает настройку выражений Cron для запланированных задач.",
|
||||
"worker": "Рабочий",
|
||||
"workerDesc": "Фоновые рабочие процессы потребляют и выполняют задачи."
|
||||
},
|
||||
"section6": {
|
||||
"title": "Наблюдаемость",
|
||||
"desc": "Встроенная поддержка OpenTelemetry.",
|
||||
"tracing": "Распределенная трассировка",
|
||||
"tracingDesc": "Поддерживает экспорт данных трассировки в Jaeger, Zipkin и другие серверы.",
|
||||
"structuredLog": "Структурированное журналирование",
|
||||
"structuredLogDesc": "Высокопроизводительное структурированное ведение журналов на основе Zap с выводом на основе уровней."
|
||||
}
|
||||
},
|
||||
"terms": {
|
||||
"title": "Условия обслуживания",
|
||||
"description": "Перед использованием платформы Wavelet внимательно прочтите следующие условия обслуживания.",
|
||||
"section1": {
|
||||
"title": "Общие положения",
|
||||
"subject": "Стороны соглашения",
|
||||
"subjectDesc": "Настоящее соглашение заключается между вами и оператором платформы Wavelet в отношении использования услуг платформы.",
|
||||
"readCarefully": "Пожалуйста, прочитайте внимательно",
|
||||
"readCarefullyDesc": "Пожалуйста, прочтите все содержание настоящего соглашения перед использованием услуги. Если вы не согласны с какими-либо условиями, пожалуйста, не используйте платформу.",
|
||||
"composition": "Состав договора",
|
||||
"compositionDesc": "Настоящее соглашение включает в себя текст соглашения и все правила, которые платформа опубликовала или может опубликовать в будущем. Все правила являются неотъемлемой частью настоящего соглашения и имеют ту же юридическую силу, что и текст соглашения."
|
||||
},
|
||||
"section2": {
|
||||
"title": "Содержание услуги",
|
||||
"liability": "Объем ответственности",
|
||||
"liabilityDesc": "Эта платформа предоставляет только техническую инфраструктуру и услуги инструментов разработки. Пользователи несут всю ответственность, вытекающую из использования ими платформы."
|
||||
},
|
||||
"section3": {
|
||||
"title": "Учетные записи пользователей",
|
||||
"accountSystem": "Система аккаунтов",
|
||||
"accountSystemDesc": "После регистрации и получения учетной записи на этой платформе пользователи должны правильно хранить свою учетную запись и пароль. Пользователи несут ответственность за все действия под своей учетной записью.",
|
||||
"securityResponsibility": "Ответственность за безопасность",
|
||||
"passwordSecurity": "Безопасность пароля",
|
||||
"passwordSecurityDesc": "Пользователи должны использовать достаточно надежные пароли и регулярно их менять. Убытки, вызванные неправильным хранением пароля, являются ответственностью пользователя.",
|
||||
"tokenSecurity": "Безопасность токена",
|
||||
"tokenSecurityDesc": "Токены доступа (AccessToken) эквивалентны учетным данным учетной записи и должны быть надлежащим образом защищены.",
|
||||
"tokenLeakLiability": "Любые убытки, вызванные утечкой токенов, являются ответственностью пользователя."
|
||||
},
|
||||
"section4": {
|
||||
"title": "Правила использования",
|
||||
"desc": "Пользователи должны соблюдать следующие правила при использовании сервисов платформы:",
|
||||
"redLine": "Запрещенные действия",
|
||||
"nationalSecurity": "Угроза национальной безопасности",
|
||||
"nationalSecurityDesc": "Запрещается использовать сервисы платформы для производства, копирования или публикации информации, которая ставит под угрозу национальную безопасность.",
|
||||
"illegalInfo": "Незаконная информация",
|
||||
"illegalInfoDesc": "Запрещается производить, копировать или публиковать информацию, которая нарушает национальные законы и правила.",
|
||||
"harmfulContent": "Вредный контент",
|
||||
"harmfulContentDesc": "Запрещается публиковать информацию, содержащую преследования, оскорбления, запугивания или другой вредный контент.",
|
||||
"ipInfringement": "Нарушение интеллектуальной собственности",
|
||||
"ipInfringementDesc": "Не следует нарушать интеллектуальную собственность, коммерческую тайну или другие законные права других лиц.",
|
||||
"otherIllegal": "Другие незаконные действия",
|
||||
"otherIllegalDesc": "Запрещается использовать услуги платформы для совершения других действий, нарушающих законы и правила.",
|
||||
"enforcement": "Правоприменение",
|
||||
"enforcementDesc": "Если пользователи нарушают вышеуказанные правила, платформа оставляет за собой право предпринять следующие действия:",
|
||||
"enforcementAction": "Предупреждение, приостановка или прекращение обслуживания"
|
||||
},
|
||||
"section5": {
|
||||
"title": "Отказ от ответственности",
|
||||
"basicDisclaimer": "Основной отказ от ответственности",
|
||||
"basicDisclaimerDesc": "Платформа приложит все усилия для обеспечения безопасности и стабильности сервиса, но не несет ответственности за следующие ситуации:",
|
||||
"techInterruption": "Технический перерыв",
|
||||
"techInterruptionDesc": "Прерывание обслуживания или потеря данных, вызванная:",
|
||||
"naturalDisaster": "Стихийные бедствия и другие форс-мажорные обстоятельства",
|
||||
"govAction": "Действия правительства или законодательные изменения",
|
||||
"telecomFailure": "Сбои оператора связи",
|
||||
"hacking": "Хакерские атаки или компьютерные вирусы"
|
||||
},
|
||||
"section6": {
|
||||
"title": "Применимое право и разрешение споров",
|
||||
"applicableLaw": "Применимое право",
|
||||
"applicableLawDesc": "Заключение, исполнение и толкование настоящего соглашения регулируются:",
|
||||
"lawName": "Законы Китайской Народной Республики",
|
||||
"lawScope": "Исключение коллизионных норм",
|
||||
"disputeResolution": "Разрешение споров",
|
||||
"disputeResolutionDesc": "Любой спор, возникающий из настоящего соглашения или связанный с ним, разрешается путем дружественных переговоров. Если переговоры не увенчались успехом, любая из сторон может подать иск в суд компетентной юрисдикции."
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Политика конфиденциальности",
|
||||
"description": "Платформа Wavelet ценит конфиденциальность пользователей. Эта политика объясняет, как мы собираем, используем и защищаем вашу личную информацию.",
|
||||
"descriptionRights": "Пожалуйста, внимательно прочтите эту политику перед использованием наших услуг.",
|
||||
"section1": {
|
||||
"title": "Сбор информации",
|
||||
"desc": "Мы собираем следующие типы информации:",
|
||||
"authInfo": "Информация аутентификации",
|
||||
"authInfoDesc": "Включая имя пользователя, адрес электронной почты и зашифрованный хеш пароля.",
|
||||
"authInfoNote": "Мы не храним ваш пароль в виде открытого текста.",
|
||||
"logInfo": "Журнал запросов",
|
||||
"logInfoDesc": "Включая адрес IP, тип браузера, время доступа и путь запроса."
|
||||
},
|
||||
"section2": {
|
||||
"title": "Использование информации",
|
||||
"desc": "Информация, которую мы собираем, используется для следующих целей:",
|
||||
"storageSecurity": "Безопасность хранения",
|
||||
"storageSecurityDesc": "Все пользовательские данные зашифрованы и хранятся в защищенных серверных средах.",
|
||||
"encryption": "Меры шифрования",
|
||||
"encryptionDesc": "Пароли используют одностороннее хэш-шифрование bcrypt. Токены используют хеш-хранилище SHA-256.",
|
||||
"accessControl": "Контроль доступа",
|
||||
"accessControlDesc": "Строгое управление разрешениями гарантирует, что только авторизованный персонал сможет получить доступ к пользовательским данным."
|
||||
},
|
||||
"section3": {
|
||||
"title": "Цель использования информации",
|
||||
"desc": "Информация, которую мы собираем, используется для следующих целей:",
|
||||
"identity": "Проверка личности",
|
||||
"identityDesc": "Подтвердите личность пользователя и обеспечьте безопасность учетной записи.",
|
||||
"business": "Деловые операции",
|
||||
"businessDesc": "Предоставлять, поддерживать и улучшать наши услуги.",
|
||||
"riskControl": "Контроль рисков",
|
||||
"riskControlDesc": "Обнаруживайте, предотвращайте и устраняйте риски безопасности и мошеннические действия.",
|
||||
"prohibited": "Запрещенное использование",
|
||||
"prohibitedDesc": "Мы обязуемся не использовать информацию пользователя для: продажи третьим лицам, рассылки нежелательных коммерческих предложений."
|
||||
},
|
||||
"section4": {
|
||||
"title": "Обмен и раскрытие информации",
|
||||
"sharingPrinciple": "Принцип обмена",
|
||||
"sharingPrincipleDesc": "Мы не передаем личную информацию третьим лицам без согласия пользователя, за исключением следующих ситуаций:",
|
||||
"sharingException1": "Требуется законами, постановлениями или государственными органами посредством юридических процедур.",
|
||||
"sharingException2": "Необходимо для защиты законных прав платформы или общественности.",
|
||||
"transfer": "Передача информации",
|
||||
"transferDesc": "В случае слияния, поглощения или аналогичной сделки мы потребуем от нового владельца продолжать соблюдать данную политику."
|
||||
},
|
||||
"section5": {
|
||||
"title": "Права пользователя",
|
||||
"desc": "Пользователи имеют следующие права в отношении своей личной информации:",
|
||||
"viewManage": "Просмотр и управление",
|
||||
"viewManageDesc": "Пользователи могут просматривать и изменять свою личную информацию в любое время в своих настройках.",
|
||||
"deleteAccount": "Удаление аккаунта",
|
||||
"deleteAccountDesc": "Пользователи могут запросить удаление своей учетной записи и всех связанных с ней личных данных."
|
||||
},
|
||||
"section6": {
|
||||
"title": "Обновления политики",
|
||||
"desc": "Мы можем время от времени обновлять данную политику конфиденциальности. После обновлений мы опубликуем уведомление на платформе.",
|
||||
"notification": "О существенных изменениях будет сообщено через уведомление в приложении или по электронной почте."
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"uploadSuccess": "Файл успешно загружен",
|
||||
"uploadFailed": "Загрузка не удалась",
|
||||
"deleteSuccess": "Файл успешно удален",
|
||||
"deleteFailed": "Удаление не удалось",
|
||||
"searchPlaceholder": "Поиск по имени файла...",
|
||||
"private": "Приватный",
|
||||
"public": "Публичный",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadFile": "Загрузить файл",
|
||||
"noFiles": "Файлы еще не загружены",
|
||||
"noMatchingFiles": "Соответствующие файлы не найдены",
|
||||
"uploadFirstFile": "Загрузите свой первый файл. Поддерживает изображения, видео, документы и архивы.",
|
||||
"selectFileUpload": "Выберите файл для загрузки",
|
||||
"downloadFile": "Скачать файл",
|
||||
"deleteFile": "Удалить файл",
|
||||
"prevPage": "Назад",
|
||||
"nextPage": "Далее",
|
||||
"confirmDeleteFile": "Подтвердить удаление файла",
|
||||
"confirmDeleteFileDesc": "Удалить файл «{name}»? Этот файл невозможно восстановить после удаления.",
|
||||
"cancel": "Отмена",
|
||||
"confirmDelete": "Подтвердить удаление",
|
||||
"myFiles": "Мои файлы"
|
||||
},
|
||||
"general": {
|
||||
"manage": {
|
||||
"saveSuccess": "Сохранено",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Удаление не удалось",
|
||||
"loading": "Загрузка",
|
||||
"noData": "Нет данных",
|
||||
"configList": "Список конфигурации",
|
||||
"confirmDelete": "Подтвердить удаление?",
|
||||
"confirmDeleteDesc": "Это невозможно отменить. Это приведет к безвозвратному удалению конфигурации.",
|
||||
"cancel": "Отмена",
|
||||
"confirmDeleteAction": "Подтвердить удаление",
|
||||
"configInfo": "Информация о конфигурации",
|
||||
"selectConfigToView": "Выберите конфигурацию, чтобы просмотреть подробности",
|
||||
"updating": "Обновление",
|
||||
"update": "Обновить",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"passwordDialog": {
|
||||
"enterPassword": "Введите пароль",
|
||||
"enterPasswordDesc": "Для безопасности вашей учетной записи введите 6-значный пароль подтверждения.",
|
||||
"cancel": "Отмена",
|
||||
"verifying": "Проверка...",
|
||||
"confirm": "Подтвердить"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"banner": {
|
||||
"description": "Универсальная вторичная платформа разработки со встроенными функциями управления пользователями, аутентификацией, хранилищем файлов, планированием задач, push-уведомлениями и возможностями наблюдения, которые помогают разработчикам быстро начинать новые проекты.",
|
||||
"getStarted": "Начать",
|
||||
"hideHint": "Закрыть"
|
||||
},
|
||||
"navigation": {
|
||||
"title": "Навигация"
|
||||
},
|
||||
"quickLinks": {
|
||||
"profile": {
|
||||
"title": "Профиль",
|
||||
"description": "Просмотр и редактирование информации вашей учетной записи"
|
||||
},
|
||||
"apiDocs": {
|
||||
"title": "API документы",
|
||||
"description": "Полная ссылка API"
|
||||
},
|
||||
"userDocs": {
|
||||
"title": "Руководство пользователя",
|
||||
"description": "Краткое руководство"
|
||||
}
|
||||
},
|
||||
"adminLinks": {
|
||||
"settings": {
|
||||
"title": "Системные настройки",
|
||||
"description": "Настройка безопасности, операций и внешнего вида"
|
||||
},
|
||||
"systemConfig": {
|
||||
"title": "Параметры системы",
|
||||
"description": "Управление элементами конфигурации системы «ключ-значение»"
|
||||
},
|
||||
"userManagement": {
|
||||
"title": "Управление пользователями",
|
||||
"description": "Просмотр и управление всеми зарегистрированными пользователями"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Задачи",
|
||||
"description": "Очередь асинхронных задач и планирование запланированных задач"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Системные журналы",
|
||||
"description": "Журналы доступа пользователей и журналы выполнения приложений"
|
||||
}
|
||||
},
|
||||
"jumpNow": "Перейти",
|
||||
"enterNow": "Войдите сейчас",
|
||||
"adminConsole": "Консоль администратора",
|
||||
"developerSection": {
|
||||
"heading": "Удобен для разработчиков",
|
||||
"description": "Встроенные полные документы RESTful API, Swagger UI и TypeScript SDK примеры для быстрой интеграции.",
|
||||
"features": {
|
||||
"restful": "Полный RESTful API",
|
||||
"swagger": "Онлайн Swagger документы",
|
||||
"typescript": "TypeScript SDK примеры",
|
||||
"examples": "Богатые примеры использования"
|
||||
},
|
||||
"apiDocs": "Посмотреть документы API",
|
||||
"codeComment": "// Быстрый старт — пример вызова API",
|
||||
"codeQuickStart": "Быстрый старт"
|
||||
},
|
||||
"hero": {
|
||||
"subtitle": "Встроенная аутентификация пользователей, хранилище файлов, планирование задач,",
|
||||
"subtitleLine2": "push-уведомления и возможность наблюдения, которые помогут вам быстро запустить следующий проект.",
|
||||
"getStarted": "Начать",
|
||||
"learnMore": "Узнать больше",
|
||||
"features": {
|
||||
"readyToUse": "Готов к использованию",
|
||||
"highlyExtensible": "Высокая расширяемость",
|
||||
"industrialGrade": "Промышленный класс"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"description": "Универсальная платформа для вторичной разработки.",
|
||||
"product": "Продукт",
|
||||
"dashboard": "Обзор",
|
||||
"personalSettings": "Персональные настройки",
|
||||
"development": "Развитие",
|
||||
"quickStart": "Быстрый старт",
|
||||
"apiDocs": "API документы",
|
||||
"sourceCode": "Исходный код",
|
||||
"community": "Сообщество",
|
||||
"discussions": "Обсуждения",
|
||||
"privacyPolicy": "Политика конфиденциальности",
|
||||
"termsOfService": "Условия обслуживания"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"title": "OpenFlare",
|
||||
"description": "OpenFlare — управление пограничными узлами и обратным прокси"
|
||||
},
|
||||
"notFound": {
|
||||
"description": "Извините, страница, которую вы ищете, кажется затерянной в цифровой туманности. Пожалуйста, проверьте путь или вернитесь в безопасное место.",
|
||||
"previousPage": "Предыдущая страница",
|
||||
"home": "Главная"
|
||||
},
|
||||
"providers": {
|
||||
"titleUpdater": {
|
||||
"login": "Войти",
|
||||
"register": "Зарегистрироваться",
|
||||
"admin": "Админ",
|
||||
"dashboard": "Обзор"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dataComponents": {
|
||||
"noDataForTitle": "Нет данных: {title}",
|
||||
"noDistribution": "Нет данных о распределении",
|
||||
"noTrend": "Нет данных о динамике"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"languageNames": { "en": "English", "zh-CN": "Chinese" }
|
||||
"languageNames": {
|
||||
"en": "English",
|
||||
"zh-CN": "Chinese",
|
||||
"ru": "Russian"
|
||||
},
|
||||
"languageLoading": {
|
||||
"failed": "The interface could not finish loading. Check your connection and reload the page.",
|
||||
"reload": "Reload page"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"languageNames": {
|
||||
"en": "английский",
|
||||
"zh-CN": "китайский",
|
||||
"ru": "Русский"
|
||||
},
|
||||
"languageLoading": {
|
||||
"failed": "Не удалось загрузить интерфейс. Проверьте подключение и обновите страницу.",
|
||||
"reload": "Обновить страницу"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
{
|
||||
"languageNames": { "en": "英语", "zh-CN": "中文" }
|
||||
"languageNames": {
|
||||
"en": "英语",
|
||||
"zh-CN": "中文",
|
||||
"ru": "俄语"
|
||||
},
|
||||
"languageLoading": {
|
||||
"failed": "界面未能完成加载。请检查网络连接并重新加载页面。",
|
||||
"reload": "重新加载页面"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"layout": {
|
||||
"nav": {
|
||||
"home": "Главная",
|
||||
"myFiles": "Мои файлы",
|
||||
"users": "Пользователи",
|
||||
"tasks": "Задачи",
|
||||
"storage": "Хранилище",
|
||||
"database": "База данных",
|
||||
"push": "Уведомления",
|
||||
"messageGateway": "Обмен сообщениями",
|
||||
"logs": "Системные журналы",
|
||||
"system": "Конфигурация системы",
|
||||
"adminSettings": "Системные настройки",
|
||||
"demo": "Демо",
|
||||
"apiDocs": "Документация API",
|
||||
"usageDocs": "Руководство",
|
||||
"dashboard": "Обзор",
|
||||
"nodes": "Узлы",
|
||||
"proxyRoutes": "Маршруты прокси",
|
||||
"websites": "Домены",
|
||||
"certificates": "Сертификаты TLS",
|
||||
"dnsAccounts": "Учётные записи DNS",
|
||||
"cloudflare": "Cloudflare",
|
||||
"origins": "Серверы-источники",
|
||||
"responses": "Страницы ответов",
|
||||
"waf": "WAF",
|
||||
"ipGroups": "IP-группы",
|
||||
"rateLimits": "Лимиты запросов",
|
||||
"pages": "Страницы",
|
||||
"configVersions": "Версии",
|
||||
"accessLogs": "Журнал запросов",
|
||||
"performance": "Производительность",
|
||||
"applyLogs": "Журнал применения"
|
||||
},
|
||||
"groups": {
|
||||
"admin": "Администрирование",
|
||||
"docs": "Документация",
|
||||
"security": "Безопасность",
|
||||
"websites": "Сайты"
|
||||
},
|
||||
"user": {
|
||||
"adminRole": "Администратор",
|
||||
"userRole": "Пользователь",
|
||||
"notLoggedIn": "Не авторизован",
|
||||
"syncing": "Синхронизация аккаунта",
|
||||
"profile": "Мой профиль",
|
||||
"settings": "Настройки",
|
||||
"help": "Помощь",
|
||||
"logout": "Выйти",
|
||||
"unknownUser": "Неизвестный пользователь"
|
||||
},
|
||||
"logoutDialog": {
|
||||
"title": "Выйти",
|
||||
"description": "Вы уверены, что хотите выйти?",
|
||||
"loggingOut": "Выход из системы…",
|
||||
"confirm": "Выйти",
|
||||
"inProgress": "Выход из системы…",
|
||||
"failed": "Не удалось выйти из системы",
|
||||
"failedDescription": "При выходе произошла ошибка. Пожалуйста, попробуйте еще раз."
|
||||
},
|
||||
"header": {
|
||||
"notifications": "Уведомления",
|
||||
"settings": "Настройки",
|
||||
"search": "Поиск",
|
||||
"toggleFullWidth": "Переключить полную ширину",
|
||||
"toggleTheme": "Переключить тему",
|
||||
"changePasswordBanner": "В целях безопасности вашей учетной записи измените пароль сейчас.",
|
||||
"changePasswordAction": "Изменить пароль",
|
||||
"bannerTitle": "Объявление",
|
||||
"bannerLink": "Узнать больше",
|
||||
"dismiss": "Закрыть",
|
||||
"guest": "Гость"
|
||||
},
|
||||
"search": {
|
||||
"categoryPage": "Страница",
|
||||
"categoryFeature": "Возможность",
|
||||
"categorySetting": "Настройка",
|
||||
"categoryAdmin": "Администрирование",
|
||||
"tipPrefix": "Советы:",
|
||||
"tipSlash": "Вы также можете нажать /, чтобы открыть это диалоговое окно.",
|
||||
"tipArrow": "для навигации по элементам",
|
||||
"tipNewTab": "Открыть в новой вкладке",
|
||||
"tipDidYouKnow": "Знаете ли вы: поиск все еще совершенствуется",
|
||||
"searchPlaceholder": "Поиск страниц и функций...",
|
||||
"noResults": "Результаты не найдены. Попробуйте другой термин?",
|
||||
"openInNewTab": "Открыть в новой вкладке",
|
||||
"open": "Открыть",
|
||||
"closeSearch": "Закрыть поиск",
|
||||
"items": {
|
||||
"globalSettings": "Настройки",
|
||||
"appearance": "Внешний вид",
|
||||
"homeDesc": "Вернитесь к панели управления консоли",
|
||||
"usageDocsDesc": "Ознакомьтесь с руководствами по началу работы и примерами интеграции.",
|
||||
"nodesDesc": "Управление пограничными узлами, ретрансляторами и туннелями во внутреннюю сеть.",
|
||||
"proxyRoutesDesc": "Настройте обратные прокси, правила сопоставления, политики WAF и настройки кэша.",
|
||||
"websitesDesc": "Управление доменами сайтов, сертификатами и портами HTTP/HTTPS.",
|
||||
"certificatesDesc": "Выпускайте и управляйте сертификатами SSL/TLS с автоматическим продлением.",
|
||||
"dnsAccountsDesc": "Настройте учетные данные поставщика DNS для выдачи сертификатов и записей.",
|
||||
"originsDesc": "Управление серверами-источниками для обратного прокси и балансировки нагрузки.",
|
||||
"responsesDesc": "Настройка страниц ошибок сервера-источника и страниц для работы без сети.",
|
||||
"wafDesc": "Настройте правила брандмауэра веб-приложений для блокировки вредоносных запросов.",
|
||||
"ipGroupsDesc": "Определите списки IP для управления разрешением/запретом в WAF или маршрутах.",
|
||||
"rateLimitsDesc": "Настройка ограничений параллелизма и пропускной способности по умолчанию для пограничных сайтов.",
|
||||
"pagesDesc": "Загружайте или развертывайте статические сайты на глобальном хостинге CDN.",
|
||||
"configVersionsDesc": "Просмотр, сравнение, публикация и откат версий конфигурации",
|
||||
"accessLogsDesc": "Поиск по журналам доступа к всему сайту и сетевая аналитика",
|
||||
"applyLogsDesc": "Просмотр истории доставки, синхронизации и активации конфигурации узла.",
|
||||
"performanceDesc": "Настройте соединения, тайм-ауты прокси-сервера и основные параметры производительности.",
|
||||
"settingsDesc": "Настройка личных предпочтений приложения",
|
||||
"profileDesc": "Редактировать атрибуты псевдонима, аватара и профиля.",
|
||||
"appearanceDesc": "Настройте тему отображения (светлая/темная)",
|
||||
"adminSettingsDesc": "Управление источниками входа, регистрации и аутентификации (только администратор)",
|
||||
"systemDesc": "Изменение конфигурации среды выполнения основной платформы (только администратор)",
|
||||
"usersDesc": "Управление зарегистрированными пользователями и их статусом (только администратор)",
|
||||
"tasksDesc": "Запуск и просмотр фоновых задач и задач по расписанию (только для администратора)",
|
||||
"storageDesc": "Просмотр, поиск и очистка файлов в объектном хранилище (только для администратора)",
|
||||
"databaseDesc": "Отслеживайте размер таблицы, просматривайте физические таблицы и запускайте интерактивный SQL (только администратор)",
|
||||
"pushDesc": "Настройте каналы уведомлений по электронной почте, Lark и Telegram (только администратор)",
|
||||
"logsDesc": "Просмотр системных журналов и журналов выполнения фоновых задач (только администратор)"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"retry": "Повторить",
|
||||
"unknownError": "Произошла неизвестная ошибка. Пожалуйста, повторите попытку позже.",
|
||||
"viewDetails": "Просмотр сведений об ошибке",
|
||||
"unknownErrorShort": "Неизвестная ошибка"
|
||||
},
|
||||
"loading": {
|
||||
"loading": "Загрузка",
|
||||
"loadingDesc": "Получение данных..."
|
||||
},
|
||||
"empty": {
|
||||
"noData": "Нет данных",
|
||||
"noContent": "Записи не найдены"
|
||||
},
|
||||
"toggleSidebar": "Переключить боковую панель",
|
||||
"mainNavigation": "Основная навигация"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Обзор",
|
||||
"generatedAt": "Создано в {time}",
|
||||
"refresh": "Обновить",
|
||||
"loadingTitle": "Загрузка обзора",
|
||||
"loadingDesc": "Собираем данные о состоянии узлов, трафике и ресурсах…",
|
||||
"loadFailed": "Не удалось загрузить обзор: {error}",
|
||||
"emptyTitle": "Нет обзорных данных",
|
||||
"emptyDesc": "Система работает, но сводного обзора пока нет.",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"stats": {
|
||||
"requests24h": "Запросы за 24 часа",
|
||||
"requestsMeta": "Уникальные посетители за 24 часа {visitors} · ошибки {errors} · оценка QPS {qps}",
|
||||
"clusterCapacity": "Емкость кластера",
|
||||
"avgMemoryHighLoad": "Среднее использование памяти: {memory} · высокая нагрузка: CPU — {cpu}, память — {memoryNodes}, диск — {storage}",
|
||||
"nodeOverview": "Состояние узлов",
|
||||
"onlineOf": "/ {total} онлайн",
|
||||
"pending": "В ожидании {count}",
|
||||
"unhealthyOffline": "С ошибками: {unhealthy} · не в сети: {offline}"
|
||||
},
|
||||
"world": {
|
||||
"title": "Глобальный обзор",
|
||||
"description": "Расположение узлов, география запросов и текущее состояние системы.",
|
||||
"legendSource": "Источник",
|
||||
"legendHealthy": "В норме",
|
||||
"legendPressure": "Под нагрузкой",
|
||||
"legendUnhealthy": "С ошибками",
|
||||
"modeVisitorSource": "Источники посетителей",
|
||||
"modeNodeCoords": "Координаты узла",
|
||||
"modeCoverage": "Покрытие",
|
||||
"onlineCoverage": "Онлайн-покрытие",
|
||||
"onlineHint": "{online}/{total} онлайн",
|
||||
"runHealth": "Состояние выполнения",
|
||||
"unhealthyCount": "С ошибками: {count}",
|
||||
"requests24h": "Запросы за 24 часа",
|
||||
"qpsReported": "QPS {qps} · узлов с данными: {count}",
|
||||
"nodeCoords": "Координаты узла",
|
||||
"sourceCountries": "Стран-источников: {count}",
|
||||
"fallbackPins": "Если координаты не заданы, используются стандартные отметки на карте.",
|
||||
"avgCpu": "Средняя загрузка CPU",
|
||||
"highCount": "С высокой нагрузкой: {count}",
|
||||
"avgMemory": "Среднее использование памяти",
|
||||
"highStorage": "Высокая заполненность диска",
|
||||
"offlinePending": "{offline} оффлайн · {pending} в ожидании",
|
||||
"mapPreparing": "Подготовка карты",
|
||||
"mapPreparingDesc": "Загружается при входе в область просмотра",
|
||||
"noNodesOverlay": "Узлов пока нет. После их подключения на карте появится географическое распределение."
|
||||
},
|
||||
"map": {
|
||||
"sourceRequests": "Запросов за последние 24 часа: {count}",
|
||||
"presetPin": "Стандартная отметка",
|
||||
"requestsErrors": "Запросы {requests} · ошибки {errors}",
|
||||
"eventsStatus": "Активные события {events} · статус узла {status}",
|
||||
"openrestyStatus": "Состояние OpenResty: {status}",
|
||||
"loadFailed": "Не удалось загрузить карту мира.",
|
||||
"loading": "Загрузка карты мира",
|
||||
"loadFailedDesc": "Карту мира ECharts не удалось зарегистрировать. Обновите и повторите попытку.",
|
||||
"loadingDesc": "Инициализация карты мира после того, как будет готов первый экран.",
|
||||
"measuring": "Измерение размера контейнера карты..."
|
||||
},
|
||||
"trafficTrend": {
|
||||
"title": "Запросы за 24 часа",
|
||||
"description": "Количество запросов по часам и распределение ответов 2xx/4xx/5xx для выявления всплесков ошибок.",
|
||||
"summaryHint": "Последние 24 часа",
|
||||
"requests": "Запросы",
|
||||
"status2xx": "2xx запросов",
|
||||
"status4xx": "4xx запросов",
|
||||
"status5xx": "5xx запросов"
|
||||
},
|
||||
"capacityTrend": {
|
||||
"title": "Использование ресурсов за 24 часа",
|
||||
"description": "Средняя загрузка CPU и использование памяти по часам для оценки доступных ресурсов кластера.",
|
||||
"avgCpu": "Средняя загрузка CPU",
|
||||
"avgMemory": "Среднее использование памяти"
|
||||
},
|
||||
"trafficCapacity": {
|
||||
"title": "Динамика 24-часового трафика и пропускной способности",
|
||||
"networkHint": "За последние 24 часа · из журналов доступа",
|
||||
"received": "Полученные данные",
|
||||
"provided": "Данные переданы",
|
||||
"capacityHint": "Последние 24 часа · емкость хоста · среднее значение"
|
||||
},
|
||||
"distributions": {
|
||||
"sourceTitle": "География запросов",
|
||||
"sourceDesc": "Страны с наибольшим количеством запросов за последние 24 часа.",
|
||||
"sourceEmpty": "Нет данных о географии запросов",
|
||||
"statusTitle": "Коды ответа HTTP",
|
||||
"statusDesc": "Проверьте, доминируют ли по-прежнему успешные ответы и не увеличивается ли число ошибок.",
|
||||
"statusEmpty": "Нет данных о кодах ответа",
|
||||
"httpLabel": "HTTP {code}",
|
||||
"domainTitle": "Популярные домены",
|
||||
"domainDesc": "Посмотрите, какие домены концентрируют большую часть трафика.",
|
||||
"domainEmpty": "Нет данных о доменах"
|
||||
},
|
||||
"nodeHealth": {
|
||||
"title": "Состояние узла",
|
||||
"description": "Сортировка по активным инцидентам и нехватке ресурсов.",
|
||||
"openNodes": "Перейти к узлам",
|
||||
"emptyTitle": "Нет узлов",
|
||||
"emptyDesc": "После присоединения узлов в этом списке будут отображаться сводные данные о работоспособности и емкости.",
|
||||
"colNode": "Узел",
|
||||
"colStatus": "Статус",
|
||||
"colCpuMem": "CPU / Память",
|
||||
"colReqErr": "Запросы/Ошибки",
|
||||
"colEvents": "Активные инциденты",
|
||||
"colHeartbeat": "Последний сигнал",
|
||||
"colActions": "Действия",
|
||||
"noGeo": "Точка карты не настроена",
|
||||
"wsConnected": "WS подключено",
|
||||
"na": "N/A",
|
||||
"detail": "Подробности"
|
||||
},
|
||||
"nodeRank": {
|
||||
"title": "Рейтинг узлов",
|
||||
"traffic": "Самый высокий трафик",
|
||||
"trafficEmpty": "Нет рейтинга трафика",
|
||||
"pressure": "Высокая нагрузка",
|
||||
"pressureEmpty": "Нет данных о нагрузке"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Узлы",
|
||||
"applyLogs": "Журнал применения",
|
||||
"create": "Добавить узел",
|
||||
"listTitle": "Список узлов",
|
||||
"refreshNow": "Обновить сейчас",
|
||||
"loadingList": "Загрузка узлов...",
|
||||
"emptyAll": "Узлов пока нет. Создайте его, чтобы начать.",
|
||||
"emptyFilter": "Ни один узел не соответствует текущему фильтру.",
|
||||
"updated": "Узел обновлен",
|
||||
"created": "Узел создан",
|
||||
"deleted": "Узел удален.",
|
||||
"deleteTitle": "Удалить узел",
|
||||
"deleteDesc": "Удалить узел «{name}»? Вам нужно будет воссоздать его и снова присоединиться к нему.",
|
||||
"deleting": "Удаление...",
|
||||
"confirmDelete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"delete": "Удалить",
|
||||
"refresh": "Обновить",
|
||||
"detail": "Подробности",
|
||||
"na": "N/A",
|
||||
"wsConnected": "WS подключено",
|
||||
"wsEstablished": "Соединение WebSocket установлено.",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"unknown": "Неизвестно",
|
||||
"running": "Выполняется",
|
||||
"liveConfig": "Живая конфигурация",
|
||||
"notApplied": "Не применяется",
|
||||
"enabled": "Включено",
|
||||
"manual": "Вручную",
|
||||
"lockedSuffix": " (заблокировано)",
|
||||
"noGeo": "Точка карты не настроена",
|
||||
"notConfigured": "Не настроено",
|
||||
"ipLine": "IP: {ip}",
|
||||
"locationLine": "Местоположение: {location}",
|
||||
"backToList": "Вернуться к узлам",
|
||||
"missingId": "Отсутствует узел ID. Откройте детали из списка узлов.",
|
||||
"loadingDetail": "Загрузка сведений об узле...",
|
||||
"notFound": "Узел не найден. Возможно, он был удален или ID недействителен.",
|
||||
"syncing": "Синхронизация...",
|
||||
"forceSync": "Принудительная синхронизация",
|
||||
"dispatching": "Отправка...",
|
||||
"viewUpgrade": "Посмотреть обновление",
|
||||
"tabOverview": "Обзор",
|
||||
"tabManage": "Статус и развертывание",
|
||||
"kpiNodeId": "ID узла",
|
||||
"kpiLastSeen": "Последний сигнал",
|
||||
"kpiCurrentConfig": "Текущая конфигурация",
|
||||
"filter": {
|
||||
"all": "Все узлы",
|
||||
"descAll": "Показаны все узлы.",
|
||||
"descRelay": "Показаны только ретрансляторы.",
|
||||
"descTunnel": "Показаны только узлы туннеля.",
|
||||
"descEdge": "Показаны только пограничные узлы."
|
||||
},
|
||||
"status": {
|
||||
"online": "Онлайн",
|
||||
"pending": "Ожидает",
|
||||
"offline": "Оффлайн",
|
||||
"running": "Выполняется",
|
||||
"wsConnected": "WS подключено"
|
||||
},
|
||||
"apply": {
|
||||
"success": "Успешно",
|
||||
"warning": "Предупреждение",
|
||||
"failed": "Ошибка",
|
||||
"none": "N/A"
|
||||
},
|
||||
"health": {
|
||||
"healthy": "В норме",
|
||||
"unhealthy": "С ошибками",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"relative": {
|
||||
"justNow": "только что",
|
||||
"minutesAgo": "{count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}} назад",
|
||||
"hoursAgo": "{count, plural, one {# час} few {# часа} many {# часов} other {# часа}} назад",
|
||||
"daysAgo": "{count, plural, one {# день} few {# дня} many {# дней} other {# дня}} назад",
|
||||
"monthsAgo": "{count, plural, one {# месяц} few {# месяца} many {# месяцев} other {# месяца}} назад"
|
||||
},
|
||||
"uptime": {
|
||||
"daysHours": "{days} дн. {hours} ч",
|
||||
"hoursMinutes": "{hours} ч {minutes} мин",
|
||||
"minutes": "{minutes} мин"
|
||||
},
|
||||
"channel": {
|
||||
"preview": "предварительный просмотр",
|
||||
"stable": "стабильный"
|
||||
},
|
||||
"table": {
|
||||
"node": "Узел",
|
||||
"status": "Статус",
|
||||
"version": "Версия",
|
||||
"runHealth": "Состояние выполнения",
|
||||
"currentVersion": "Текущая версия",
|
||||
"latestApply": "Последняя подача заявки",
|
||||
"lastSeen": "Последний сигнал",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"editor": {
|
||||
"editTitle": "Редактировать узел",
|
||||
"createTitle": "Добавить узел",
|
||||
"description": "После создания узла откройте его данные для выделенного токена и выполните команду развертывания.",
|
||||
"nodeType": "Тип узла",
|
||||
"typeEdge": "Краевой узел",
|
||||
"typeRelay": "Узел-ретранслятор",
|
||||
"typeTunnel": "Туннельный узел",
|
||||
"name": "Имя узла",
|
||||
"namePlaceholder": "например край-hk-01",
|
||||
"ip": "Узел IP",
|
||||
"ipPlaceholder": "Необязательно; сообщается автоматически после присоединения",
|
||||
"lockIp": "Блокировка IP",
|
||||
"lockIpDesc": "Если включено, сервер управления не будет перезаписывать IP-адрес узла.",
|
||||
"autoUpdate": "Агент автоматического обновления",
|
||||
"autoUpdateDesc": "Если этот параметр включен, узел автоматически получает стабильные обновления.",
|
||||
"bindPort": "Привязать порт",
|
||||
"vhostPort": "Порт хоста",
|
||||
"manualGeo": "Ручная точка карты",
|
||||
"manualGeoDesc": "Используется на обзорной карте",
|
||||
"geoName": "Название местоположения",
|
||||
"latitude": "Широта",
|
||||
"longitude": "Долгота",
|
||||
"saving": "Сохранение...",
|
||||
"saveEdit": "Сохранить изменения",
|
||||
"createAction": "Добавить узел",
|
||||
"errName": "Введите имя узла",
|
||||
"errLockIp": "Узел IP требуется, когда IP заблокирован.",
|
||||
"errBindPort": "Введите действительный порт привязки",
|
||||
"errVhostPort": "Введите действительный порт VHost"
|
||||
},
|
||||
"install": {
|
||||
"edgeTitle": "Развертывание пограничного узла",
|
||||
"edgeDesc": "Используйте токен агента для подключения пограничного узла к серверу управления.",
|
||||
"relayTitle": "Команда развертывания реле",
|
||||
"relayDesc": "Используйте токен обнаружения для подключения ретранслятора frps к серверу управления.",
|
||||
"tunnelTitle": "Команда развертывания туннеля",
|
||||
"tunnelDesc": "Используйте токен туннеля для подключения клиента openflared к серверу управления.",
|
||||
"agentToken": "Токен агента",
|
||||
"discoveryToken": "Токен открытия",
|
||||
"tunnelToken": "Туннельный токен",
|
||||
"scriptLabel": "Однострочный скрипт (Linux / macOS)",
|
||||
"dockerLabel": "Docker развертывание контейнера",
|
||||
"copyFailed": "Копировать не удалось. Выберите текст команды вручную.",
|
||||
"copiedScript": "Скрипт развертывания скопирован.",
|
||||
"copiedDocker": "Docker команда развертывания скопирована",
|
||||
"copyScript": "Копировать скрипт",
|
||||
"copyDocker": "Копировать Docker",
|
||||
"nodeId": "Узел ID",
|
||||
"serverUrl": "URL сервера управления",
|
||||
"serverUrlHint": "Скрипт и контейнер используют этот адрес для подключения к серверу управления OpenFlare.",
|
||||
"noToken": "У этого узла еще нет токена доступа. Обновите позже.",
|
||||
"needServerUrl": "Введите действительный сервер URL, чтобы сгенерировать команду развертывания."
|
||||
},
|
||||
"update": {
|
||||
"title": "Обновление агента",
|
||||
"description": "Проверьте выпуски GitHub и отправьте обновление. Узел запускает его после следующего такта.",
|
||||
"currentVersion": "Текущая версия",
|
||||
"channel": "Канал выпуска",
|
||||
"updateStatus": "Обновить статус",
|
||||
"waitingPreview": "Ожидание обновления предварительного просмотра",
|
||||
"waitingUpdate": "Ожидание обновления",
|
||||
"notRequested": "Не отправлено",
|
||||
"checking": "Проверяем выпуски...",
|
||||
"hasUpdate": "Доступно обновление",
|
||||
"upToDate": "Уже в курсе",
|
||||
"previewRelease": "Предварительный выпуск",
|
||||
"stableRelease": "Стабильная версия",
|
||||
"targetVersion": "Целевая версия",
|
||||
"notFound": "Не найден",
|
||||
"publishedAt": "Опубликовано",
|
||||
"noNotes": "Нет примечаний к выпуску",
|
||||
"viewRelease": "Посмотреть выпуск",
|
||||
"checkStable": "Проверить стабильную версию",
|
||||
"checkPreview": "Проверить предварительный просмотр",
|
||||
"upgradePreview": "Обновите до предварительной версии",
|
||||
"upgradeStable": "Обновить до стабильной версии"
|
||||
},
|
||||
"edge": {
|
||||
"upgrade": "Агент обновления",
|
||||
"restartOpenresty": "Перезагрузить OpenResty",
|
||||
"runStatus": "Статус выполнения",
|
||||
"runStatusDesc": "Доступность узла и состояние OpenResty",
|
||||
"openrestyHealth": "Состояние OpenResty",
|
||||
"lastSeen": "Последний сигнал",
|
||||
"ip": "IP адрес",
|
||||
"geo": "Точка карты",
|
||||
"syncTitle": "Синхронизация конфигурации",
|
||||
"syncDesc": "Обновление версии и последний результат применения",
|
||||
"currentVersion": "Текущая версия конфигурации",
|
||||
"latestApply": "Последняя подача заявки",
|
||||
"latestApplyAt": "Последнее время подачи заявки",
|
||||
"agentVersion": "Версия агента",
|
||||
"nginxVersion": "Версия Nginx",
|
||||
"autoUpdate": "Автоматическое обновление",
|
||||
"runtimeMessages": "Сообщения времени выполнения",
|
||||
"runtimeMessagesDesc": "Примечания о состоянии сообщили OpenResty и агент",
|
||||
"openrestyMessage": "OpenResty сообщение о состоянии",
|
||||
"noExtraError": "Никаких дополнительных ошибок не сообщается.",
|
||||
"restartPending": "Ожидание перезапуска узла OpenResty после следующего контрольного сигнала.",
|
||||
"kpiAgent": "Версия агента",
|
||||
"connectionHint": "Текущие соединения OpenResty",
|
||||
"toastUpdated": "Узел обновлен",
|
||||
"toastForceSync": "Принудительная синхронизация отправлена на узел {name}",
|
||||
"toastRestart": "OpenResty перезапуск отправлен на узел {name}",
|
||||
"toastUpgrade": "{channel} обновление отправлено на узел {name}",
|
||||
"toastDeleted": "Узел удален."
|
||||
},
|
||||
"relay": {
|
||||
"upgrade": "Обновление реле",
|
||||
"runStatus": "Время работы реле",
|
||||
"runStatusDesc": "онлайн-состояние и сводка нагрузки frps",
|
||||
"health": "Состояние ретранслятора",
|
||||
"connections": "Активные соединения/прокси",
|
||||
"portsTitle": "Сетевые порты",
|
||||
"portsDesc": "Плоскость управления frps и настройки vhost HTTP",
|
||||
"bindPort": "Порт привязки управления",
|
||||
"vhostPort": "Порт VHost HTTP",
|
||||
"agentAddr": "Адрес доступа агента",
|
||||
"relayVersion": "Версия реле",
|
||||
"frpsVersion": "основная версия frps",
|
||||
"webuiTitle": "FRPS Веб-интерфейс",
|
||||
"webuiDesc": "Включите встроенную веб-консоль frps и установите ее порт прослушивания.",
|
||||
"enableWebui": "Включить веб-консоль",
|
||||
"enableWebuiHint": "Когда этот параметр включен, узел запускает встроенную службу администрирования frps.",
|
||||
"webuiPort": "Порт веб-консоли",
|
||||
"webuiPortPlaceholder": "e.g. 17500",
|
||||
"actualPort": "Сейчас используется порт {port}. Изменения вступят в силу после следующего сигнала узла. По умолчанию: основной порт + 500.",
|
||||
"openWebui": "Откройте веб-интерфейс FRPS",
|
||||
"copiedLink": "Ссылка скопирована",
|
||||
"copyLink": "Копировать ссылку",
|
||||
"webuiDisabled": "WebUI отключен или порт привязки не установлен.",
|
||||
"metadata": "Метаданные узла",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"kpiFrps": "Ядро frps",
|
||||
"connectionHint": "Релейные активные соединения",
|
||||
"invalidPort": "Порт должен быть между 1 и 65535.",
|
||||
"portSaveFailed": "Не удалось обновить порт",
|
||||
"toastPortSaved": "FRPS Порт WebUI обновлен.",
|
||||
"toastUpdated": "Узел-ретранслятор обновлен",
|
||||
"toastWebui": "FRPS Настройки веб-интерфейса обновлены.",
|
||||
"toastForceSync": "Команда принудительной синхронизации отправлена ретранслятору {name}",
|
||||
"toastUpgrade": "{channel} обновление отправлено на ретранслятор {name}",
|
||||
"toastDeleted": "Узел-ретранслятор удален.",
|
||||
"deleteTitle": "Удалить узел реле",
|
||||
"deleteDesc": "Удалить ретранслятор «{name}»? Для повторного подключения его потребуется создать заново."
|
||||
},
|
||||
"tunnel": {
|
||||
"upgrade": "Обновление openflared",
|
||||
"runStatus": "Время работы туннеля",
|
||||
"runStatusDesc": "openflared соединение и онлайн-состояние",
|
||||
"flaredStatus": "Состояние openflared",
|
||||
"autoUpdate": "Автоматическое обновление",
|
||||
"syncTitle": "Синхронизация конфигурации",
|
||||
"syncDesc": "Обновление конфигурации туннельного клиента",
|
||||
"currentVersion": "Текущая версия конфигурации",
|
||||
"latestApply": "Последняя подача заявки",
|
||||
"latestApplyAt": "Последнее время подачи заявки",
|
||||
"fileCount": "Синхронизированные файлы",
|
||||
"fileCountValue": "{count}",
|
||||
"upgradeStatus": "Статус обновления",
|
||||
"credentials": "Учетные данные для присоединения",
|
||||
"credentialsDesc": "Токен, необходимый туннельному клиенту",
|
||||
"tunnelToken": "Туннельный токен",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"kpiOpenflared": "openflared",
|
||||
"connectionHint": "Одновременные соединения через туннель",
|
||||
"toastUpdated": "Узел туннеля обновлен.",
|
||||
"toastForceSync": "Принудительная синхронизация отправлена в туннель {name}",
|
||||
"toastUpgrade": "{channel} обновление отправлено в туннель {name}",
|
||||
"toastDeleted": "Узел туннеля удален.",
|
||||
"deleteTitle": "Удалить туннельный узел",
|
||||
"deleteDesc": "Удалить туннельный узел «{name}»? Вам нужно будет воссоздать его и снова присоединиться к нему."
|
||||
},
|
||||
"obs": {
|
||||
"hostname": "Имя хоста",
|
||||
"os": "Операционная система",
|
||||
"kernelArch": "Ядро / арка",
|
||||
"uptime": "Время работы",
|
||||
"cpu": "CPU",
|
||||
"cpuCores": "{count, plural, one {# ядро} few {# ядра} many {# ядер} other {# ядра}}",
|
||||
"totalMemory": "Общий объем памяти",
|
||||
"totalStorage": "Общий объём хранилища",
|
||||
"reportedAt": "Об этом сообщается на",
|
||||
"eventsTitle": "История событий состояния узла",
|
||||
"eventsDesc": "Сохраняет активные и восстановленные события, чтобы помочь оценить работоспособность среды выполнения.",
|
||||
"cleanupLogs": "Очистить журналы",
|
||||
"filterAll": "Все события",
|
||||
"filterActive": "Активный",
|
||||
"filterResolved": "Восстановлено",
|
||||
"filterEmpty": "Для этого фильтра нет событий работоспособности.",
|
||||
"noMessage": "Нет подробного сообщения",
|
||||
"firstTriggered": "Первое срабатывание:",
|
||||
"lastTriggered": "Последний запуск:",
|
||||
"resolvedAt": "Восстановлено:",
|
||||
"noEvents": "Этот узел еще не сообщил ни о каких событиях работоспособности.",
|
||||
"cleaned": "Удалено записей о состоянии: {count}",
|
||||
"nothingToClean": "Нет журналов событий работоспособности, которые нужно очистить",
|
||||
"loading": "Загрузка данных наблюдения...",
|
||||
"diag": "Диагностика во время выполнения",
|
||||
"activeIssues": "Активных инцидентов: {count}",
|
||||
"stable": "Стабильный",
|
||||
"noActiveEvents": "Нет активных событий состояния",
|
||||
"systemCore": "Ядро системы",
|
||||
"uptimeFromProfile": "Из профиля системы узла",
|
||||
"profileTitle": "Профиль системы",
|
||||
"profileDesc": "Информация о хосте и оборудовании, сообщаемая узлом",
|
||||
"noProfile": "Узел присоединился, но еще не предоставил полный профиль системы.",
|
||||
"noProfileLong": "Узел присоединился, но еще не предоставил полный профиль системы.",
|
||||
"snapshotTitle": "Живой снимок ресурса",
|
||||
"snapshotDesc": "Последний отчет по показателям",
|
||||
"realtimeResources": "Живые ресурсы",
|
||||
"memory": "Память",
|
||||
"storage": "Хранилище",
|
||||
"connections": "Соединения",
|
||||
"snapshotAgo": "Снимок {time}",
|
||||
"noSnapshot": "Узел присоединился, но еще не сообщил о снимке ресурса.",
|
||||
"noSnapshotLong": "Узел присоединился, но еще не сообщил о снимке ресурса.",
|
||||
"cleanupTitle": "Очистить журнал событий состояния",
|
||||
"cleanupDesc": "Вся история диагностики этого узла на сервере управления будет удалена. Новые события продолжат поступать.",
|
||||
"cleaning": "Очистка...",
|
||||
"confirmCleanup": "Чистый",
|
||||
"windowRequests": "Запросы окон",
|
||||
"windowVisitorsHint": "Уникальные посетители окна {visitors} · частота ошибок {rate}%",
|
||||
"noWindowSummary": "Сводка окна «Нет запроса»",
|
||||
"capacityPressure": "Давление производительности",
|
||||
"needsAttention": "Требует внимания",
|
||||
"normalRange": "Нормальный",
|
||||
"cpuStorage": "CPU {cpu} · хранилище {storage}",
|
||||
"noResourceSnapshot": "Нет снимка ресурса",
|
||||
"sourceSignal": "Исходный сигнал",
|
||||
"requestTimes": "{count, plural, one {# запрос} few {# запроса} many {# запросов} other {# запроса}}",
|
||||
"noSourceDist": "Нет данных о распространении источника",
|
||||
"systemInfo": "Информация о системе",
|
||||
"networkTraffic": "Сетевой трафик",
|
||||
"noActiveIssue": "Нет активных инцидентов",
|
||||
"openrestyConn": "OpenResty соединения",
|
||||
"currentConn": "Текущие связи:",
|
||||
"throughputHint": "Просмотр тенденций журнала доступа для бизнес-пропускной способности (обслужено/получено)",
|
||||
"windowVisitors": "Уникальные посетители окна",
|
||||
"reqQps": "Запросы {requests} · расчетное QPS {qps}",
|
||||
"noTrafficSummary": "Нет окна сводки трафика",
|
||||
"windowErrors": "Ошибки окна",
|
||||
"errorRate": "Частота ошибок {rate}%",
|
||||
"noErrorSummary": "Нет сводки по частоте ошибок",
|
||||
"noNetworkSnapshot": "Узел присоединился, но еще не сообщил о снимке сетевого трафика.",
|
||||
"trafficTrendDesc": "Почасовое количество запросов и ошибок для этого узла.",
|
||||
"capacityTrendDesc": "CPU и использование памяти для этого узла за 24 часа.",
|
||||
"structureTitle": "Структура запроса",
|
||||
"structureDesc": "Последние 24 часа: какие коды статуса и домены концентрируют трафик и ошибки.",
|
||||
"mainStatus": "Верхний код статуса",
|
||||
"times": "{count, plural, one {# раз} few {# раза} many {# раз} other {# раза}}",
|
||||
"noStatusDist": "Нет распределения кода состояния",
|
||||
"topDomain": "Топ Домен",
|
||||
"noDomainDist": "Нет распределения доменов",
|
||||
"resolvedEvents": "Восстановленные события",
|
||||
"resolvedHint": "Устранённые события состояния за последние 24 часа",
|
||||
"statusDist": "Распределение кода состояния",
|
||||
"distEmpty": "Нет данных о распространении",
|
||||
"unknownLabel": "Неизвестно"
|
||||
},
|
||||
"networkTrend": {
|
||||
"title": "24-часовой сетевой тренд",
|
||||
"description": "Почасовые данные, обслуживаемые/полученные из журналов доступа; сводка — это итоговые данные за последние 24 часа.",
|
||||
"summaryHint": "Последние 24 часа",
|
||||
"received": "Полученные данные",
|
||||
"provided": "Данные переданы"
|
||||
},
|
||||
"diskIo": {
|
||||
"title": "24-часовой диск IO тренд",
|
||||
"description": "Почасовая скорость чтения/записи диска (Б/с) для выявления увеличения журнала, дрожания кэша или нагрузки на диск.",
|
||||
"summaryHint": "Последние 24 часа · средняя цена",
|
||||
"read": "Чтение диска",
|
||||
"write": "Запись на диск"
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"title": "Производительность",
|
||||
"description": "Управление глобальными настройками производительности OpenResty. Сохранение входит в единый процесс публикации конфигурации.",
|
||||
"preview": "Посмотреть предварительный просмотр конфигурации",
|
||||
"saved": "Настройки производительности сохранены.",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"loadingAuth": "Загрузка разрешений...",
|
||||
"forbiddenTitle": "Недостаточно разрешений",
|
||||
"forbiddenDesc": "Доступ к настройкам производительности имеют только администраторы.",
|
||||
"loading": "Загрузка настроек производительности...",
|
||||
"runtimeTitle": "Связи и события",
|
||||
"runtimeDesc": "Рабочие процессы, тайм-ауты и размер тела запроса",
|
||||
"proxyTitle": "Прокси-буферы и таймауты",
|
||||
"proxyDesc": "Восходящие соединения, буферизация запросов/ответов и переключатели WebSocket/HTTP3",
|
||||
"proxyRequestBuffering": "Буферизация тела запроса (отключить для потоковой загрузки)",
|
||||
"gzipTitle": "Сжатие",
|
||||
"cacheTitle": "Кэш",
|
||||
"cacheDesc": "Кэш обратного прокси-сервера с одним узлом",
|
||||
"errors": {
|
||||
"workerProcesses": "worker_processes должно быть автоматическим или целым числом больше 0.",
|
||||
"timeouts": "Значения времени ожидания и соединения должны быть целыми числами больше 0.",
|
||||
"statusCode": "Статус возврата сервера по умолчанию должен находиться в диапазоне от 100 до 999.",
|
||||
"bodySize": "Неверный формат client_max_body_size.",
|
||||
"headerBuffers": "large_client_header_buffers должно выглядеть как «4 16k»",
|
||||
"proxyTimeouts": "Тайм-ауты прокси-сервера должны быть целыми числами больше 0 секунд.",
|
||||
"proxyBuffers": "proxy_buffers должно выглядеть как «16 16k»",
|
||||
"bufferSize": "Размеры буфера должны быть целыми числами или значениями в единицах к/м/г.",
|
||||
"gzipMinLength": "gzip_min_length должно быть целым числом больше 0",
|
||||
"gzipLevel": "gzip_comp_level должно быть от 1 до 9",
|
||||
"cachePath": "proxy_cache_path требуется, если кэш включен.",
|
||||
"cacheFormat": "Неверные уровни кэша, неактивный формат max_size или lock_timeout.",
|
||||
"cacheKey": "Шаблон ключа кэша необходим, если кэш включен."
|
||||
}
|
||||
},
|
||||
"applyLogs": {
|
||||
"title": "Журнал применения конфигурации",
|
||||
"description": "Проверьте успешную, предупреждающую и неудачную конфигурацию. Фильтровать по node_id.",
|
||||
"refresh": "Обновить",
|
||||
"clear": "Очистить журналы",
|
||||
"backToNodes": "Вернуться к узлам",
|
||||
"totalRecords": "Всего записей",
|
||||
"currentPage": "Текущая страница",
|
||||
"totalPages": "Всего страниц",
|
||||
"pageNodes": "Узлы на этой странице",
|
||||
"nodeId": "Узел ID",
|
||||
"nodePlaceholder": "Фильтровать журналы применения по node_id",
|
||||
"pageSize": "Размер страницы",
|
||||
"pageSizeOption": "{count}",
|
||||
"filter": "Фильтр",
|
||||
"reset": "Сбросить фильтры",
|
||||
"emptyTitle": "Нет журналов применения",
|
||||
"emptyDesc": "Ни одна запись применения не соответствует текущим фильтрам.",
|
||||
"colNodeId": "Узел ID",
|
||||
"colVersion": "Версия",
|
||||
"colResult": "Результат",
|
||||
"colChecksum": "Контрольная сумма",
|
||||
"colTime": "Время",
|
||||
"colMessage": "Сообщение",
|
||||
"colActions": "Действия",
|
||||
"detail": "Подробности",
|
||||
"pageInfo": "Страница {current}/{total}, записей: {count}.",
|
||||
"prev": "Назад",
|
||||
"next": "Далее",
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"cleared": "Журнал применения конфигурации очищен",
|
||||
"clearedDesc": "Удалено записей: {count}",
|
||||
"clearFailed": "Не удалось очистить журналы применения.",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"clearTitle": "Очистить все журналы применения?",
|
||||
"clearDesc": "Все записи журнала применения конфигурации ({count}) будут удалены. Это действие нельзя отменить. Узлы продолжат отправлять новые результаты.",
|
||||
"clearing": "Очистка...",
|
||||
"confirmClear": "Очистить",
|
||||
"success": "Успешно",
|
||||
"warning": "Предупреждение",
|
||||
"failed": "Ошибка",
|
||||
"sheetTitle": "Применить данные журнала",
|
||||
"sheetDesc": "Полный результат и сведения о контрольной сумме для этого журнала применения.",
|
||||
"versionBadge": "Версия: {version}",
|
||||
"createdAt": "Создано",
|
||||
"targetChecksum": "Целевая контрольная сумма",
|
||||
"mainChecksum": "Дайджест основной конфигурации",
|
||||
"routeChecksum": "Дайджест конфигурации маршрута",
|
||||
"supportFiles": "Файлы поддержки",
|
||||
"message": "Сообщение",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"configVersions": {
|
||||
"title": "Версии конфигурации",
|
||||
"description": "Просматривайте исторические снимки, просматривайте ожидающие изменения и при необходимости повторно активируйте старую версию.",
|
||||
"refresh": "Обновить",
|
||||
"cleanupOld": "Чистый",
|
||||
"viewDiff": "Разница",
|
||||
"forcePublish": "Принудительно опубликовать",
|
||||
"previewPublish": "Опубликовать",
|
||||
"emptyTitle": "Нет истории",
|
||||
"emptyDesc": "Опубликованных версий пока нет. Сначала опубликуйте конфигурацию.",
|
||||
"colVersion": "Версия",
|
||||
"colStatus": "Статус",
|
||||
"colAuthor": "Создано",
|
||||
"colChecksum": "Контрольная сумма",
|
||||
"colCreatedAt": "Создано",
|
||||
"colActions": "Действия",
|
||||
"active": "Активный",
|
||||
"history": "История",
|
||||
"system": "Система",
|
||||
"snapshot": "Снимок",
|
||||
"activate": "Активировать",
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"previewLoadFailed": "Не удалось загрузить предварительный просмотр.",
|
||||
"diffLoadFailed": "Не удалось загрузить диф.",
|
||||
"publishSuccess": "Опубликовано",
|
||||
"publishSuccessDesc": "Версия {version}",
|
||||
"publishFailed": "Опубликовать не удалось",
|
||||
"activateSuccess": "Активировано",
|
||||
"activateSuccessDesc": "Версия {version}",
|
||||
"activateFailed": "Активировать не удалось",
|
||||
"cleanupDone": "Очистка завершена",
|
||||
"cleanupDoneDesc": "Удалено снимков: {count}",
|
||||
"cleanupFailed": "Очистка не удалась",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"confirmPublishTitle": "Опубликовать конфигурацию",
|
||||
"confirmPublishDesc": "Это создаст новую версию из ожидающей конфигурации и пометит ее как активную. Следующими узлами будет получено обновление.",
|
||||
"confirmPublish": "Опубликовать",
|
||||
"confirmForceTitle": "Принудительно опубликовать",
|
||||
"confirmForceDesc": "При этом проверка изменений игнорируется и немедленно создается новая версия. Подтвердите, что вам действительно нужна повторная публикация.",
|
||||
"confirmActivateTitle": "Активировать версию",
|
||||
"confirmActivateDesc": "Установить версию {version} в качестве активной версии?",
|
||||
"confirmActivate": "Активировать",
|
||||
"previewTitle": "Опубликовать предварительный просмотр",
|
||||
"previewDesc": "Отображаются ожидающие файлы конфигурации и поддержки.",
|
||||
"loadingPreview": "Загрузка предварительного просмотра...",
|
||||
"routeCount": "Маршрутов: {count}",
|
||||
"websiteCount": "Сайтов: {count}",
|
||||
"mainConfig": "Основная конфигурация",
|
||||
"routeConfig": "Конфигурация маршрута",
|
||||
"supportFiles": "Файлы поддержки ({count})",
|
||||
"noSupportFiles": "Эта публикация не содержит дополнительных файлов поддержки.",
|
||||
"noChanges": "Ожидающие маршруты соответствуют активной версии, поэтому их нельзя опубликовать снова.",
|
||||
"close": "Закрыть",
|
||||
"publishing": "Публикация...",
|
||||
"diffTitle": "Разница в конфигурации",
|
||||
"diffDesc": "Различия между ожидающей конфигурацией и активной версией.",
|
||||
"loadingDiff": "Загрузка разницы...",
|
||||
"activeVersion": "Активная версия",
|
||||
"none": "Нет",
|
||||
"addedDomains": "Добавлены домены",
|
||||
"removedDomains": "Удаленные домены",
|
||||
"modifiedDomains": "Модифицированные домены",
|
||||
"itemCount": "{count}",
|
||||
"noChange": "Никаких изменений",
|
||||
"emptyValue": "пустой",
|
||||
"mainChanged": "Основная конфигурация изменена",
|
||||
"mainUnchanged": "Основная конфигурация без изменений",
|
||||
"wafChanged": "WAF изменено",
|
||||
"wafUnchanged": "WAF без изменений",
|
||||
"websiteDelta": "Веб-сайты {from} → {to}",
|
||||
"optionKey": "Вариант",
|
||||
"activeValue": "Активное значение",
|
||||
"pendingValue": "Ожидаемое значение",
|
||||
"noOptionChanges": "Опция OpenResty не изменяется.",
|
||||
"snapshotTitle": "Снимок версии",
|
||||
"snapshotVersion": "Версия {version}",
|
||||
"snapshotDesc": "Полный снимок этой исторической версии конфигурации.",
|
||||
"loadingSnapshot": "Загрузка снимка...",
|
||||
"createdBy": "Создано",
|
||||
"createdAt": "Создано",
|
||||
"snapshotLoadFailed": "Не удалось загрузить снимок версии.",
|
||||
"cleanupTitle": "Чистые исторические снимки",
|
||||
"cleanupDesc": "Удалите старые снимки. Текущая активная версия всегда защищена.",
|
||||
"keepCount": "Сохраняйте последние снимки",
|
||||
"keepHint": "По умолчанию 10, минимум 3.",
|
||||
"keepMin": "Сохраните как минимум 3 исторических снимка.",
|
||||
"cleaning": "Очистка...",
|
||||
"confirmCleanup": "Чистый"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"layout": {
|
||||
"search": {
|
||||
"keywords": {
|
||||
"home": "главная страница|панель управления",
|
||||
"docs-how-to-use": "документы|как|руководство|помощь",
|
||||
"console-nodes": "узел|туннель|node|nodes|edge|relay|tunnel",
|
||||
"console-proxy-routes": "маршрут|прокси|proxy|routes",
|
||||
"console-websites": "сайт|домен",
|
||||
"console-certificates": "сертификат|ssl|tls|https",
|
||||
"console-dns-accounts": "DNS|учетная запись DNS|cloudflare|aliyun|tencent",
|
||||
"console-origins": "источник|сервер-источник|бэкенд|origin",
|
||||
"console-responses": "502|503|5xx|Service Worker|страница ошибки",
|
||||
"console-waf": "waf|безопасность",
|
||||
"console-ip-groups": "ip|ip группа",
|
||||
"console-rate-limits": "лимиты|скорость|ограничение|limit_conn|limit_rate",
|
||||
"console-pages": "страницы|cdn|статические|pages|static",
|
||||
"console-config-versions": "версия|конфигурация|config|versions",
|
||||
"console-access-logs": "журнал|журналы",
|
||||
"console-apply-logs": "применить|журнал|журналы",
|
||||
"console-performance": "производительность",
|
||||
"settings": "настройки|предпочтения",
|
||||
"settings-profile": "профиль|аватар",
|
||||
"settings-appearance": "внешний вид|тема|темный|светлый",
|
||||
"admin-settings": "администратор|безопасность|oidc|логин",
|
||||
"admin-system": "администратор|система|конфигурации",
|
||||
"admin-users": "администратор|пользователи|статус",
|
||||
"admin-tasks": "администратор|задачи|расписание|планировщик|worker",
|
||||
"admin-files": "администратор|файлы|загрузить|s3",
|
||||
"admin-database": "администратор|база данных|sql|запрос|gorm",
|
||||
"admin-push": "администратор|уведомления|почта|push|mail|telegram|lark",
|
||||
"admin-logs": "администратор|журналы|системный журнал|терминал"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@
|
||||
"title": "Automatic rule test result",
|
||||
"description": "Replay request logs against the current automatic configuration JSON.",
|
||||
"testing": "Testing...",
|
||||
"summary": "Lookback {lookback} · {rules} rules · {matched} IPs matched",
|
||||
"summary": "Lookback {lookback} · {rules, plural, one {# rule} other {# rules}} · {matched, plural, one {# IP matched} other {# IPs matched}}",
|
||||
"testedAt": "Tested at: {time}",
|
||||
"noMatch": "No IPs matched.",
|
||||
"empty": "No test result yet.",
|
||||
@@ -425,7 +425,8 @@
|
||||
"history": {
|
||||
"source": {
|
||||
"manual_upload": "Local upload",
|
||||
"manual_url": "URL import"
|
||||
"manual_url": "URL import",
|
||||
"remote_url": "Remote URL"
|
||||
},
|
||||
"trigger": {
|
||||
"manual_upload": "Manual upload",
|
||||
@@ -623,7 +624,7 @@
|
||||
"title": "Clean up access logs",
|
||||
"description": "Delete access logs older than the retention period. This cannot be undone.",
|
||||
"policy": "Retention policy",
|
||||
"keepDays": "Keep last {days} days",
|
||||
"keepDays": "Keep the last {days, plural, one {# day} other {# days}}",
|
||||
"custom": "Custom days",
|
||||
"customDays": "Custom retention days",
|
||||
"cleaning": "Cleaning...",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -425,7 +425,8 @@
|
||||
"history": {
|
||||
"source": {
|
||||
"manual_upload": "本地上传",
|
||||
"manual_url": "URL 导入"
|
||||
"manual_url": "URL 导入",
|
||||
"remote_url": "远程 URL"
|
||||
},
|
||||
"trigger": {
|
||||
"manual_upload": "手动上传",
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"certStatus": {
|
||||
"unknownExpiry": "Expiry unknown",
|
||||
"expired": "Expired",
|
||||
"expiresInDays": "Expires within {days} days",
|
||||
"expiresInDays": "Expires within {days, plural, one {# day} other {# days}}",
|
||||
"valid": "Valid"
|
||||
},
|
||||
"validation": {
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
{
|
||||
"websites": {
|
||||
"title": "Сайты",
|
||||
"createZone": "Добавить зону",
|
||||
"searchRoot": "Корневой домен зоны поиска",
|
||||
"loadingList": "Загрузка списка зон...",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"emptySearch": "Соответствующая зона не найдена.",
|
||||
"emptyList": "Зон пока нет. Нажмите «Добавить зону» в правом верхнем углу, чтобы начать.",
|
||||
"rootDomain": "Корневой домен",
|
||||
"domainCount": "Домены",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"actions": "Действия",
|
||||
"manage": "Управление",
|
||||
"manageZone": "Управление зоной",
|
||||
"loadingDetail": "Загрузка сведений о зоне...",
|
||||
"invalidId": "Неверный сайт ID. Откройте страницу сведений из списка веб-сайтов.",
|
||||
"notFound": "Сайт не найден. Возможно, он был удален или ID недействителен.",
|
||||
"back": "Назад",
|
||||
"detailSubtitle": "Управление сайтом зоны",
|
||||
"tabOverview": "Обзор",
|
||||
"tabDomains": "Домены ({count})",
|
||||
"tabCertificates": "Сертификаты ({count})",
|
||||
"basicInfo": "Основная информация",
|
||||
"basicInfoDesc": "Поддерживать регистрируемый корневой домен Зоны. После смены корня подтвердите, что его домены по-прежнему принадлежат ему.",
|
||||
"currentRoot": "Текущий корневой домен",
|
||||
"editRoot": "Изменить корневой домен",
|
||||
"dangerZone": "Опасные действия",
|
||||
"dangerZoneDesc": "Очистите все домены в зоне перед ее удалением. Удаление невозможно отменить.",
|
||||
"deleteZone": "Удалить зону",
|
||||
"deleteZoneTitle": "Удалить зону",
|
||||
"deleteZoneDesc": "Удалить {domain}? Домены под ним должны быть сначала отвязаны от маршрутов. Это невозможно отменить.",
|
||||
"deleting": "Удаление…",
|
||||
"confirmDelete": "Удалить",
|
||||
"zoneDeleted": "Зона удалена",
|
||||
"editZone": "Редактировать зону",
|
||||
"editorDesc": "Зона принимает только регистрируемый корневой домен, например example.com.",
|
||||
"rootRequired": "Введите корневой домен зоны",
|
||||
"rootInvalid": "Введите корневой домен без протокола или подстановочного знака.",
|
||||
"zoneUpdated": "Зона обновлена",
|
||||
"zoneCreated": "Зона создана",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"saveChanges": "Сохранить изменения",
|
||||
"range24h": "24 часа",
|
||||
"range7d": "7 дней",
|
||||
"range30d": "30 дней",
|
||||
"chartUv": "Ведро UV",
|
||||
"chartBytes": "Данные переданы",
|
||||
"statsFailed": "Не удалось загрузить статистику трафика.",
|
||||
"analyticsUnavailable": "Хранилище аналитики недоступно, поэтому диаграммы могут быть пустыми. Убедитесь, что ClickHouse включен.",
|
||||
"uniqueVisitors": "Уникальные посетители в окне запроса",
|
||||
"uniqueVisitorsDesc": "Верхняя сумма дедуплицируется по всему окну; кривая равна UV для каждого сегмента времени, и сегменты не могут быть сложены вместе",
|
||||
"totalRequests": "Всего запросов",
|
||||
"totalBytes": "Всего передано данных",
|
||||
"zoneInfo": "Информация о зоне",
|
||||
"addDomain": "Добавить домен",
|
||||
"emptyDomains": "Домены еще не добавлены",
|
||||
"certificate": "Сертификат",
|
||||
"upstream": "Сервер-источник",
|
||||
"certNumber": "Сертификат №{id}",
|
||||
"unbound": "Несвязанный",
|
||||
"unlinkedRoute": "Нет связанного маршрута",
|
||||
"loadingEllipsis": "Загрузка…",
|
||||
"upstreamUnset": "Восходящий поток не настроен",
|
||||
"viewRoute": "Посмотреть детали маршрута",
|
||||
"deleteDomain": "Удалить домен",
|
||||
"domainDeleted": "Домен удален",
|
||||
"deleteFailed": "Удаление не удалось",
|
||||
"deleteDomainTitle": "Удалить домен",
|
||||
"deleteDomainDesc": "Удалить {domain}? Это невозможно отменить.",
|
||||
"zoneCertsTitle": "Сертификаты, используемые этой зоной",
|
||||
"zoneCertsDesc": "Сертификаты управляются в глобальной библиотеке сертификатов. В этом списке показаны только сертификаты, привязанные к доменам в этой зоне.",
|
||||
"certLibrary": "Библиотека сертификатов",
|
||||
"unboundCertHint": "Доменов без сертификата: {count}. Выберите сертификат на вкладке «Домены».",
|
||||
"emptyBoundCerts": "Никаких связанных сертификатов. Выберите сертификат в строке домена или импортируйте/примените его в библиотеке сертификатов.",
|
||||
"status": "Статус",
|
||||
"coverage": "Охватываемые домены",
|
||||
"expiresAt": "Срок действия истекает",
|
||||
"selectZone": "Сначала выберите зону",
|
||||
"enterDomain": "Введите домен",
|
||||
"noWildcard": "Домены зон не поддерживают подстановочные знаки",
|
||||
"invalidFormat": "Неверный формат домена",
|
||||
"invalidSubdomain": "Неверный формат имени субдомена",
|
||||
"mustBelongToZone": "Домен должен принадлежать зоне {root}",
|
||||
"domainAdded": "Домен добавлен",
|
||||
"addFailed": "Добавить не удалось",
|
||||
"quickCreateTitle": "Быстрое добавление домена",
|
||||
"quickCreateDesc": "После выбора зоны введите имя субдомена (например, API), @ (корневой) или полное FQDN.",
|
||||
"selectRegisteredRoot": "Выберите зарегистрированный корневой домен",
|
||||
"zoneLabel": "Зона:",
|
||||
"domain": "Домен",
|
||||
"domainPlaceholderWithZone": "API или @ или API.{domain}",
|
||||
"domainPlaceholder": "API/@/полный домен",
|
||||
"willCreate": "Создаст:",
|
||||
"domainExample": "Пример: введите <api>api</api> → <fqdn>api.zone.com</fqdn>; <apex>@</apex> → сам корень",
|
||||
"certOptional": "Сертификат (необязательно)",
|
||||
"noCert": "Нет сертификата"
|
||||
},
|
||||
"certificates": {
|
||||
"title": "Сертификаты",
|
||||
"refresh": "Обновить сертификаты",
|
||||
"importCert": "Импортный сертификат",
|
||||
"applyTitle": "Подать заявку на сертификат",
|
||||
"listTitle": "Список сертификатов",
|
||||
"listDesc": "Показывает срок действия сертификата, примечания и статус. Вы можете просмотреть подробную информацию, отредактировать содержимое или удалить сертификаты.",
|
||||
"loadingList": "Загрузка списка сертификатов...",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"emptyList": "Сертификатов пока нет. Нажмите «Импортировать сертификат» или «Подать заявку на сертификат» в правом верхнем углу, чтобы начать.",
|
||||
"notBeforeLabel": "Действительно с: {value}",
|
||||
"notAfterLabel": "Срок действия истекает: {value}",
|
||||
"sourceLabel": "Источник: {value}",
|
||||
"sourceAcme": "Выпущен через ACME",
|
||||
"sourceUpload": "Загрузка вручную",
|
||||
"statusLabel": "Статус: {value}",
|
||||
"converting": "Выполняется конвертация...",
|
||||
"applying": "Применение...",
|
||||
"statusError": "Статус: {value} ({message})",
|
||||
"convertFailed": "Преобразование не удалось",
|
||||
"applyFailed": "Не удалось выпустить сертификат",
|
||||
"remarkLabel": "Примечание: {value}",
|
||||
"noRemark": "Нет примечания",
|
||||
"view": "Посмотреть",
|
||||
"edit": "Изменить",
|
||||
"renew": "Продлить",
|
||||
"deleted": "Сертификат удален.",
|
||||
"renewSubmitted": "Задача продления отправлена на сертификат {name}",
|
||||
"imported": "Сертификат {name} импортирован.",
|
||||
"applySubmitted": "Задача заявки отправлена на сертификат {name}",
|
||||
"convertSubmitted": "Заявка на конвертацию подана для получения сертификата {name}",
|
||||
"reapplySubmitted": "Сертификат {name} обновлен и применяется повторно...",
|
||||
"updated": "Сертификат {name} обновлен.",
|
||||
"deleteTitle": "Удалить сертификат",
|
||||
"deleteDesc": "Удалить сертификат {name}?",
|
||||
"applyEditTitle": "Изменить и повторно применить сертификат",
|
||||
"applyConvertTitle": "Перевыпустить сертификат через ACME",
|
||||
"applyEditDesc": "Измените настройки сертификата ACME. После сохранения сертификат будет повторно применен с новыми настройками.",
|
||||
"applyConvertDesc": "Укажите параметры выпуска через ACME. После успешного выпуска текущий сертификат будет заменён на сертификат с автоматическим продлением.",
|
||||
"applyDesc": "Используйте ACME (Let's Encrypt и другие), чтобы автоматически подавать заявки на получение и продление сертификатов, включая домены с подстановочными знаками.",
|
||||
"name": "Название сертификата",
|
||||
"namePlaceholder": "Например: сертификат основного сайта.",
|
||||
"primaryDomain": "Основной домен",
|
||||
"primaryDomainPlaceholder": "example.com или *.example.com",
|
||||
"otherDomains": "Другие домены",
|
||||
"otherDomainsHint": "Один домен на строку. Для сертификата с подстановочным знаком также введите соответствующий корневой домен, чтобы его можно было выдать вместе.",
|
||||
"dnsAccount": "DNS аккаунт провайдера",
|
||||
"selectDnsAccount": "Выберите учетную запись DNS.",
|
||||
"keyAlgorithm": "Ключевой алгоритм",
|
||||
"remark": "Примечание",
|
||||
"remarkPlaceholder": "Необязательно. Запишите, для чего используется этот сертификат.",
|
||||
"autoRenew": "Включить автоматическое продление",
|
||||
"autoRenewDesc": "Если эта опция включена, сертификат продлевается автоматически за 7 дней до истечения срока его действия.",
|
||||
"advanced": "Расширенные параметры",
|
||||
"dnsServer1": "DNS сервер проверки 1",
|
||||
"dnsServer2": "DNS сервер проверки 2",
|
||||
"dnsServerPlaceholder": "Оставьте пустым, чтобы использовать авторитетное значение по умолчанию DNS",
|
||||
"skipCname": "Пропустить проверку CNAME",
|
||||
"skipCnameDesc": "Не следуйте записям CNAME во время проверки DNS-01.",
|
||||
"skipDns": "Пропустить предварительную проверку DNS",
|
||||
"skipDnsDesc": "Попросите Let’s Encrypt проверить напрямую, без локальной проверки.",
|
||||
"submitting": "Отправка...",
|
||||
"saveAndApply": "Сохраните и примените",
|
||||
"startConvert": "Начать преобразование",
|
||||
"startApply": "Выпустить сертификат",
|
||||
"editTitle": "Изменить сертификат",
|
||||
"editDesc": "Вы можете изменить имя и примечание сертификата, а также повторно загрузить сертификат PEM и закрытый ключ.",
|
||||
"loadingContent": "Загрузка содержимого сертификата...",
|
||||
"notFound": "Сертификат не найден. Возможно, оно было удалено.",
|
||||
"certPem": "Сертификат PEM",
|
||||
"keyPem": "Закрытый ключ PEM",
|
||||
"convertSource": "Преобразовать источник",
|
||||
"saving": "Сохранение...",
|
||||
"saveCert": "Сохранить сертификат",
|
||||
"importTitle": "Добавить сертификат",
|
||||
"importDesc": "Вставьте PEM вручную или загрузите файлы сертификатов. После импорта его можно сразу выбрать в формах сайта.",
|
||||
"manualImport": "Ручной импорт",
|
||||
"fileImport": "Импорт файла",
|
||||
"remarkProdPlaceholder": "Например: производственный сертификат для основного объекта.",
|
||||
"importing": "Импорт...",
|
||||
"remarkWildcardPlaceholder": "Например: производственный сертификат с подстановочным знаком.",
|
||||
"certFile": "Файл сертификата",
|
||||
"keyFile": "Файл закрытого ключа",
|
||||
"fileSelected": "Выбрано: {name}",
|
||||
"selectCertFile": "Выберите файл PEM/CRT.",
|
||||
"selectKeyFile": "Выберите файл KEY/PEM.",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadFiles": "Загрузить файлы",
|
||||
"clearFiles": "Очистить файлы",
|
||||
"selectFiles": "Выберите файл сертификата и файл закрытого ключа.",
|
||||
"detailTitle": "Детали сертификата",
|
||||
"detailDesc": "Просмотр метаданных сертификата, примечаний и текущего сохраненного содержимого PEM.",
|
||||
"loadingDetail": "Загрузка сведений о сертификате...",
|
||||
"status": "Статус",
|
||||
"notBefore": "Действительно с",
|
||||
"notAfter": "Срок действия истекает",
|
||||
"certPemCopied": "Сертификат PEM скопирован.",
|
||||
"keyPemCopied": "Закрытый ключ PEM скопирован.",
|
||||
"copy": "Копировать",
|
||||
"close": "Закрыть",
|
||||
"editCert": "Изменить сертификат",
|
||||
"deleting": "Удаление...",
|
||||
"deleteCert": "Удалить сертификат",
|
||||
"certStatus": {
|
||||
"unknownExpiry": "Срок действия неизвестен",
|
||||
"expired": "Срок действия истек",
|
||||
"expiresInDays": "Срок действия истекает через {days, plural, one {# день} few {# дня} many {# дней} other {# дня}}.",
|
||||
"valid": "Действителен"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Введите имя сертификата",
|
||||
"nameTooLong": "Имя сертификата не может превышать 255 символов.",
|
||||
"certPemRequired": "Введите сертификат PEM",
|
||||
"keyPemRequired": "Введите закрытый ключ PEM",
|
||||
"remarkTooLong": "Примечание не может превышать 255 символов.",
|
||||
"primaryDomainRequired": "Введите основной домен",
|
||||
"dnsAccountRequired": "Выберите учетную запись DNS."
|
||||
}
|
||||
},
|
||||
"dnsAccounts": {
|
||||
"title": "DNS аккаунтов",
|
||||
"addAccount": "Добавить аккаунт",
|
||||
"listTitle": "DNS список аккаунтов",
|
||||
"listDesc": "Централизованно управляйте учетными записями поставщиков DNS, используемыми для проверки сертификата DNS ACME.",
|
||||
"loadingList": "Загрузка DNS аккаунтов...",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"emptyList": "Аккаунтов DNS пока нет. Нажмите «Добавить учетную запись» в правом верхнем углу, чтобы начать.",
|
||||
"createdAt": "Создано: {date}",
|
||||
"created": "DNS аккаунт добавлен",
|
||||
"deleted": "DNS аккаунт удален",
|
||||
"deleteTitle": "Удалить учетную запись DNS",
|
||||
"deleteDesc": "Удалить учетную запись DNS {name}?",
|
||||
"createTitle": "Добавить аккаунт DNS",
|
||||
"createDesc": "Централизованно управляйте учетными записями поставщиков DNS, используемыми для проверки сертификата DNS ACME.",
|
||||
"accountName": "Имя учетной записи",
|
||||
"accountNamePlaceholder": "Cloudflare адрес электронной почты",
|
||||
"provider": "DNS провайдер",
|
||||
"tokenPlaceholder": "Не используйте глобальный ключ API.",
|
||||
"nameRequired": "Введите имя",
|
||||
"tokenRequired": "Введите токен",
|
||||
"submitting": "Отправка...",
|
||||
"submit": "Отправить"
|
||||
},
|
||||
"origins": {
|
||||
"title": "Серверы-источники",
|
||||
"subtitle": "Общий каталог серверов-источников для маршрутов. Измените адрес один раз для всех связанных маршрутов.",
|
||||
"refresh": "Обновить",
|
||||
"create": "Добавить сервер-источник",
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"emptyTitle": "Нет серверов-источников",
|
||||
"emptyDesc": "Добавьте сервер-источник, чтобы использовать его адрес в маршрутах.",
|
||||
"routeCount": "Маршрутов: {count}",
|
||||
"noRemark": "Нет примечания",
|
||||
"lastUpdated": "Последнее обновление: {date}",
|
||||
"detail": "Подробности",
|
||||
"edit": "Изменить",
|
||||
"deleted": "Сервер-источник удалён",
|
||||
"deleteFailed": "Удаление не удалось",
|
||||
"deleteTitle": "Удалить сервер-источник",
|
||||
"deleteDesc": "Удалить сервер-источник «{name}»?",
|
||||
"confirmDelete": "Удалить",
|
||||
"missingId": "Не указан корректный ID сервера-источника.",
|
||||
"loadingDetail": "Загрузка сервера-источника…",
|
||||
"backToList": "Вернуться к списку",
|
||||
"notFound": "Сервер-источник не найден или уже удалён.",
|
||||
"editOrigin": "Изменить сервер-источник",
|
||||
"deleteOrigin": "Удалить сервер-источник",
|
||||
"boundRoutes": "Связанные маршруты",
|
||||
"createdAt": "Создано",
|
||||
"updatedAt": "Обновлено",
|
||||
"remark": "Примечание",
|
||||
"relatedRoutes": "Связанные маршруты",
|
||||
"relatedRoutesDesc": "Маршруты, использующие этот сервер-источник в качестве основного.",
|
||||
"noRelatedRoutes": "Нет связанных маршрутов",
|
||||
"noRelatedRoutesDesc": "Этот сервер-источник пока не используется ни в одном маршруте.",
|
||||
"domain": "Домен",
|
||||
"address": "Адрес сервера-источника",
|
||||
"status": "Статус",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"addressRequired": "Укажите адрес сервера-источника",
|
||||
"addressInvalid": "Неверный формат адреса сервера-источника.",
|
||||
"updated": "Сервер-источник обновлён",
|
||||
"created": "Сервер-источник создан",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"editorDesc": "Каталог адресов серверов-источников для повторного использования в маршрутах. Протокол и порт задаются в самом маршруте.",
|
||||
"name": "Название сервера-источника",
|
||||
"namePlaceholder": "Основной сервер-источник",
|
||||
"saving": "Сохранение...",
|
||||
"saveChanges": "Сохранить изменения"
|
||||
},
|
||||
"proxyRoutes": {
|
||||
"title": "Маршруты прокси",
|
||||
"refresh": "Обновить",
|
||||
"createRule": "Новый маршрут",
|
||||
"listTitle": "Список маршрутов",
|
||||
"searchPlaceholder": "Поиск по сайтам, доменам или серверам-источникам…",
|
||||
"emptyTitle": "Нет веб-сайтов",
|
||||
"emptyDesc": "Сначала создайте веб-сайт, затем откройте страницу конфигурации, чтобы завершить настройку HTTPS, кэша и ограничений скорости.",
|
||||
"noMatches": "Нет соответствующих результатов",
|
||||
"noMatchesDesc": "Попробуйте другой поисковый запрос или очистите фильтр.",
|
||||
"siteName": "Название сайта",
|
||||
"domain": "Домен",
|
||||
"status": "Статус",
|
||||
"upstream": "Сервер-источник",
|
||||
"actions": "Действия",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"configure": "Настроить",
|
||||
"siteCreated": "Сайт создан",
|
||||
"siteDeleted": "Сайт удален",
|
||||
"deleteFailed": "Удаление не удалось",
|
||||
"loadListFailed": "Не удалось загрузить список маршрутов.",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"deleteSiteTitle": "Удалить сайт",
|
||||
"deleteSiteDesc": "Удалить сайт {name}? При этом будут удалены все домены и настройки сайта.",
|
||||
"deleting": "Удаление...",
|
||||
"missingId": "Действительный маршрут ID отсутствует.",
|
||||
"notFound": "Маршрут не найден",
|
||||
"notFoundDesc": "Вернитесь к списку и повторите попытку.",
|
||||
"saving": "Сохранение...",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"requestFailed": "Запрос не выполнен. Пожалуйста, повторите попытку позже.",
|
||||
"back": "Назад",
|
||||
"publish": "Опубликовать конфигурацию",
|
||||
"diffFailed": "Не удалось загрузить разницу конфигурации.",
|
||||
"noPublishNeeded": "Текущая конфигурация соответствует активной версии. Нечего публиковать.",
|
||||
"publishSuccess": "Опубликовано успешно",
|
||||
"versionLabel": "Версия {version}",
|
||||
"publishFailed": "Опубликовать не удалось",
|
||||
"publishTitle": "Опубликовать конфигурацию",
|
||||
"publishDesc": "Это создаст новую версию из ожидающей конфигурации и сделает ее активной. Затем узлы получат обновление.",
|
||||
"confirmPublish": "Опубликовать",
|
||||
"createDesc": "Выбирайте привязки из зарегистрированных доменов Зоны. После создания маршрута вы можете продолжить настройку кэша, ограничений скорости и других дополнительных параметров.",
|
||||
"siteId": "Идентификатор сайта",
|
||||
"siteIdOptionalDesc": "Необязательно. Если оставить пустым, автоматически будет использоваться первый домен.",
|
||||
"siteIdStableDesc": "Используйте стабильный и читаемый бизнес-идентификатор. Он не обязательно должен точно совпадать с доменом.",
|
||||
"bindDomains": "Связанные домены",
|
||||
"bindDomainsHint": "Выберите зарегистрированные домены для привязки. Используйте «Быстрое добавление домена», чтобы создать FQDN под зоной и выбрать его автоматически.",
|
||||
"upstreamType": "Исходный режим",
|
||||
"upstreamDirect": "Прямое подключение к серверу-источнику",
|
||||
"upstreamTunnel": "Туннель",
|
||||
"upstreamPages": "Страницы статического сайта",
|
||||
"upstreamAddresses": "Восходящие адреса",
|
||||
"upstreamUrlsHint": "Полный URL на каждой строке. Первая строка — основной сервер-источник. Для нескольких серверов используйте один протокол, без пути и параметров запроса.",
|
||||
"selectTunnel": "Выберите туннель",
|
||||
"pleaseSelect": "Пожалуйста, выберите...",
|
||||
"online": "Онлайн",
|
||||
"offline": "Оффлайн",
|
||||
"tunnelForwardHint": "Пересылать запросы на клиентский узел, подключенный этим туннелем.",
|
||||
"tunnelProtocol": "Внутренний протокол обслуживания",
|
||||
"tunnelAddress": "Адрес внутреннего сервиса",
|
||||
"tunnelAddressHint": "Например: 127.0.0.1:8080 или 192.168.1.10:80.",
|
||||
"selectPagesProject": "Выберите проект Pages",
|
||||
"pagesProjectHint": "Показаны только включенные проекты Pages с активным развертыванием.",
|
||||
"enableSite": "Включить сайт",
|
||||
"enableSiteDesc": "Если этот параметр отключен, конфигурация сохраняется, но не включается в публикацию.",
|
||||
"createFailed": "Создать не удалось. Пожалуйста, повторите попытку позже.",
|
||||
"creating": "Создание…",
|
||||
"create": "Создать",
|
||||
"searchDomainOrZone": "Поиск домена или зоны…",
|
||||
"quickAddDomain": "Быстрое добавление домена",
|
||||
"noZoneDomains": "Нет доступных доменов зоны. Сначала добавьте FQDN в <websites>Управление веб-сайтами</websites> или используйте «Быстрое добавление домена».",
|
||||
"noBindableDomains": "Нет привязываемых доменов (остальные уже привязаны к другим маршрутам). Используйте «Быстрое добавление домена», чтобы создать его.",
|
||||
"noMatchingDomains": "Нет подходящих доменов",
|
||||
"certNumber": "Сертификат №{id}",
|
||||
"noCert": "Нет сертификата",
|
||||
"zoneName": "Зона {name}",
|
||||
"zoneId": "Зона №{id}",
|
||||
"unboundDomain": "Нет привязки к домену",
|
||||
"pagesProjectId": "Проект страниц №{id}",
|
||||
"pagesUnbound": "Проект страниц не связан",
|
||||
"tunnelTargetUnset": "Цель не настроена",
|
||||
"upstreamUnset": "Восходящий поток не настроен",
|
||||
"domainSettings": "Настройки домена",
|
||||
"domainSettingsDesc": "Привяжите полные доменные имена, уже зарегистрированные в зоне. Поддерживать сертификаты в управлении доменами зоны.",
|
||||
"redirectNeedsCert": "Привяжите сертификат к выбранным доменам (в Зоне) перед включением перенаправления HTTP.",
|
||||
"domainSettingsSaved": "Настройки домена сохранены.",
|
||||
"redirectHttp": "Автоматически перенаправить HTTP на HTTPS",
|
||||
"redirectHttpEnabledHint": "Если этот параметр включен, генерируются дополнительные правила перенаправления порта 80.",
|
||||
"redirectHttpNeedsCert": "Прежде чем эту функцию можно будет включить, хотя бы один выбранный домен должен иметь привязанный сертификат.",
|
||||
"reverseProxy": "Обратный прокси",
|
||||
"reverseProxyDesc": "Настройте адреса серверов-источников и стратегию выбора сервера.",
|
||||
"enableHttp2": "Включить HTTP/2",
|
||||
"enableHttp2Desc": "Включено по умолчанию. Если этот параметр отключен, трафик HTTPS не будет использовать HTTP/2.",
|
||||
"proxySaved": "Настройки обратного прокси сохранены.",
|
||||
"originHostHint": "Если оставить пустым, запрос имени хоста $host передается по умолчанию.",
|
||||
"customHeaders": "Пользовательские заголовки запросов",
|
||||
"customHeadersHint": "По одному на строку в формате Ключ: Значение.",
|
||||
"limits": "Ограничения трафика",
|
||||
"limitsDesc": "Лимиты запросов для сайта. Пустое значение или 0 — глобальные настройки; -1 — отключено; положительное значение — собственный лимит.",
|
||||
"limitsSaved": "Лимиты трафика сохранены",
|
||||
"connLimit": "Предел параллелизма",
|
||||
"connLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; Значение больше 0 — это пользовательское ограничение параллелизма.",
|
||||
"ipLimit": "Лимит на IP",
|
||||
"ipLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; Значение больше 0 — это индивидуальное ограничение на IP.",
|
||||
"rateLimit": "Ограничение скорости",
|
||||
"rateLimitPlaceholder": "512k/1m или -1",
|
||||
"rateLimitHint": "Пусто или 0 наследует глобальное значение по умолчанию; -1 выключает его; например, 512k или 1m — это пользовательская полоса пропускания.",
|
||||
"reqLimit": "Скорость запроса на IP",
|
||||
"reqLimitPlaceholder": "10r/s / 100r/m или -1",
|
||||
"reqLimitHint": "Пустое значение или 0 — глобальные настройки; -1 — отключено; например, 10r/s или 100r/m задаёт собственную частоту запросов.",
|
||||
"cache": "Кэш",
|
||||
"cacheDesc": "Настройте политику пограничного кэша сайта.",
|
||||
"cacheSaved": "Настройки кэша сохранены.",
|
||||
"enableCache": "Включить кэш сайта",
|
||||
"enableCacheDesc": "Новые сайты должны использовать «Стандартные статические ресурсы».",
|
||||
"cachePolicy": "Политика кэширования",
|
||||
"policyStatic": "Стандартные статические ресурсы (рекомендуется)",
|
||||
"policyAll": "Все кэшируемое GET (дополнительно)",
|
||||
"policySuffix": "Пользовательский суффикс",
|
||||
"policyPrefix": "Префикс пути",
|
||||
"policyExact": "Точный путь",
|
||||
"advancedRiskTitle": "Расширенный политический риск",
|
||||
"advancedRiskDesc": "Персонализированный HTML может быть кэширован на периферии и передан другим пользователям, если источник не заявляет о приватности/отсутствии хранилища. Подробности смотрите в справке рядом с заголовком.",
|
||||
"cacheRules": "Правила кэширования",
|
||||
"cacheHelpTitle": "Примечания по использованию кэша",
|
||||
"cacheHelpIntro": "Расширение/политика решает, можно ли кэшировать ответ. Заголовки источника и Set-Cookie решают, будет ли он сохранен. Также необходимо включить глобальный кэш OpenResty в настройках производительности.",
|
||||
"cacheHelpRecommendLabel": "Рекомендуемая политика:",
|
||||
"cacheHelpRecommend": "Новые сайты должны использовать «Стандартные статические ресурсы» (включая css/js/map/images/fonts/media, исключая HTML/JSON).",
|
||||
"cacheHelpGeneralLabel": "Общие правила:",
|
||||
"cacheHelpGeneral": "Номера, отличные от GET, не кэшируются; файлы cookie входа не пропускают кэш самостоятельно; origin Private/no-store или ответ Set-Cookie не записывается на край.",
|
||||
"cacheHelpAllGetLabel": "Все кэшируемое GET:",
|
||||
"cacheHelpAllGet": "Расширенный вариант, аналогичный «Кэшировать все». Персонализированные страницы должны быть объявлены источником как частные/не храниться, иначе HTML может быть кэшировано в Edge и предоставлено другим пользователям.",
|
||||
"cacheHelpCustomLabel": "Пользовательские правила:",
|
||||
"cacheHelpCustom": "Используйте суффиксы, такие как jpg/css/js; префиксы путей, такие как /assets; точные пути, такие как /robots.txt. После сохранения повторно опубликуйте версию конфигурации, прежде чем она вступит в силу на узлах.",
|
||||
"cacheHintSuffix": "По одному суффиксу в строке, например jpg, css, js.",
|
||||
"cacheHintPrefix": "Один префикс пути в каждой строке, например /assets, /static.",
|
||||
"cacheHintExact": "Один точный путь в каждой строке, например /robots.txt.",
|
||||
"cacheHintStatic": "Стандартные статические ресурсы используют встроенный список расширений. Никаких дополнительных правил не требуется.",
|
||||
"cacheHintNone": "Текущая политика не требует дополнительных правил пути.",
|
||||
"cachePlaceholderNone": "Никаких дополнительных правил для текущей политики.",
|
||||
"auth": "Аутентификация",
|
||||
"authDesc": "Настройте Basic Auth, чтобы ограничить доступ. Защита PoW настраивается в группе правил WAF.",
|
||||
"authSaved": "Настройки аутентификации сохранены.",
|
||||
"authFormInvalid": "Проверьте форму аутентификации",
|
||||
"authMode": "Режим аутентификации",
|
||||
"authNone": "Нет аутентификации",
|
||||
"powHint": "Защиту PoW можно включить только в группе правил WAF и привязать к сайту.",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"wafDesc": "Глобальные группы правил применяются всегда. Здесь вы можете добавить собственные группы правил для текущего сайта.",
|
||||
"wafUpdated": "WAF группы правил обновлены",
|
||||
"loadingWaf": "Загрузка групп правил WAF...",
|
||||
"alwaysOn": "Всегда включен",
|
||||
"selectCustomRules": "Выберите специальные правила",
|
||||
"selectCustomRulesDesc": "Выбранные правила выполняются в порядке выполнения, указанном ниже.",
|
||||
"selectRule": "Выберите {name}",
|
||||
"ruleEnabled": "Включено",
|
||||
"ruleDisabled": "Отключено",
|
||||
"nodeCount": "Узлов: {count}",
|
||||
"executionOrder": "Порядок выполнения",
|
||||
"executionOrderDesc": "Пользовательские правила выполняются в этом порядке. Перетащите их или используйте кнопки вверх/вниз, чтобы изменить порядок.",
|
||||
"noCustomWaf": "Нет пользовательских групп правил WAF.",
|
||||
"dragRule": "Перетащите {name}",
|
||||
"moveUpRule": "Переместить {name} вверх.",
|
||||
"moveDownRule": "Переместите {name} вниз.",
|
||||
"sections": {
|
||||
"domains": "Настройки домена",
|
||||
"domainsDesc": "Сохраняйте идентификатор сайта и выбирайте привязанные домены из зоны.",
|
||||
"limits": "Ограничения трафика",
|
||||
"limitsDesc": "Установите ограничения на соединения и ограничения скорости (можно наследовать глобальные значения по умолчанию).",
|
||||
"proxy": "Обратный прокси",
|
||||
"proxyDesc": "Настройте основной сервер-источник и дополнительные адреса.",
|
||||
"cache": "Кэш",
|
||||
"cacheDesc": "Настройте политику кэширования сайта.",
|
||||
"waf": "WAF",
|
||||
"wafDesc": "Свяжите группы правил WAF и просмотрите политики, применимые к этому сайту.",
|
||||
"auth": "Аутентификация",
|
||||
"authDesc": "Настройте базовую аутентификацию, чтобы посетители должны были вводить имя пользователя и пароль."
|
||||
},
|
||||
"validation": {
|
||||
"enterDomain": "Введите домен",
|
||||
"invalidDomain": "Неверный формат домена",
|
||||
"enterAtLeastOneDomain": "Введите хотя бы один домен",
|
||||
"invalidDomainWithValue": "Неверный формат домена: {domain}",
|
||||
"duplicateDomain": "Дубликат домена: {domain}",
|
||||
"enterAtLeastOneUpstream": "Укажите хотя бы один адрес сервера-источника",
|
||||
"invalidUpstream": "Неверный формат адреса сервера-источника: {url}",
|
||||
"upstreamMustBeHttp": "Адрес сервера-источника должен начинаться с http:// или https://: {url}",
|
||||
"upstreamMissingHost": "В адресе сервера-источника не указано имя хоста: {url}",
|
||||
"multiUpstreamNoPath": "При использовании нескольких серверов-источников адреса не должны содержать путь или параметры запроса.",
|
||||
"multiUpstreamSameProtocol": "Все серверы-источники одного сайта должны использовать одинаковый протокол.",
|
||||
"invalidOriginHost": "Неверный исходный формат хоста",
|
||||
"invalidHeader": "Неверный формат заголовка пользовательского запроса: {line}",
|
||||
"invalidHeaderName": "Недопустимое имя заголовка пользовательского запроса: {key}",
|
||||
"invalidLimitRate": "Неверный формат ограничения скорости. Используйте 512k, 1m, число или -1, чтобы отключить его.",
|
||||
"invalidLimitReq": "Неверный формат частоты запросов. Используйте 10r/s, 100r/m или -1 для отключения.",
|
||||
"cacheRuleRequired": "Для текущей политики кэширования требуется хотя бы одно правило.",
|
||||
"invalidCacheSuffix": "Неверный формат суффикса кэша: {rule}",
|
||||
"invalidCachePath": "Неверный формат правила пути к кэшу: {rule}",
|
||||
"siteNameTooLong": "Идентификатор сайта не может превышать 255 символов.",
|
||||
"selectAtLeastOneDomain": "Выберите хотя бы один домен",
|
||||
"selectTunnel": "Выберите туннель",
|
||||
"enterTunnelTarget": "Введите внутренний адрес службы (например, 127.0.0.1:8080).",
|
||||
"selectPagesProject": "Выберите проект Pages",
|
||||
"enterSiteId": "Введите идентификатор сайта",
|
||||
"enterIntegerOrMinusOne": "Введите -1, 0 или целое положительное число.",
|
||||
"enterUsername": "Введите имя пользователя",
|
||||
"enterPassword": "Введите пароль"
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"title": "Страницы ответов",
|
||||
"subtitle": "Настройте страницы ошибок сервера-источника и страницы для работы без сети.",
|
||||
"loadingPermission": "Загрузка информации о разрешении...",
|
||||
"loadingConfig": "Загрузка настроек страницы ответов...",
|
||||
"loadingPage": "Загрузка страниц ответов...",
|
||||
"loadingErrorConfig": "Загрузка настроек страницы ошибки...",
|
||||
"loadingOfflineConfig": "Загрузка настроек офлайн-страницы...",
|
||||
"loadFailed": "Загрузка не удалась",
|
||||
"forbidden": "Недостаточное разрешение",
|
||||
"forbiddenSettings": "Доступ к настройкам страницы ответов имеют только администраторы.",
|
||||
"forbiddenPreviewError": "Только администраторы могут просматривать страницу ошибки.",
|
||||
"forbiddenPreviewOffline": "Только администраторы могут просматривать автономную страницу.",
|
||||
"forbiddenEditError": "Только администраторы могут редактировать страницу ошибки HTML.",
|
||||
"forbiddenEditOffline": "Только администраторы могут редактировать автономную страницу HTML.",
|
||||
"errorPage": "Страница ошибки",
|
||||
"offlinePage": "Офлайн-страница",
|
||||
"saveFailed": "Сохранить не удалось",
|
||||
"policySaved": "Политика триггера сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.",
|
||||
"triggerPolicy": "Триггерная политика",
|
||||
"triggerPolicyDesc": "Настройте, когда страница исходной ошибки включена и какие коды состояния ее запускают.",
|
||||
"enableErrorPage": "Показывать страницу ошибки сервера-источника",
|
||||
"enableErrorPageDesc": "Если этот параметр отключен, передается исходный ответ или ответ об ошибке Nginx по умолчанию.",
|
||||
"getOnly": "GET только запросы",
|
||||
"getOnlyDesc": "Если этот параметр включен, пользовательская страница ошибок возвращается только для сопоставления кодов состояния ошибок по запросам GET.",
|
||||
"statusCodes": "Коды состояния триггера",
|
||||
"statusCodesDesc": "Поддерживает один код (например, 502) или закрытый диапазон (например, 500–599).",
|
||||
"statusCodesPlaceholder": "Например 502 или 500-599, затем нажмите Enter.",
|
||||
"pagePreview": "Предварительный просмотр страницы",
|
||||
"preview": "Предпросмотр",
|
||||
"edit": "Изменить",
|
||||
"errorPagePreview": "Предпросмотр страницы ошибки сервера-источника",
|
||||
"errorPreview": "Предварительный просмотр страницы ошибки",
|
||||
"offlinePreview": "Предварительный просмотр страницы в автономном режиме",
|
||||
"offlinePageDesc": "Если этот параметр включен, Service Worker выдается веб-сайтам HTTPS. Если домен недоступен, браузер отображает эту автономную страницу из кэша.",
|
||||
"offlineSaved": "Офлайн-страница сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.",
|
||||
"enableOffline": "Включить автономную страницу Service Worker",
|
||||
"enableOfflineDesc": "Вступает в силу только на веб-сайтах HTTPS.",
|
||||
"enableOfflineAria": "Включить автономную страницу",
|
||||
"scope": "Область применения",
|
||||
"scopeDesc": "Применяется только к выбранным доменам HTTPS. Если пусто, ничего не вводится.",
|
||||
"addDomain": "Добавить домен",
|
||||
"noScopeSelected": "Домены области действия не выбраны. После сохранения ни на один сайт ничего не внедряется.",
|
||||
"enableThenSelectScope": "Включите резервный режим автономного режима перед выбором доменов области.",
|
||||
"removeDomain": "Удалить {domain}",
|
||||
"selectScopeTitle": "Выберите домены области действия",
|
||||
"selectScopeDesc": "Внедрите резервный вариант автономного режима только для выбранных доменов HTTPS. Поиск, фильтрация и массовый выбор.",
|
||||
"domain": "Домен",
|
||||
"searchDomainOrZone": "Поиск домена или апекса…",
|
||||
"selectedCount": "Выбрано: {count}",
|
||||
"visibleCount": "Показано: {count}",
|
||||
"unselectVisible": "Снять выбор с видимых доменов",
|
||||
"selectVisible": "Выбрать видимый",
|
||||
"clear": "Очистить",
|
||||
"noAvailableDomains": "Нет доступных доменов. Сначала зарегистрируйте домены в разделе «Управление зонами».",
|
||||
"noMatchingDomains": "Нет подходящих доменов",
|
||||
"selectZone": "Выберите вершину {domain}",
|
||||
"apex": "Апекс",
|
||||
"confirm": "OK",
|
||||
"backToError": "Вернуться на страницу ошибки",
|
||||
"backToOffline": "Вернуться на офлайн-страницу",
|
||||
"closePreview": "Закрыть предварительный просмотр",
|
||||
"fullscreen": "Полноэкранный просмотр",
|
||||
"htmlTooLarge": "HTML превышает ограничение размера (максимум 256 КБ)",
|
||||
"errorHtmlSaved": "Страница исходной ошибки HTML сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.",
|
||||
"offlineHtmlSaved": "Офлайн-страница HTML сохранена. Опубликуйте версию, чтобы конфигурация вступила в силу.",
|
||||
"templateNotFound": "Выбранный шаблон не найден",
|
||||
"templateLoaded": "Загрузил шаблон «{name}» в редактор.",
|
||||
"errorRestored": "Снято для использования встроенного шаблона сервера по умолчанию (сохраните, чтобы применить).",
|
||||
"offlineRestored": "Снято для использования встроенного шаблона по умолчанию (сохраните, чтобы применить)",
|
||||
"editErrorHtml": "Изменить страницу ошибки HTML",
|
||||
"editOfflineHtml": "Редактировать офлайн-страницу HTML",
|
||||
"offlineLivePreview": "Предварительный просмотр оффлайн-страницы",
|
||||
"builtinTemplates": "Встроенные шаблоны",
|
||||
"selectTemplate": "Выберите шаблон",
|
||||
"loadTemplate": "Загрузить шаблон",
|
||||
"restoreDefault": "Восстановить настройки по умолчанию",
|
||||
"restoreTitle": "Восстановить значение по умолчанию HTML?",
|
||||
"restoreErrorDesc": "Это очищает редактор. После сохранения используется встроенный шаблон сервера по умолчанию. Это действие не сохраняется автоматически.",
|
||||
"restoreOfflineDesc": "Это очищает редактор. После сохранения используется встроенный шаблон по умолчанию. Это действие не сохраняется автоматически.",
|
||||
"confirmRestore": "Восстановить"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user