feat(i18n): default to English, localize runtime text and add Russian (#52)

This commit is contained in:
ryan
2026-11-07 21:35:55 +08:00
359 changed files with 15795 additions and 4333 deletions
+4
View File
@@ -33,3 +33,7 @@ jobs:
- name: Run ESLint
working-directory: frontend
run: npx eslint . --max-warnings 0
- name: Check translation catalogs and runtime language
working-directory: frontend
run: node scripts/merge-i18n-fragments.mjs && pnpm check:i18n
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
// if any unchecked, fail the workflow
if (unchecked.length > 0) {
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
core.setFailed(`Complete all PR checklist items. Unchecked items:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
+1
View File
@@ -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
+1 -1
View File
@@ -45,7 +45,7 @@ code-check:
exit 1; \
fi
golangci-lint run
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm check:i18n && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
+11 -1
View File
@@ -17,13 +17,23 @@ sidebar: false
## [Unreleased]
- 新增完整俄语界面资源及服务端展示文案,支持俄语浏览器语言识别、原生语言名称、日期与数字格式、日历及数量复数形式;补齐图表标题、表单字段和无障碍按钮文案的国际化,内置错误页及离线页跟随界面语言,保留用户自定义内容。
- 选择非默认语言时,在静态页面首次绘制前等待对应语言资源就绪,避免刷新或完整跳转时短暂显示英文。
- 修复本地化审查发现的通知收件人误判,保留历史系统目标并正确解析同名普通用户;恢复推送模板变量与富文本标签,补齐公开错误和限流响应翻译,修正俄语技术示例、数量及术语,并在首次渲染、系统指标和日历无障碍标签中应用当前语言。界面脚本加载失败时提供独立的同语言重新加载入口,避免无限空白页。
### 优化
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成;分页控件支持中英文及俄语,新增注册和列表错误按界面语言展示。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
### 🛠 修复
- 将界面默认语言调整为英文,保留已有语言偏好和中文语言资源。
- 静态页面以英文预渲染,并在水合界面绘制前应用已保存的语言偏好,避免英文界面刷新时先显示中文再切换。
- 将 Server、Agent、任务执行详情、安装脚本及浏览器调试日志的固定描述统一为英文;任务和通知元数据、接口错误、页面标题、表单校验与内置页面模板按中英文语言包展示,历史任务记录在读取时兼容翻译,不回写用户数据或上传统计键。
- 增加语言包一致性与运行时中文文案检查,防止页面和终端重新混入未登记的中文固定文案。
- 语言选择器固定显示各语言的原生名称,并提供全仓库非 ASCII 文本清单命令,按模块和用途区分语言资源、源码、文档与兼容标识。
- 根据实际界面审校英文通知、任务与模板提示,修正文档拼接缺少空格、重复用词和 Webhook JSON 示例转义;系统配置及内置模板的默认元数据按语言展示,保留自定义内容与原始编辑值,并改善窄屏 WAF 标题和通知模板说明的换行。
- 修复 Agent 自动注册响应凭证字段不一致导致节点不断重复创建的问题;新增持久化注册重试凭证与退避,响应丢失、并发重试或配置写入失败后仍复用原节点,且不会覆盖管理员修改或恢复已更换的节点凭证。支持目录刷盘的平台会确认原子替换后的目录持久化,失败时阻止使用未确认的注册凭证;接口文档同步列出两种兼容凭证字段。
- SQLite 文件数据库启动时自动启用 WAL 模式,改善读写并发;数据库导出改用一致性快照,确保包含 WAL 中已提交的数据。
+45
View File
@@ -0,0 +1,45 @@
# 运行时语言与国际化
## 运维输出与界面文案
Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。
前端使用 `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`、`中文`、`Русский`,不随当前界面语言翻译语言名称。日期、数字和日历跟随界面语言;语言切换不改变既有时区。俄语数量文案使用 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`,没有支持的语言时回退英文。
- 错误中间件先记录原始英文异常及 OTel 状态,再翻译 HTTP 响应中的 `error_msg`。登录流程使用的 `smtp_invalid:` 和 `need_email_code:` 控制前缀保持不变。
- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。
- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。
- 通知接收目标只识别既有技术标识 `system`、`0` 和历史别名 `系统`;显示译文 `System`、`Система` 不会扩大保留目标集合,也不会把同名普通用户改投给管理员。
- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。
格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。
## 持久化兼容性
上传统计原有的六个中文分类名同时是数据库计数器键和 API 分类标识,不能当作标签直接改名。保留这些既有键,前端图表、图例和最大分类说明单独使用语言包标签;无需清空或重建现有统计。
历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知语言默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。内置源站错误页和离线页在选择模板或空值预览时使用当前语言,已保存的自定义 HTML 保持不变。
系统状态 API 的诊断时长和容量字符串保留英文及技术单位,界面在展示边界转换为当前语言;原始字符串继续用于既有内存计算。数据库计数采用当前 `next-intl` 上下文格式化。日历使用 DayPicker 扩展语言包,同时翻译日期与导航、下拉框、选中日期等无障碍名称。
## 校验与扩展
在仓库根目录运行 `node scripts/audit-non-english.mjs` 可生成完整的非 ASCII 文本清单,默认输出到被 Git 忽略的 `.codex-artifacts/non-english-audit/`。`occurrences.csv` 包含文件、行号、模块、分类和原文;JSON 另含字符位置、Unicode 码点、字符集及跳过的文件;`review-candidates.csv` 单列待审查源码。命令读取所有已跟踪和未被忽略的新文件,不按业务目录名称排除内容,也识别 Unicode 转义。
清单包含标点、表情、注释、文档、语言资源与历史迁移,不表示每个命中都需要替换。依赖、构建输出和私有环境文件不进入清单;二进制文件单独列出,不执行截图 OCR 或解包。生成的 Swagger 和中文文档站配置保留中文,既有上传分类键保留兼容性。字符集也不等同于语言,例如 Han 字符可能属于中文或日文。
`pnpm check:i18n` 校验三个语言包的键、标签和 `$` 变量一致性,并检查 Go、TypeScript/TSX 和安装脚本中的汉字、日文假名、韩文及西里尔字母运行时文本;注释、语言资源、测试样例、六个上传持久化键及通知历史目标别名有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。俄语资源测试还验证命名参数、真实 rich-text 渲染、ICU 格式、复数边界、可复制的限速语法及 HTML 模板占位符,服务端测试验证格式动词、模板变量和枚举值。技术标识(如 `10r/s`、`30m`、`true`、`and`)不随语言翻译。
新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
+2
View File
@@ -128,6 +128,8 @@ SQLite 文件数据库在启动时自动启用 WAL 模式;内存数据库保
| `log.max_size` | - | 单个日志文件的最大空间(MB),超出自动切割轮转 | `100` |
| `log.max_age` | - | 历史切割日志文件最大保留天数 | `30` |
Server、Agent、调度器、任务框架及存储维护等运行时日志的固定描述使用英文,便于跨语言排障和检索。界面语言切换不会改变终端日志和 OTel 异常;接口错误、任务执行详情与内置元数据在展示时按语言翻译,历史记录不回写。用户配置名称及第三方错误保留原文,可能包含中文。现有日志格式、级别、参数及输出渠道保持不变,详见 [运行时语言与国际化](../design/log-language.md)。
### 6. 异步任务 Worker 队列配置 (`worker:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
+12 -6
View File
@@ -1,17 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
import {
createApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default function ApiDocPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='API 接口文档'
title={rt('api_documentation_cb3d2229')}
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
sections={createApiSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
OpenFlare 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
{rt('openflare_provides_a_simple_powerful_api_for_ba400468')}
</p>
}
/>
+9 -5
View File
@@ -1,17 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { createHowToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='平台使用指南'
title={rt('platform_user_guide_d0ead991')}
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
sections={createHowToUseSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 OpenFlare
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
{rt('welcome_to_the_openflare_management_platform_this_e8ecbe4d')}
</p>
}
/>
@@ -1,18 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { createPrivacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='隐私政策 (Privacy)'
title={rt('privacy_policy_b6da1ca6')}
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
sections={createPrivacySections(rt)}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
{rt('we_value_your_privacy_this_policy_explains_2c9e3c0b')}
<br className='hidden md:block' />
以及您享有的相关权利。
{rt('and_the_rights_you_have_over_it_4560abf2')}
</span>
}
/>
@@ -1,16 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
createTermsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='服务协议 (Terms)'
title={rt('terms_of_service_4e80a140')}
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
sections={createTermsSections(rt)}
description={
<span>
{rt('please_read_the_following_terms_carefully_to_c46a6a61')}
</span>
}
/>
);
}
@@ -1,3 +1,4 @@
import { getFormattingLocale } from '@/i18n/format';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
@@ -66,7 +67,7 @@ export function parseSortValue(value: string) {
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
return new Intl.NumberFormat(getFormattingLocale(), {
notation: value >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
@@ -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>
+19 -17
View File
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -22,6 +22,8 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
@@ -59,7 +61,8 @@ export default function HeaderDemoPage() {
{t('rules.structureFocus.description')}
</li>
<li>
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>
{rt('use_1b3d699e')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
@@ -67,22 +70,23 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
(或{' '}
{rt('or_07d4e3c0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>
{rt('place_the_lucide_icon_directly_inside_the_104f794c')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>
{rt('use_only_5e12eecb')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
@@ -93,10 +97,8 @@ export default function HeaderDemoPage() {
{t('rules.textStyle.description2')}
</li>
<li>
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<strong>{t('rules.tabsModular.title')}</strong>
{rt('for_pages_with_multiple_tabs_keep_each_5f721959')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
@@ -107,8 +109,8 @@ export default function HeaderDemoPage() {
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
<strong>{t('rules.complexityDriven.title')}</strong>
{rt('beyond_tabs_when_a_page_exceeds_600_d152cfdc')}
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
@@ -121,19 +123,19 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
) 的{' '}
{rt('message_18ab4763')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
table-browser.tsx
</code>
、
{rt('list_separator')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
cache-manager.tsx
</code>{' '}
与{' '}
{rt('and_de1d5dd0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
sql-console.tsx
</code>
。
{rt('sentence_end')}
</li>
</ul>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -26,6 +26,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
@@ -166,78 +168,71 @@ export function ControlsTab() {
{t('spec.unifiedHeight')}
</p>
<p>
输入框 (
{rt('inputs_fabee312')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
Input
</code>
) 与下拉选择器 (
{rt('and_selectors_e4875cdf')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
SelectTrigger
</code>
) 的高度应统一限制为小号高度{' '}
{rt('should_share_the_compact_height_f1fe2218')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8
</code>
,字体大小使用{' '}
{rt('with_font_size_e7c29991')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs
</code>
,并使用{' '}
{rt('and_use_ca398a12')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
shadow-none
</code>{' '}
去除原生的重叠阴影。
{rt('to_remove_overlapping_native_shadows_dd65b475')}
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.checkboxAlignment')}
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
{rt('align_checkboxes_toward_the_top_left_using_092dcef0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-start gap-2.5
</code>{' '}
排列。Checkbox 组件本身添加{' '}
{rt('add_bf15da3a')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
mt-0.5
</code>
,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
{rt('to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b')}
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.buttonStrategy')}
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
{rt('use_the_default_primary_or_secondary_db7513e5')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'variant="secondary"'}
</code>{' '}
按钮,小号高度统一采用{' '}
{rt('button_for_forms_and_create_save_actions_a657dba8')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8 text-xs
</code>
。线框按钮必须带有{' '}
{rt('outline_buttons_must_include_the_66ace005')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
类。
{rt('class_43eff5ff')}
</p>
</div>
<div className='bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto'>
<div>{`// 1. Label 与 Input 对齐`}</div>
<div>{`<div className="space-y-1.5">
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
<Input id="id" className="h-8 text-xs shadow-none" />
</div>`}</div>
<div className='pt-2'>{`// 2. Checkbox 顶对齐`}</div>
<div>{`<div className="flex items-start gap-2.5">
<Checkbox id="c" className="mt-0.5" />
<div className="grid gap-1">
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
</div>
</div>`}</div>
<div>{rt('1_align_label_and_input_32d4558d')}</div>
<div>{rt.raw('div_classname_space_y_1_5_label_32230ad7')}</div>
<div className='pt-2'>
{rt('2_align_checkbox_to_the_top_bdf68ad8')}
</div>
<div>{rt.raw('div_classname_flex_items_start_gap_2_ccf0d526')}</div>
</div>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
@@ -10,6 +10,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
@@ -129,17 +131,9 @@ export function DashboardTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">指标标题</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<Progress value={42.8} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">状态说明小字</p>
</CardContent>
</Card>`}
{rt.raw(
'card_classname_border_dashed_shadow_none_cardheader_35727acf',
)}
</pre>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
@@ -20,6 +20,8 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
@@ -193,7 +195,7 @@ export function TableTab() {
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.headerBg')}
@@ -204,7 +206,7 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.cellDivider')}
@@ -215,14 +217,14 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.badgeStyle')}
@@ -233,27 +235,7 @@ export function TableTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">编号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs">ID-1001</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>`}
{rt.raw('div_classname_border_border_dashed_shadow_none_b430b520')}
</pre>
</CardContent>
</Card>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -72,7 +73,7 @@ function getFileIcon(mimeType: string, className = 'size-8') {
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN', {
return new Date(dateStr).toLocaleString(getFormattingLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -82,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('');
@@ -564,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}
@@ -50,6 +50,12 @@ const categoryMap: Record<string, string> = {
其他: 'others',
};
// The API retains persisted category keys; labels always follow the UI locale.
function categoryTranslationKey(name: string) {
const key = categoryMap[name] || 'others';
return `stats.cat${key.charAt(0).toUpperCase()}${key.slice(1)}`;
}
function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
@@ -158,11 +164,11 @@ export function FileStats() {
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count;
maxCountName = cat.name;
maxCountName = t(categoryTranslationKey(cat.name));
}
if (cat.size > maxSize) {
maxSize = cat.size;
maxSizeName = cat.name;
maxSizeName = t(categoryTranslationKey(cat.name));
}
});
@@ -217,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'>
@@ -254,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'>
@@ -378,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'>
@@ -488,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')}
@@ -17,6 +17,7 @@ limitations under the License.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import {
BarChart3,
Globe,
@@ -117,7 +118,7 @@ export function AccessAnalytics() {
const errorInstance =
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
if (isLogStorageDisabledMessage(errMsg)) {
setClickhouseDisabled(true);
} else {
setError(errorInstance);
@@ -1,3 +1,5 @@
'use client';
/*
Copyright 2026 Arctel.net
@@ -14,9 +16,8 @@ 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';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -94,10 +95,12 @@ function statusVariant(status: number) {
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || '';
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
return isLogStorageDisabledMessage(errMsg);
}
export function AccessLogs() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
@@ -519,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'
@@ -0,0 +1,14 @@
import { locales } from '@/i18n/config';
import { getRuntimeTranslator } from '@/i18n/runtime';
export function isLogStorageDisabledMessage(message: string): boolean {
const normalized = message.toLowerCase();
return (
message.includes('ClickHouse') ||
locales.some((locale) =>
normalized.includes(
getRuntimeTranslator(locale)('disabled_f95ea7f4').toLowerCase(),
),
)
);
}
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -81,6 +81,8 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
@@ -706,11 +708,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{newEventType === 'task'
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -721,7 +723,11 @@ export function EventsTab() {
onChange={(e) => setNewEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
newEventType === 'task'
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</div>
@@ -886,11 +892,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{selectedEvent.task_type
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -901,7 +907,11 @@ export function EventsTab() {
onChange={(e) => setEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
selectedEvent.task_type
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</div>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -62,6 +62,8 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
@@ -606,7 +608,7 @@ export function SettingsTab() {
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
{rt('lark_webhook_c2e0b312')}
</Button>
<Button
variant='outline'
@@ -623,7 +625,9 @@ export function SettingsTab() {
msgtype: 'markdown',
markdown: {
title: '$title',
text: '### $title\n$content\n\n[查看详情]($url)',
text: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -633,7 +637,7 @@ export function SettingsTab() {
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
{rt('dingtalk_group_bot_5d8efec9')}
</Button>
<Button
variant='outline'
@@ -649,8 +653,9 @@ export function SettingsTab() {
{
msgtype: 'markdown',
markdown: {
content:
'### $title\n$content\n\n[查看详情]($url)',
content: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -660,7 +665,7 @@ export function SettingsTab() {
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
{rt('wecom_group_bot_138ea7a8')}
</Button>
<Button
variant='outline'
@@ -684,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>
);
+20 -5
View File
@@ -1,7 +1,8 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { localizeConfigDescription } from '@/i18n/builtin-metadata';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -52,6 +53,7 @@ function SystemConfigDetailPanel({
saving: boolean;
}) {
const t = useTranslations('admin.system');
const locale = useLocale();
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
@@ -138,8 +140,16 @@ function SystemConfigDetailPanel({
type='text'
value={
editData.description !== undefined
? editData.description
: config?.description || ''
? localizeConfigDescription(
config?.key ?? '',
editData.description,
locale,
)
: localizeConfigDescription(
config?.key ?? '',
config?.description || '',
locale,
)
}
placeholder={t('optionalDescription')}
onChange={(e) => {
@@ -205,6 +215,7 @@ function SystemConfigDetailPanel({
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const locale = useLocale();
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -309,9 +320,13 @@ export function SystemConfigs() {
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
title={item.description}
title={localizeConfigDescription(
item.key,
item.description,
locale,
)}
>
{item.description}
{localizeConfigDescription(item.key, item.description, locale)}
</span>
),
width: '200px',
@@ -47,7 +47,6 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
@@ -177,7 +176,6 @@ function DatePickerWithTime({
mode='single'
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
@@ -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>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
@@ -16,7 +17,7 @@ function buildNodeRankItems(
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
return left.name.localeCompare(right.name, getFormattingLocale());
}
return rightValue - leftValue;
})
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
@@ -506,7 +507,7 @@ export function WorldStageMap({
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString(getFormattingLocale()) })}</div>`,
].join('');
}
@@ -522,8 +523,8 @@ export function WorldStageMap({
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
requests: data.requestCount.toLocaleString(getFormattingLocale()),
errors: data.errorCount.toLocaleString(getFormattingLocale()),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import type { LucideIcon } from 'lucide-react';
import {
@@ -13,6 +16,8 @@ interface PlaceholderPageProps {
}
export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
const rt = useTranslations('runtime');
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center gap-2'>
@@ -22,8 +27,12 @@ export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
<Card>
<CardHeader>
<CardTitle className='text-base'>开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
<CardTitle className='text-base'>
{rt('under_development_78c33fbf')}
</CardTitle>
<CardDescription>
{rt('this_feature_is_being_migrated_and_developed_61ebaf78')}
</CardDescription>
</CardHeader>
</Card>
</div>
@@ -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={[
{
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
@@ -652,7 +653,9 @@ export function NodeObservability({
hint={
topSourceCountry
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
count: topSourceCountry.value.toLocaleString(
getFormattingLocale(),
),
})
: t('obs.noSourceDist')
}
@@ -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,3 +1,5 @@
import { getFormattingLocale } from '@/i18n/format';
import { runtimeText } from '@/i18n/runtime';
import type {
ApplyResult,
NodeItem,
@@ -111,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) {
@@ -255,7 +257,7 @@ export function formatMetricCount(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
return value.toLocaleString(getFormattingLocale());
}
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
@@ -79,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const rt = useTranslations('runtime');
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
@@ -116,7 +118,9 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
'relay_frps_web_ui_port',
{
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
description:
portCfg?.description ||
rt('port_for_the_built_in_frps_web_cfab540f'),
},
);
},
@@ -485,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'
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
@@ -154,7 +155,8 @@ const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function linesFromTextarea(value: string) {
@@ -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,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { Gauge } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -31,9 +32,11 @@ function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 1,
});
}
return value.toLocaleString('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
@@ -48,7 +49,7 @@ function formatAxisTime(value: string, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) return formatCompactNumber(value);
return value.toLocaleString('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
+19 -8
View File
@@ -1,7 +1,10 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error ? error.message : (fallback ?? '操作失败');
return error instanceof Error
? error.message
: (fallback ?? runtimeText('operation_failed_0c3b4cf7'));
}
export function listToText(items: string[] | undefined) {
@@ -17,7 +20,9 @@ export function parseTextareaList(text: string) {
export function parseAutomaticConfig(
text: string,
invalidMessage = '自动配置必须是 JSON 对象。',
invalidMessage = runtimeText(
'automatic_configuration_must_be_a_json_object_90ba17a4',
),
): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -28,12 +33,16 @@ export function parseAutomaticConfig(
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
get name() {
return runtimeText('high_frequency_404_scan_from_one_ip_f7d27ffb');
},
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
get name() {
return runtimeText('abnormal_direct_access_from_one_ip_732dbeee');
},
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
@@ -75,7 +84,7 @@ export function formatIPGroupBanRemaining(
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
return t ? t('ban.permanent') : '永久';
return t ? t('ban.permanent') : runtimeText('permanent_3e71ccc8');
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
@@ -83,7 +92,7 @@ export function formatIPGroupBanRemaining(
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= reference.getTime()) {
return t ? t('ban.expired') : '已过期';
return t ? t('ban.expired') : runtimeText('expired_2fe0e333');
}
const diffMins = Math.round(
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
@@ -91,10 +100,12 @@ export function formatIPGroupBanRemaining(
if (diffMins < 60) {
return t
? t('ban.minutesLater', { count: diffMins })
: `${diffMins} 分钟后`;
: runtimeText('in_value0_minutes_fd2a5b53', { value0: diffMins });
}
const hours = Math.round(diffMins / 60);
return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
return t
? t('ban.hoursLater', { count: hours })
: runtimeText('in_value0_hours_5ca37144', { value0: hours });
}
export function getIPGroupViewEntries(
@@ -1,7 +1,9 @@
import { locales } from '@/i18n/config';
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { wouldCreateCycle } from './graph-validation';
import { getRuntimeTranslator } from '@/i18n/runtime';
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
@@ -109,9 +111,9 @@ export function findGraphErrorTarget(
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, '节点', nodeIds);
const nodeId = findMessageId(message, 'node', nodeIds);
if (nodeId) return { kind: 'node', id: nodeId };
const edgeId = findMessageId(message, '边', edgeIds);
const edgeId = findMessageId(message, 'edge', edgeIds);
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
@@ -132,16 +134,22 @@ function collectMessages(value: unknown): string[] {
function findMessageId(
message: string,
prefix: '节点' | '边',
prefix: 'node' | 'edge',
ids: string[],
): string | undefined {
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
const tokenKey = prefix === 'edge' ? 'edge_099db313' : 'node_2410d860';
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
message,
),
locales.some((locale) => {
const t = getRuntimeTranslator(locale);
const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
const pattern = t
.raw('value0_s_value1_s_40960c98')
.replace('{value0}', token)
.replace('{value1}', escapeRegExp(id));
return new RegExp(pattern, 'i').test(message);
}),
);
}
@@ -1,8 +1,22 @@
import { describe, expect, it } from 'vitest';
import { countryOptions, regionOptions } from './geo-options';
import { createGeoOptions } from './geo-options';
const { countryOptions, regionOptions } = createGeoOptions('zh-CN');
describe('geo options', () => {
it('uses English country names when requested without changing country codes', () => {
const english = createGeoOptions('en');
expect(english.countryOptions).toContainEqual(
expect.objectContaining({ value: 'CN', label: 'China' }),
);
expect(english.regionOptions).toContainEqual(
expect.objectContaining({
value: 'CN-BJ',
searchText: expect.stringContaining('China'),
}),
);
});
it('contains the complete country list with localized names', () => {
expect(countryOptions).toHaveLength(249);
expect(countryOptions).toContainEqual(
@@ -8,46 +8,50 @@ export interface GeoOption {
const countryCodePattern = /^[A-Z]{2}$/;
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
type: 'region',
});
export function createGeoOptions(locale: string) {
const countryDisplayNames = new Intl.DisplayNames([locale], {
type: 'region',
});
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
}
const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, locale));
const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
return { countryOptions, regionOptions };
}
export const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, 'zh-CN'));
export const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
@@ -39,25 +40,33 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
const issues: GraphIssue[] = [];
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
if (graph.schema_version !== 1)
issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
issues.push({
code: 'schema',
message: runtimeText('rule_graph_schema_version_must_be_1_ea9a1161'),
});
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
issues.push({
code: 'size_limit',
message: runtimeText('rule_graph_exceeds_the_size_limit_337651b9'),
});
const nodeIds = new Set<string>();
for (const node of graph.nodes) {
if (!node.id.trim())
issues.push({
code: 'empty_id',
message: '节点 ID 不能为空',
message: runtimeText('node_id_must_not_be_empty_51a5de58'),
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
message: `节点 ID ${node.id} 重复`,
message: runtimeText('duplicate_node_id_value0_621230af', {
value0: node.id,
}),
nodeId: node.id,
});
nodeIds.add(node.id);
@@ -74,20 +83,22 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!edge.id.trim())
issues.push({
code: 'empty_id',
message: '连线 ID 不能为空',
message: runtimeText('edge_id_must_not_be_empty_bc8f69cb'),
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
message: `连线 ID ${edge.id} 重复`,
message: runtimeText('duplicate_edge_id_value0_0a9d1c7c', {
value0: edge.id,
}),
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
message: '开始节点不能有入边',
message: runtimeText('the_start_node_must_not_have_incoming_50a07b17'),
edgeId: edge.id,
nodeId: edge.target,
});
@@ -95,12 +106,12 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
message: '规则图必须恰好有一个开始节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_7dfbc027'),
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
message: '规则图必须恰好有一个通过节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_0036eb82'),
});
for (const edge of graph.edges) {
@@ -112,7 +123,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
) {
issues.push({
code: 'invalid_edge',
message: `连线 ${edge.id} 的端点或出口无效`,
message: runtimeText(
'edge_value0_has_an_invalid_endpoint_or_9d84eb7e',
{ value0: edge.id },
),
edgeId: edge.id,
});
}
@@ -125,13 +139,19 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
message: runtimeText(
'node_value0_output_value1_is_not_connected_c3038e81',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
message: runtimeText(
'node_value0_output_value1_can_only_be_903e54ce',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
}
@@ -151,7 +171,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
message: `节点 ${node.id} 无法从开始节点到达`,
message: runtimeText(
'node_value0_is_unreachable_from_the_start_086531dc',
{ value0: node.id },
),
nodeId: node.id,
});
const terminals = graph.nodes
@@ -162,18 +185,27 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
message: `节点 ${node.id} 无法抵达终止节点`,
message: runtimeText(
'node_value0_cannot_reach_a_terminal_node_8de2ff6e',
{ value0: node.id },
),
nodeId: node.id,
});
if (hasCycle(graph))
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
issues.push({
code: 'cycle',
message: runtimeText('the_rule_graph_must_not_contain_cycles_fd6fe797'),
});
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value)))
return `节点 ${node.id} 包含无效 IP`;
return runtimeText(
'node_value0_contains_an_invalid_ip_address_1873ab94',
{ value0: node.id },
);
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
@@ -186,9 +218,13 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
);
})
)
return `节点 ${node.id} 包含无效 CIDR`;
return runtimeText('node_value0_contains_an_invalid_cidr_00c880d9', {
value0: node.id,
});
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
return `节点 ${node.id} 包含无效 IP 组`;
return runtimeText('node_value0_contains_an_invalid_ip_group_589219a5', {
value0: node.id,
});
}
if (
node.type === 'geo_match' &&
@@ -197,7 +233,10 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
return `节点 ${node.id} 包含无效地域代码`;
return runtimeText(
'node_value0_contains_an_invalid_geography_code_1a9f469b',
{ value0: node.id },
);
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
@@ -205,37 +244,61 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
return `节点 ${node.id} 的 PoW 配置超出范围`;
return runtimeText('node_value0_has_pow_settings_outside_the_9fd2a4cb', {
value0: node.id,
});
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return `节点 ${node.id} 的阻止响应配置无效`;
return runtimeText(
'node_value0_has_invalid_block_response_settings_ae4c35ca',
{ value0: node.id },
);
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
return runtimeText('node_value0_match_mode_must_be_and_242c1ae7', {
value0: node.id,
});
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
return `节点 ${node.id} 包含无效浏览器标签`;
return runtimeText(
'node_value0_contains_an_invalid_browser_tag_4d0e6740',
{ value0: node.id },
);
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return `节点 ${node.id} 包含无效操作系统标签`;
return runtimeText(
'node_value0_contains_an_invalid_operating_system_45bea1fb',
{ value0: node.id },
);
if (node.config.custom_ua_patterns.length > 32)
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
return runtimeText('node_value0_allows_at_most_32_custom_497938c6', {
value0: node.id,
});
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (!pattern.trim())
return runtimeText('node_value0_custom_ua_pattern_must_not_a72718be', {
value0: node.id,
});
if (new TextEncoder().encode(pattern).length > 256)
return `节点 ${node.id} 的自定义 UA 正则过长`;
return runtimeText('node_value0_custom_ua_pattern_is_too_c2c83c0e', {
value0: node.id,
});
try {
void new RegExp(pattern);
} catch {
return `节点 ${node.id} 的自定义 UA 正则无效`;
return runtimeText('node_value0_has_an_invalid_custom_ua_c47ffd0d', {
value0: node.id,
});
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
return runtimeText('node_value0_requires_at_least_one_pattern_6114bb93', {
value0: node.id,
});
}
return undefined;
}
@@ -1,29 +1,38 @@
import { describe, expect, it } from 'vitest';
import { getRuntimeTranslator } from '@/i18n/runtime';
import {
createRuleNode,
displayNodeTitle,
NODE_TYPE_LABELS,
parseAddableNodeType,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
displayNodeTitle({
type: 'ip_match',
label: ' 办公室 ',
}),
displayNodeTitle(
{
type: 'ip_match',
label: ' 办公室 ',
},
getRuntimeTranslator('en'),
),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle({
type: 'block',
label: ' ',
}),
).toBe(NODE_TYPE_LABELS.block);
displayNodeTitle(
{
type: 'block',
label: ' ',
},
getRuntimeTranslator('en'),
),
).toBe('Block');
expect(
displayNodeTitle({ type: 'block' }, getRuntimeTranslator('zh-CN')),
).toBe('阻止');
});
});
@@ -7,22 +7,25 @@ export type AddableNodeType = Extract<
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
start: '开始',
ip_match: 'IP 匹配',
geo_match: '地域匹配',
ua_check: 'UA 检查',
security_check: '安全防护',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
};
export const NODE_TYPE_LABEL_KEYS = {
start: 'start_d2bb025a',
ip_match: 'ip_match_43f4b6d4',
geo_match: 'geography_match_9acb08c4',
ua_check: 'ua_check_b56dac23',
security_check: 'security_checks_e09822e6',
pow: 'pow_challenge_3c043088',
allow: 'allow_1e9f2561',
block: 'block_2a366d09',
} as const satisfies Record<WAFRuleNode['type'], string>;
export function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
translate: (
key: (typeof NODE_TYPE_LABEL_KEYS)[WAFRuleNode['type']],
) => string,
): string {
const custom = node.label?.trim();
return custom || NODE_TYPE_LABELS[node.type];
return custom || translate(NODE_TYPE_LABEL_KEYS[node.type]);
}
export function createRuleNode(
@@ -1,3 +1,4 @@
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -10,7 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import {
NODE_TYPE_LABELS,
NODE_TYPE_LABEL_KEYS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
@@ -25,6 +26,7 @@ const items = [
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
export function NodeLibrary() {
const rt = useTranslations('runtime');
return (
<div className='flex items-center gap-2'>
{items.map(({ type, icon: Icon }) => (
@@ -42,7 +44,7 @@ export function NodeLibrary() {
}}
>
<Icon data-icon='inline-start' />
{NODE_TYPE_LABELS[type]}
{rt(NODE_TYPE_LABEL_KEYS[type])}
</Button>
))}
</div>
@@ -1,6 +1,6 @@
import { CircleHelp, Settings2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -35,8 +35,8 @@ import {
} from '@/components/ui/tooltip';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABELS } from './node-factory';
import { createGeoOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABEL_KEYS } from './node-factory';
import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
@@ -78,6 +78,14 @@ function PropertyFields({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const locale = useLocale();
const { countryOptions, regionOptions } = useMemo(
() =>
node.type === 'geo_match'
? createGeoOptions(locale)
: { countryOptions: [], regionOptions: [] },
[locale, node.type],
);
if (node.type === 'start' || node.type === 'allow')
return (
<p className='text-sm text-muted-foreground'>{t('systemNodeNoConfig')}</p>
@@ -518,13 +526,14 @@ function DisplayNameField({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const rt = useTranslations('runtime');
return (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>{t('displayName')}</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={NODE_TYPE_LABELS[node.type]}
placeholder={rt(NODE_TYPE_LABEL_KEYS[node.type])}
onChange={(event) => onChange({ ...node, label: event.target.value })}
/>
</Field>
@@ -1,4 +1,5 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -42,10 +43,11 @@ const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
};
export function RuleNode({ data, selected }: NodeProps) {
const rt = useTranslations('runtime');
const value = data as RuleFlowNodeData;
const { rule, issues } = value;
const { icon: Icon } = meta[rule.type];
const title = displayNodeTitle(rule);
const title = displayNodeTitle(rule, rt);
return (
<div
className={cn(
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
export const UA_BROWSER_OPTIONS = [
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Safari', label: 'Safari' },
@@ -5,12 +6,32 @@ export const UA_BROWSER_OPTIONS = [
{ value: 'Edge', label: 'Edge' },
{ value: 'Opera', label: 'Opera' },
{ value: 'Chromium', label: 'Chromium' },
{ value: 'WeChat', label: '微信' },
{
value: 'WeChat',
get label() {
return runtimeText('wechat_81b430b6');
},
},
{ 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 = [
@@ -20,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>(
@@ -219,8 +219,8 @@ function EditorContent() {
<ArrowLeft className='size-3.5' />
{t('back')}
</Button>
<div className='flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<div className='flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
@@ -235,7 +235,7 @@ function EditorContent() {
</Badge>
{dirty && <Badge variant='secondary'>{t('unsaved')}</Badge>}
</div>
<div className='flex shrink-0 items-center gap-2'>
<div className='flex flex-wrap items-center gap-2 xl:shrink-0'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Activity, HardDrive, Users } from 'lucide-react';
@@ -31,13 +32,13 @@ function formatAxisLabel(iso: string, range: ZoneStatsRange) {
return '';
}
if (range === '24h') {
return date.toLocaleTimeString('en-US', {
return date.toLocaleTimeString(getFormattingLocale(), {
hour: 'numeric',
minute: undefined,
hour12: true,
});
}
return date.toLocaleDateString('en-GB', {
return date.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'short',
});
@@ -54,7 +55,10 @@ function formatWindowLabel(startedAt?: string, endedAt?: string) {
}
const fmt = (value: Date) =>
value
.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'long',
})
.toUpperCase();
return `${fmt(start)} — ${fmt(end)}`;
}
@@ -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)}
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
@@ -17,7 +18,8 @@ export type TranslateFn = (
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function getMatchTypeMeta(domain: string): {
@@ -25,8 +27,8 @@ export function getMatchTypeMeta(domain: string): {
tone: StatusTone;
} {
return domain.startsWith('*.')
? { label: '通配符', tone: 'warning' }
: { label: '精确匹配', tone: 'info' };
? { label: runtimeText('wildcard_c6b0739e'), tone: 'warning' }
: { label: runtimeText('exact_match_391fe725'), tone: 'info' };
}
export function getCertificateStatus(
@@ -57,7 +59,10 @@ export function getCertificateStatus(
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
? runtimeText('value0_expires_value1_7d8600f8', {
value0: certificate.name,
value1: formatDateTime(certificate.not_after),
})
: certificate.name;
}
+12
View File
@@ -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
View File
@@ -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'
+8 -5
View File
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'motion/react';
@@ -11,6 +12,8 @@ import { Button } from '@/components/ui/button';
* 当用户访问不存在的页面时显示
*/
export default function NotFound() {
const rt = useTranslations('runtime');
const router = useRouter();
return (
@@ -31,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>
@@ -43,9 +46,9 @@ export default function NotFound() {
className='space-y-8'
>
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
{rt('sorry_the_page_you_requested_seems_to_ba66cd83')}
<br />
请检查路径或返回安全地带。
{rt('check_the_path_or_return_to_safety_b1f31888')}
</p>
<div className='flex justify-center gap-4'>
@@ -56,7 +59,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className='size-3 opacity-70' />
上一页
{rt('previous_c9b9ae7a')}
</Button>
<Link href='/'>
@@ -66,7 +69,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className='size-3' />
首页
{rt('home_203c08e0')}
</Button>
</Link>
</div>
+459 -404
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
import {
@@ -16,57 +17,65 @@ export const DOCS_LAST_UPDATED = '2026-06-07';
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: 'api-specs',
title: '1. 接口规范与鉴权说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
</p>
</div>
export function createApiSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'api-specs',
title: rt('1_api_conventions_and_authentication_e6d4df61'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt(
'standard_api_request_conventions_and_developer_access_39eb956e',
)}
</p>
</div>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.1 统一响应格式
</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_1_standard_response_format_5cddae2f')}
</h3>
<p>{rt('all_api_endpoints_use_a_standard_json_87c92fb0')}</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('field_49ecd0e3')}
</DocsTableHead>
<DocsTableHead className='w-[100px]'>
{rt('type_ba40014f')}
</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('error_message_empty_on_success_contains_error_d89d457f')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
data
</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
{rt('the_response_payload_null_when_the_request_97b8b29c')}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
<p className='mt-2'>{rt('successful_response_example_8917e853')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
@@ -74,263 +83,290 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language='json'
/>
<p className='mt-2'>{rt('failed_response_example_eae899e1')}</p>
<CodeBlock
code={rt.raw('error_msg_incorrect_password_data_null_b2eccc86')}
language='json'
/>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.2 鉴权方式
</h3>
<p>
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
Cookie 会话保持登录。
</li>
<li>
<strong>AccessToken 令牌:</strong>
供后台调用或第三方应用集成使用。客户端生成 API
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
支持携带令牌的请求头格式(二选一):
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_2_authentication_methods_3c5db38a')}
</h3>
<p>
{rt(
'protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c',
)}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<ul className='list-disc pl-5 space-y-2'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
<strong>{rt('session_credentials_de72024d')}</strong>{' '}
{rt('browser_clients_can_stay_signed_in_using_f316279b')}
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
<strong>{rt('access_tokens_8de23550')}</strong>{' '}
{rt('for_backend_calls_and_third_party_integrations_dfd8bf0f')}
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
{rt('use_either_of_the_following_request_headers_1e03b04b')}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
</div>
</div>
</div>
),
children: [
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: 'auth-apis',
title: '2. 用户与认证接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 用户注册
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
),
children: [
{
value: '1-1-response-format',
title: rt('1_1_standard_response_format_5cddae2f'),
},
{
value: '1-2-authentication',
title: rt('1_2_authentication_methods_3c5db38a'),
},
],
},
{
value: 'auth-apis',
title: rt('2_user_and_authentication_endpoints_4a89d429'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_user_registration_4f332f85')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('register_a_local_account_when_registration_is_938891dc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('username_must_be_unique_and_contain_no_e81c1e5c')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('password_at_least_8_characters_37cdc807')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>{rt('no_0c70665b')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt(
'nickname_defaults_to_the_username_when_omitted_676aae7e',
)}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.2 密码登录
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
Session Cookie 会话。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_2_password_login_271483ba')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'authenticate_with_a_username_and_password_establishing_a38ff252',
)}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('username_1a3f0617')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('password_a621ab60')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.3 退出登录
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
</p>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_3_sign_out_3252611d')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('delete_the_current_session_cookie_and_sign_c213ffd9')}
</p>
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.4 获取个人资料
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>说明:</strong>获取当前登录账户的基本数据模型(包含
ID、角色、昵称等)。
</p>
</div>
),
children: [
{ value: '2-1-register', title: '2.1 用户注册' },
{ value: '2-2-login', title: '2.2 密码登录' },
{ value: '2-3-logout', title: '2.3 退出登录' },
{ value: '2-4-profile', title: '2.4 获取个人资料' },
],
},
{
value: 'token-apis',
title: '3. 个人访问令牌 (AccessToken) 接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
AccessToken 管理相关接口均要求通过 Session
登录后调用,支持普通用户权限。
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_4_get_profile_7b96110a')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_the_signed_in_account_s_basic_b0eb8cfa')}
</p>
</div>
),
children: [
{ value: '2-1-register', title: rt('2_1_user_registration_4f332f85') },
{ value: '2-2-login', title: rt('2_2_password_login_271483ba') },
{ value: '2-3-logout', title: rt('2_3_sign_out_3252611d') },
{ value: '2-4-profile', title: rt('2_4_get_profile_7b96110a') },
],
},
{
value: 'token-apis',
title: rt('3_personal_access_token_endpoints_989f5d7c'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
{rt('access_token_management_requires_a_session_login_42d91543')}
</p>
</div>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 获取令牌列表
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>说明:</strong>
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
</p>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_list_tokens_9840c227')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('list_the_current_user_s_tokens_with_c34d2210')}
</p>
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.2 新建访问令牌
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>参数:</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
</p>
<p>
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
Token,切勿遗失。
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
<code className='bg-muted px-1 rounded'>false</code>
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
</p>
<p className='mt-2'>成功返回样例:</p>
<CodeBlock
code={`{
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_2_create_an_access_token_e53bd784')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<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>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('generate_an_access_token_the_response_includes_70552390')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>
{rt('optional_default_210b9d8f')}{' '}
<code className='bg-muted px-1 rounded'>false</code>
{rt('grant_administrator_privileges_to_the_token_only_2292cad8')}{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code>{' '}
{rt('endpoints_815c88e5')}
</p>
<p className='mt-2'>{rt('successful_response_example_7783a39c')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -344,76 +380,84 @@ export const apiSections: PolicySection[] = [
}
}
}`}
language='json'
/>
language='json'
/>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.3 撤销/删除令牌
</h3>
<p>
<strong>接口:</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>说明:</strong>通过 ID
物理删除对应访问令牌,该令牌将立即失效。
</p>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_3_revoke_delete_a_token_ae510f58')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('permanently_delete_a_token_by_id_immediately_145287f6')}
</p>
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.4 轮换令牌密钥
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>说明:</strong>
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
Token,令牌名称与 ID 保持一致。
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: '3.1 获取令牌列表' },
{ value: '3-2-create-token', title: '3.2 新建访问令牌' },
{ value: '3-3-delete-token', title: '3.3 撤销/删除令牌' },
{ value: '3-4-rotate-token', title: '3.4 轮换令牌密钥' },
],
},
{
value: 'config-apis',
title: '4. 公共配置与管理接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
4.1 公共系统配置
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>说明:</strong>
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
</p>
<p className='mt-2'>返回数据结构样例:</p>
<CodeBlock
code={`{
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_4_rotate_a_token_secret_79209983')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('replace_a_token_s_secret_and_return_d2caca89')}
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: rt('3_1_list_tokens_9840c227') },
{
value: '3-2-create-token',
title: rt('3_2_create_an_access_token_e53bd784'),
},
{
value: '3-3-delete-token',
title: rt('3_3_revoke_delete_a_token_ae510f58'),
},
{
value: '3-4-rotate-token',
title: rt('3_4_rotate_a_token_secret_79209983'),
},
],
},
{
value: 'config-apis',
title: rt('4_public_configuration_and_administration_17ebdbd5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('4_1_public_system_configuration_2be912e4')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_publicly_visible_configuration_keys_and_values_bc7bff66')}
</p>
<p className='mt-2'>{rt('response_example_d3087445')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "OpenFlare",
@@ -424,50 +468,61 @@ export const apiSections: PolicySection[] = [
"oidc_login_enabled": "true"
}
}`}
language='json'
/>
language='json'
/>
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
4.2 系统配置项 CRUD (管理员)
</h3>
<p>
<strong>说明:</strong>用于在后台对 `system_configs`
配置进行动态变更,要求管理员权限会话调用。
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>获取配置列表:</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>新建配置项:</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>修改指定配置值:</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>删除配置项:</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('4_2_system_configuration_crud_administrator_2ce66c8b')}
</h3>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'manage_system_configs_dynamically_through_an_administrator_acdcdb99',
)}
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('list_configuration_3d58680b')}</strong> GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>{rt('create_a_configuration_entry_1d995264')}</strong>{' '}
POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>{rt('update_a_configuration_value_2a0b0b76')}</strong> PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>{rt('delete_a_configuration_entry_1b77a6ea')}</strong>{' '}
DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{
value: '4-1-public-config',
title: rt('4_1_public_system_configuration_2be912e4'),
},
{
value: '4-2-admin-configs',
title: rt('4_2_system_configuration_crud_administrator_2ce66c8b'),
},
],
},
];
}
+264 -239
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
@@ -6,249 +7,273 @@ import { CodeBlock } from '@/components/ui/code-block';
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: 'quick-start',
title: '1. 快速开始',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
</p>
export function createHowToUseSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'quick-start',
title: rt('1_quick_start_ef8fbb58'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt('a_guide_to_the_general_purpose_full_9cd8c987')}
</p>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('architecture_e992b65c')}</strong>{' '}
{rt('go_gin_gorm_redis_asynq_backend_react_d5fe02d3')}
</li>
<li>
<strong>{rt('authentication_d36b345b')}</strong>{' '}
{rt(
'local_username_password_registration_and_login_plus_583ab638',
)}
</li>
<li>
<strong>{rt('access_tokens_b7b856d3')}</strong>{' '}
{rt('personal_developer_access_tokens_api_keys_for_4b407149')}
</li>
<li>
<strong>{rt('observability_ea0cc1bf')}</strong>{' '}
{rt(
'zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f',
)}
</li>
</ul>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 +
React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
</li>
<li>
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
第三方自定义 OIDC (OAuth2) 认证源绑定
</li>
<li>
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
Key),用于通过 Http Header 鉴权直接调用系统 API
</li>
<li>
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
全链路 Tracing 追踪
</li>
</ul>
</div>
),
},
{
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 常规账号密码认证
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>自主注册与密码登录:</strong>
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
高强度加盐哈希存储。
</li>
<li>
<strong>系统开关控制:</strong>
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.2 第三方 OIDC 认证源
</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
进入 <strong>设置 / 个人资料</strong> 页面。
</li>
<li>
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
认证源直接触发 OAuth2 绑定流。
</li>
<li>
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: 'access-token',
title: '3. 个人访问令牌 (AccessToken) 接口对接',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
为便于开发者或第三方工具直接调用系统
API,平台提供个人访问令牌管理功能。
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 令牌生成与存储规范
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
(形如{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
)
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
</li>
<li>
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.2 携带 Header 进行 API 调用
</h3>
<p>
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
Http 请求头携带方式之一:
</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式一:Authorization Bearer 头
),
},
{
value: 'auth-security',
title: rt('2_authentication_and_security_settings_76967059'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_combines_authentication_methods_to_support_bc9f1772',
)}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_username_and_password_authentication_97184f8f')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('registration_and_password_login_c5bd6cb4')}</strong>{' '}
{rt('users_can_register_and_create_sessions_with_21883e04')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
{rt('salted_hashing_dc2a9b63')}
</li>
<li>
<strong>{rt('system_switches_53741557')}</strong>{' '}
{rt(
'administrators_can_disable_password_registration_or_login_a2648e62',
)}
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_2_third_party_oidc_providers_f14083c8')}
</h3>
<p>{rt('users_can_link_external_accounts_from_their_c08d3f5a')}</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
{rt('open_4540d74d')}
<strong>{rt('settings_profile_32835b0d')}</strong>{' '}
{rt('message_a173cce8')}
</li>
<li>
{rt(
'under_linked_accounts_view_existing_connections_or_5151eb9b',
)}
</li>
<li>{rt('after_linking_use_the_oidc_login_button_6095b15d')}</li>
</ol>
</div>
),
children: [
{
value: '2-1-login',
title: rt('2_1_username_and_password_authentication_97184f8f'),
},
{
value: '2-2-oidc',
title: rt('2_2_third_party_oidc_providers_f14083c8'),
},
],
},
{
value: 'access-token',
title: rt('3_integrating_personal_access_tokens_af2647b5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('personal_access_tokens_allow_developers_and_third_9a242a0e')}
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_token_generation_and_storage_2b78c681')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('one_time_plaintext_display_fd13912a')}</strong>{' '}
{rt('a_newly_generated_plaintext_token_such_as_57eb11a1')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
{rt('is_displayed_only_once_copy_and_save_8d378c8a')}
</li>
<li>
<strong>{rt('secure_hash_storage_1ce392ff')}</strong>{' '}
{rt('only_a_sha_256_hash_of_the_dbf434b1')}
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_2_calling_apis_with_headers_fccd7b0d')}
</h3>
<p>{rt('use_your_saved_plaintext_token_with_either_3af53a05')}</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_1_authorization_bearer_header_2a9c2a6c')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
Authorization: Bearer at_628d022b7a95e26b...`}
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式二:X-Access-Token 自定义头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_2_custom_x_access_token_header_4548169f')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
language='http'
/>
</div>
</div>
</div>
),
children: [
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: 'config-system',
title: '4. 动态系统配置项',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
</li>
<li>
<strong>核心系统配置项说明:</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:平台展示名称
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:密码登录开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:用户自主注册开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:普通用户创建令牌的最大数限制 (默认5)
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: '5. 异步任务与定时调度',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
服务解耦复杂计算或高延迟IO:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
表达式配置,定时将待执行任务推送到 Redis 队列中。
</li>
<li>
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>OpenTelemetry Tracing:</strong>自动传递 Tracing
上下文,所有经由 Gin 中间件、外部请求或 GORM
数据库的事务操作都将带有全局唯一的
Span,用于排查链路耗时或调用异常。
</li>
<li>
<strong>Zap 结构化日志:</strong>
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
等日志收集分析工具无缝对接。
</li>
</ul>
</div>
),
},
];
),
children: [
{
value: '3-1-generation',
title: rt('3_1_token_generation_and_storage_2b78c681'),
},
{
value: '3-2-usage',
title: rt('3_2_calling_apis_with_headers_fccd7b0d'),
},
],
},
{
value: 'config-system',
title: rt('4_dynamic_system_configuration_b3bf8c49'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('the_built_in_key_value_configuration_module_713f6d05')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('cached_reads_35828322')}</strong>{' '}
{rt('configuration_is_read_from_the_database_through_3e9f6a34')}
</li>
<li>
<strong>{rt('core_system_configuration_keys_6dc02be8')}</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
{rt('platform_display_name_116219b1')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
{rt('password_login_switch_153eaa45')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
{rt('user_registration_switch_827b5b21')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
{rt('maximum_tokens_per_regular_user_default_5_5dd6d9e7')}
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: rt('5_background_tasks_and_scheduling_7142ce96'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('cobra_provides_separate_commands_so_worker_services_1bbae2df')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>
{rt('scheduled_dispatch_scheduler_command_e79062ad')}
</strong>{' '}
{rt('enqueues_tasks_in_redis_according_to_their_694e5985')}
</li>
<li>
<strong>
{rt('workers_with_queue_priorities_worker_command_faa970b1')}
</strong>{' '}
{rt('asynq_consumes_tasks_by_priority_and_executes_69d82251')}
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: rt('6_distributed_tracing_and_structured_logs_e8971cad'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_integrates_monitoring_components_for_observability_2b1da50f',
)}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('ui_openTelemetryTracing')}</strong>{' '}
{rt(
'tracing_context_is_propagated_automatically_gin_requests_0f549cce',
)}
</li>
<li>
<strong>{rt('zap_structured_logs_1c9725e8')}</strong>{' '}
{rt('standardizes_backend_console_and_log_output_as_16970757')}
</li>
</ul>
</div>
),
},
];
}
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import Link from 'next/link';
import { ArrowLeft, Calendar, Check, Copy, FileText } from 'lucide-react';
@@ -21,6 +22,8 @@ export function LegalPageLayout({
sections,
description,
}: LegalPageLayoutProps) {
const rt = useTranslations('runtime');
const [activeSection, setActiveSection] = React.useState<string>(
sections[0]?.value || '',
);
@@ -192,7 +195,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`最后更新:${lastUpdated}`);
lines.push(rt('last_updated_value0_87a896df', { value0: lastUpdated }));
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -303,7 +306,7 @@ export function LegalPageLayout({
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className='w-4 h-4' />
返回首页
{rt('back_to_home_d66f4359')}
</Button>
</Link>
@@ -318,11 +321,12 @@ export function LegalPageLayout({
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8'>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<FileText className='w-3.5 h-3.5' />
{sections.length} 个内容
{sections.length} {rt('sections_fcb2696e')}
</span>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<Calendar className='w-3.5 h-3.5' />
最后更新:{lastUpdated}
{rt('last_updated_a83cd480')}
{lastUpdated}
</span>
<Button
type='button'
@@ -336,7 +340,9 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? '已复制' : '复制 Markdown'}
{hasCopied
? rt('copied_8f6f8d97')
: rt('copy_markdown_f9c71ee4')}
</Button>
</div>
{description && (
@@ -357,7 +363,7 @@ export function LegalPageLayout({
<div className='sticky top-24'>
<div className='space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin'>
<div className='text-sm font-semibold text-foreground mb-4 pl-3'>
目录
{rt('table_of_contents_52daa71e')}
</div>
<nav className='space-y-1'>
{sections.map((section) => (
@@ -445,7 +451,7 @@ export function LegalPageLayout({
<div className='pt-16 border-t border-border mt-20'>
<div className='bg-muted/30 rounded-2xl p-8 text-center'>
<p className='text-muted-foreground text-sm mb-4'>
对这些内容有疑问?
{rt('questions_about_this_content_feb7d44a')}
</p>
<div className='flex justify-center gap-4'>
<Link
@@ -453,7 +459,7 @@ export function LegalPageLayout({
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
{rt('contact_support_4cac90c8')}
</Button>
</Link>
</div>
+156 -149
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
/**
@@ -5,156 +6,162 @@ import { type PolicySection } from './types';
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: 'collection-details',
title: '1. 详细信息收集说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
1.1 身份鉴权信息:
</span>
<p className='mt-1 text-muted-foreground'>
当您通过本地账号注册或绑定第三方 OIDC
认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方
OpenID 标识符。
<strong>
我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
1.2 服务与接口日志信息:
</span>
<p className='mt-1 text-muted-foreground'>
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
IP 地址、访问日期和时间、个人访问令牌 API
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
</p>
export function createPrivacySections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'collection-details',
title: rt('1_information_we_collect_af0db091'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('we_collect_only_the_information_necessary_to_72f6e528')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{rt('1_1_authentication_information_60199ee9')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('when_you_register_locally_or_sign_in_acdd6bd1')}{' '}
<strong>
{rt('we_do_not_require_a_phone_number_3540dfca')}
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{rt('1_2_service_and_api_logs_46c9a04d')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt(
'for_system_security_and_auditing_we_automatically_89737fdd',
)}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'storage-security',
title: '2. 数据存储与安全保护',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>存储安全:</strong>
用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
</li>
<li>
<strong>加密技术:</strong>
敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API
数据传输链路强制使用 SSL/TLS 进行安全加密。
</li>
<li>
<strong>访问控制:</strong>我们实行严格的最小权限原则(Least
Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: '3. 信息使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们收集的信息将仅用于以下目的:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>身份识别:</strong>
用于确认您的注册身份,展示您的个人中心及设置页面数据。
</li>
<li>
<strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API
鉴权指令。
</li>
<li>
<strong>安全风控:</strong>利用 IP
及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
</li>
</ul>
<p>
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: '4. 信息共享与对外披露',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>4.1 共享原则:</strong>
除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>事先获得您的明确授权或同意;</li>
<li>
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
</li>
</ul>
<p>
<strong>4.2 转让与公开披露:</strong>
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
</p>
</div>
),
},
{
value: 'user-rights',
title: '5. 您的权利与数据管理',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
5.1 查阅与管理权:
</span>
<p className='mt-1 text-muted-foreground'>
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的
AccessToken (API 密钥)。
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
5.2 删除与注销权:
</span>
<p className='mt-1 text-muted-foreground'>
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
</p>
),
},
{
value: 'storage-security',
title: rt('2_data_storage_and_security_7cc5cdef'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('we_recognize_the_importance_of_data_security_cd3bd4d9')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('storage_security_a33cfbef')}</strong>{' '}
{rt('user_data_is_stored_in_a_dedicated_132af7bb')}
</li>
<li>
<strong>{rt('cryptography_9a15a88b')}</strong>{' '}
{rt(
'sensitive_password_and_personal_access_token_hashes_9f724cd8',
)}
</li>
<li>
<strong>{rt('access_control_3471eafa')}</strong>{' '}
{rt('we_apply_the_principle_of_least_privilege_79328c28')}
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: rt('3_how_we_use_information_41b4d6ad'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('collected_information_is_used_only_for_the_4868b538')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>{rt('identification_481b07f4')}</strong>{' '}
{rt('to_identify_your_registered_account_and_display_8c559094')}
</li>
<li>
<strong>{rt('service_functions_c5834f20')}</strong>{' '}
{rt(
'to_identify_and_process_api_authentication_requests_d48a36a2',
)}
</li>
<li>
<strong>{rt('security_e7132437')}</strong>{' '}
{rt('to_analyze_ip_addresses_and_activity_logs_5210836a')}
</li>
</ul>
<p>
<strong>{rt('prohibited_uses_b13fde94')}</strong>{' '}
{rt('we_will_8856fb95')}
<strong>{rt('never_c1d7d603')}</strong>{' '}
{rt('sell_your_data_to_third_parties_or_7bb4d77a')}
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: rt('4_information_sharing_and_disclosure_c034d295'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('4_1_sharing_principles_b89eb890')}</strong>{' '}
{rt('we_do_not_share_personal_information_with_ffcf1e8f')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>
{rt('with_your_prior_explicit_authorization_or_consent_0fa05878')}
</li>
<li>
{rt(
'when_required_by_applicable_laws_legal_proceedings_cb547dd6',
)}
</li>
</ul>
<p>
<strong>{rt('4_2_transfer_and_public_disclosure_383d35be')}</strong>{' '}
{rt('we_do_not_transfer_your_personal_information_e3339921')}
</p>
</div>
),
},
{
value: 'user-rights',
title: rt('5_your_rights_and_data_management_08999326'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('under_the_personal_information_protection_law_of_94ea405e')}
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{rt('5_1_access_and_management_854d0793')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('you_may_sign_in_at_any_time_8727c5be')}
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{rt('5_2_deletion_and_account_closure_9d1e7aba')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('if_you_stop_using_the_service_you_24c642c6')}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</div>
),
},
];
),
},
{
value: 'policy-update',
title: rt('6_policy_updates_and_notices_444076f5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('this_privacy_policy_may_change_as_the_2a270464')}</p>
<p>
{rt('we_will_prominently_announce_material_changes_for_210fbd3b')}
</p>
</div>
),
},
];
}
+170 -146
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = '2026-06-07';
@@ -7,153 +8,176 @@ export const TERMS_LAST_UPDATED = '2026-06-07';
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: 'contract-establishment',
title: '1. 缔约申明与服务综述',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>1.1 缔约主体:</strong>
本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
</p>
<p>
<strong>1.2 审慎阅读:</strong>
本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC
接入、API Key
令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
</p>
<p>
<strong>1.3 协议构成:</strong>
本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
</p>
</div>
),
},
{
value: 'service-definition',
title: '2. 服务定义与性质界定',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>责任自担:</strong>
关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
</li>
</ul>
</div>
),
},
{
value: 'account-specifications',
title: '3. 账号注册与使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>3.1 账号体系:</strong>
用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方
OIDC 认证源进行登录关联。
</p>
<p>
<strong>3.2 密码及令牌安全责任:</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>密码安全:</strong>您应妥善保管您账户的登录密码。
</li>
<li>
<strong>API Token 安全:</strong>个人生成的 AccessToken (API Key)
代表您账户的完整调用权限。
<strong>
因您保管不善导致 Token
泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: '4. 用户行为准则(负面清单)',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
export function createTermsSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'contract-establishment',
title: rt('1_agreement_and_service_overview_511c6782'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('1_1_parties_d2bf79cd')}</strong>{' '}
{rt('these_terms_of_service_the_agreement_govern_1e6754fc')}
</p>
<p>
<strong>{rt('1_2_read_carefully_dfde346e')}</strong>{' '}
{rt('this_system_is_a_general_purpose_full_f1d395fe')}
</p>
<p>
<strong>{rt('1_3_agreement_contents_f05cebc0')}</strong>{' '}
{rt('the_agreement_includes_these_terms_and_any_43e244b6')}
</p>
</div>
),
},
{
value: 'service-definition',
title: rt('2_service_definition_and_nature_61db87f8'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>危害国家安全:</strong>
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
</li>
<li>
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
攻击服务、服务器爆破等其他非法信息服务平台;
</li>
<li>
<strong>黄赌毒关联:</strong>
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
</li>
<li>
<strong>侵犯知识产权:</strong>
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
</li>
<li>
<strong>其他违法信息:</strong>
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
<strong>{rt('your_responsibility_527cbdbf')}</strong>{' '}
{rt('the_entity_deploying_and_operating_a_customized_047c6a8f')}
</li>
</ul>
</div>
<p>
<strong>处理规则:</strong>
一旦发现您违反上述规定,平台运维主体有权不经通知
<strong>
立即永久封禁您的账号、拦截所有 AccessToken
请求,并依法向公安机关、网安部门移交相关线索。
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: '5. 免责声明与不可抗力',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>5.1 基础免责:</strong>
本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
</p>
<p>
<strong>5.2 技术服务中断:</strong>
对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: '6. 法律适用与争议解决',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>6.1 法律适用:</strong>
本协议的订立、执行、解释及争议的解决均适用
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
</p>
<p>
<strong>6.2 争议解决:</strong>
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
</p>
</div>
),
},
];
),
},
{
value: 'account-specifications',
title: rt('3_registration_and_account_use_cfab1555'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('3_1_accounts_2631c114')}</strong>{' '}
{rt('users_may_register_through_the_frontend_or_e527ef52')}
</p>
<p>
<strong>{rt('3_2_password_and_token_security_80dd80d9')}</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('password_security_7a29e44e')}</strong>{' '}
{rt('keep_your_account_password_secure_eb9adf10')}
</li>
<li>
<strong>{rt('api_token_security_34af066f')}</strong>{' '}
{rt('a_personal_access_token_api_key_represents_6215b3b0')}{' '}
<strong>
{rt('you_are_responsible_for_data_loss_or_0c9acc88')}
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: rt('4_prohibited_user_conduct_bd5628b6'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('when_using_this_system_you_must_comply_1e54356c')}{' '}
<strong>
{rt('the_following_activities_are_strictly_prohibited_5b57de7f')}
</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
<li>
<strong>{rt('endangering_national_security_4a160c4e')}</strong>{' '}
{rt(
'opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814',
)}
</li>
<li>
<strong>{rt('illegal_information_services_c97748bb')}</strong>{' '}
{rt('hacking_tools_ddos_attack_services_server_brute_686746c4')}
</li>
<li>
<strong>{rt('pornography_gambling_and_drugs_fdb44a54')}</strong>{' '}
{rt(
'creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb',
)}
</li>
<li>
<strong>
{rt('intellectual_property_infringement_cc10b950')}
</strong>{' '}
{rt('selling_pirated_software_or_media_illegal_game_3f0f4159')}
</li>
<li>
<strong>{rt('other_unlawful_information_4085c17c')}</strong>{' '}
{rt(
'spreading_rumors_promoting_cults_or_superstition_insulting_a062981a',
)}
</li>
</ul>
</div>
<p>
<strong>{rt('enforcement_9c7cfd68')}</strong>{' '}
{rt('if_these_rules_are_violated_the_platform_86df3b18')}
<strong>
{rt('permanently_suspend_your_account_block_all_access_b9f04a42')}
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: rt('5_disclaimer_and_force_majeure_be649f43'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('5_1_general_disclaimer_30d01f0d')}</strong>{' '}
{rt('this_development_platform_is_provided_as_is_23bc2b55')}
</p>
<p>
<strong>{rt('5_2_service_interruptions_16558fa8')}</strong>{' '}
{rt('the_platform_is_not_liable_for_interruptions_405b1c95')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>
{rt(
'natural_disasters_including_typhoons_earthquakes_tsunamis_and_1bbbfb21',
)}
</li>
<li>
{rt(
'government_actions_cybersecurity_law_changes_or_administrative_2b7322f5',
)}
</li>
<li>
{rt('telecommunication_faults_or_damage_to_data_center_259a542f')}
</li>
<li>
{rt('data_corruption_or_downtime_caused_by_hacking_68f2e04f')}
</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: rt('6_governing_law_and_disputes_b9663c86'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('6_1_governing_law_ff2afc5e')}</strong>{' '}
{rt(
'the_formation_performance_interpretation_and_resolution_of_c1510775',
)}
<strong>{rt('the_laws_of_the_people_s_republic_5d5c80d6')}</strong>{' '}
{rt('excluding_the_conflict_of_laws_rules_of_b235c4e3')}
</p>
<p>
<strong>{rt('6_2_dispute_resolution_44e871de')}</strong>{' '}
{rt('disputes_should_first_be_resolved_through_friendly_1ccf4610')}
</p>
</div>
),
},
];
}
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { ImageOff } from 'lucide-react';
@@ -23,6 +24,8 @@ export function FileImagePreview({
fallbackClassName,
variant = 'default',
}: FileImagePreviewProps) {
const rt = useTranslations('runtime');
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
@@ -34,7 +37,7 @@ export function FileImagePreview({
return (
<div
role='img'
aria-label={`${alt} 加载失败`}
aria-label={rt('failed_to_load_value0_7f5a06a3', { value0: alt })}
className={cn(
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
fallbackClassName,
@@ -47,7 +50,9 @@ export function FileImagePreview({
)}
/>
{variant === 'default' && (
<span className='text-[10px] leading-none'>加载失败</span>
<span className='text-[10px] leading-none'>
{rt('load_failed_d1d04482')}
</span>
)}
</div>
);
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -78,11 +81,15 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = '暂无数据',
loadingDescription = '加载中',
emptyDescription = undefined,
loadingDescription = undefined,
getId,
headerExtra,
}: ManagePageProps<T>) {
const rt = useTranslations('runtime');
emptyDescription ??= rt('no_data_497c8569');
loadingDescription ??= rt('loading_d04fcbda');
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
@@ -125,10 +132,11 @@ export function ManagePage<T>({
try {
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
toast.success(rt('saved_e05ddf99'));
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(rt('save_failed_6309a3bb'), {
description:
error instanceof Error ? error.message : rt('unknown_error_13a46616'),
});
} finally {
setSaving(false);
@@ -144,7 +152,7 @@ export function ManagePage<T>({
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem);
toast.success('删除成功');
toast.success(rt('deleted_5223f91b'));
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
@@ -152,8 +160,9 @@ export function ManagePage<T>({
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(rt('delete_failed_c228558c'), {
description:
error instanceof Error ? error.message : rt('unknown_error_13a46616'),
});
}
};
@@ -216,7 +225,9 @@ export function ManagePage<T>({
<div className='space-y-6'>
<div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
<div className='font-semibold'>
{rt('configuration_list_53052147')}
</div>
</div>
{renderContent()}
</div>
@@ -239,18 +250,18 @@ export function ManagePage<T>({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogTitle>{rt('confirm_delete_2d61cf17')}</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
{rt('this_cannot_be_undone_this_will_permanently_8c896756')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{rt('cancel_2cd0f3be')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className='bg-red-600 hover:bg-red-700'
>
确认删除
{rt('confirm_delete_a3ea3c17')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -271,13 +282,17 @@ interface ManageDetailPanelProps {
/** 详情面板 */
export function ManageDetailPanel({
title = '配置信息',
title = undefined,
isEmpty,
emptyDescription = '请选择配置查看详情',
emptyDescription = undefined,
onSave,
saving = false,
children,
}: ManageDetailPanelProps) {
const rt = useTranslations('runtime');
title ??= rt('configuration_info_7f69a860');
emptyDescription ??= rt('select_a_configuration_to_view_details_ec4949ab');
if (isEmpty) {
return (
<div className='space-y-4'>
@@ -301,10 +316,10 @@ export function ManageDetailPanel({
>
{saving ? (
<>
<Spinner /> 更新中
<Spinner /> {rt('updating_e1d9dd03')}
</>
) : (
'更新'
rt('update_3055a035')
)}
</Button>
)}
@@ -341,6 +356,8 @@ export function ManageTable<T>({
onDelete?: (item: T) => void;
getId: (item: T) => string | number;
}) {
const rt = useTranslations('runtime');
return (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<ScrollArea className='w-full'>
@@ -358,7 +375,7 @@ export function ManageTable<T>({
))}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
{rt('actions_ed31fbb4')}
</TableHead>
)}
</TableRow>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { useState } from 'react';
import {
@@ -36,9 +37,13 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
title = undefined,
description = undefined,
}: PasswordDialogProps) {
const rt = useTranslations('runtime');
title ??= rt('enter_password_728a7b60');
description ??= rt('for_your_account_security_please_enter_the_28fc0bbd');
const [password, setPassword] = useState('');
const handleConfirm = () => {
@@ -92,7 +97,7 @@ export function PasswordDialog({
disabled={loading}
className='h-8 text-xs'
>
取消
{rt('cancel_2cd0f3be')}
</Button>
<Button
type='button'
@@ -103,10 +108,10 @@ export function PasswordDialog({
{loading ? (
<>
<Spinner className='mr-2 h-4 w-4' />
验证中...
{rt('verifying_6c128e0a')}
</>
) : (
'确认'
rt('confirm_36f33ada')
)}
</Button>
</div>
+38 -23
View File
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useUser } from '@/contexts/user-context';
@@ -18,13 +19,17 @@ import {
import Link from 'next/link';
export function HomeMain() {
const rt = useTranslations('runtime');
const { user } = useUser();
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
const quickLinks = [
{
title: '个人资料',
description: '管理您的个人账户信息及个性化配置',
title: rt('profile_b94d13a4'),
description: rt(
'manage_your_account_information_and_preferences_cced83b4',
),
icon: User,
url: '/settings/profile',
color: 'text-blue-500',
@@ -32,8 +37,8 @@ export function HomeMain() {
borderColor: 'hover:border-blue-500/30',
},
{
title: '使用文档',
description: '学习如何集成 API 及日常操作帮助指南',
title: rt('user_guide_c83b6b0a'),
description: rt('api_integration_and_everyday_operation_guides_281269a1'),
icon: HelpCircle,
url: 'https://openflare.fyrn.link/',
color: 'text-purple-500',
@@ -45,8 +50,10 @@ export function HomeMain() {
const adminLinks = [
{
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
title: rt('system_settings_68ea5dd4'),
description: rt(
'manage_registration_login_and_authentication_provider_security_f378ed37',
),
icon: Shield,
url: '/admin/settings',
color: 'text-amber-500',
@@ -54,8 +61,8 @@ export function HomeMain() {
borderColor: 'hover:border-amber-500/30',
},
{
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
title: rt('global_system_configuration_1eb8931e'),
description: rt('manage_core_platform_runtime_settings_341e4182'),
icon: ShieldCheck,
url: '/admin/system',
color: 'text-indigo-500',
@@ -63,8 +70,8 @@ export function HomeMain() {
borderColor: 'hover:border-indigo-500/30',
},
{
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
title: rt('user_permissions_8e8b1d33'),
description: rt('view_and_manage_registered_users_and_their_db533b7c'),
icon: User,
url: '/admin/users',
color: 'text-rose-500',
@@ -72,8 +79,10 @@ export function HomeMain() {
borderColor: 'hover:border-rose-500/30',
},
{
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
title: rt('background_task_scheduling_408172d1'),
description: rt(
'dispatch_and_monitor_background_and_scheduled_tasks_83967fc9',
),
icon: Layers,
url: '/admin/tasks',
color: 'text-teal-500',
@@ -81,8 +90,8 @@ export function HomeMain() {
borderColor: 'hover:border-teal-500/30',
},
{
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
title: rt('system_logs_366ada1d'),
description: rt('view_background_task_logs_and_system_status_24fd6a7d'),
icon: Terminal,
url: '/admin/logs',
color: 'text-cyan-500',
@@ -109,8 +118,9 @@ export function HomeMain() {
Modern Platform
</h3>
<p className='text-xs text-muted-foreground leading-relaxed'>
通用的、现代化的后台管理系统脚手架,
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
{rt(
'a_modern_extensible_administration_platform_with_ready_0d95c0c1',
)}
</p>
<div className='flex gap-2.5'>
<Button
@@ -118,7 +128,7 @@ export function HomeMain() {
className='bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7'
asChild
>
<Link href='/'>开始使用</Link>
<Link href='/'>{rt('get_started_715ee9dc')}</Link>
</Button>
<Button
size='sm'
@@ -126,7 +136,7 @@ export function HomeMain() {
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
onClick={() => setIsBannerVisible(false)}
>
隐藏提示
{rt('hide_tip_738f4b27')}
</Button>
</div>
</div>
@@ -137,7 +147,9 @@ export function HomeMain() {
{/* 快捷导航 */}
<div className='space-y-4'>
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{rt('quick_navigation_05deefcb')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{quickLinks.map((link, idx) => (
<motion.div
@@ -179,7 +191,8 @@ export function HomeMain() {
target='_blank'
rel='noopener noreferrer'
>
立即跳转 <ArrowRight className='size-3 ml-1' />
{rt('open_fee3b5a5')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
) : (
@@ -189,7 +202,8 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{rt('open_df42ebc3')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -205,7 +219,7 @@ export function HomeMain() {
<div className='space-y-4 pt-2'>
<h2 className='text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5'>
<Shield className='size-5' />
后台管理控制台
{rt('administration_console_ceb0c289')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{adminLinks.map((link, idx) => (
@@ -240,7 +254,8 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{rt('open_df42ebc3')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
@@ -39,8 +40,11 @@ export function HtmlEditorWorkspace({
</>
),
showPreviewLink = true,
previewTitle = '源站错误页实时预览',
previewTitle = undefined,
}: HtmlEditorWorkspaceProps) {
const rt = useTranslations('runtime');
previewTitle ??= rt('live_origin_error_page_preview_a2a12823');
const containerRef = useRef<HTMLDivElement>(null);
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
const { resolvedTheme } = useTheme();
@@ -79,10 +83,14 @@ export function HtmlEditorWorkspace({
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2 min-w-0'>
<Terminal className='size-4 text-primary shrink-0' />
<span className='text-xs font-semibold'>HTML 编辑器</span>
<span className='text-xs font-semibold'>
{rt('html_editor_19f0d7b7')}
</span>
<span className='text-[11px] text-muted-foreground font-mono truncate'>
{htmlBytes} / {maxBytes} 字节
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
{htmlBytes} / {maxBytes} {rt('bytes_f5d87a8b')}
{value.trim() === ''
? rt('leave_empty_for_the_built_in_default_29ffce9f')
: ''}
</span>
</div>
<div className='flex items-center gap-1.5 flex-wrap'>
@@ -109,21 +117,21 @@ export function HtmlEditorWorkspace({
allowMultipleSelections: false,
indentOnInput: true,
}}
placeholder='留空使用内置默认模板…'
placeholder={rt('leave_empty_to_use_the_built_in_e57252bd')}
/>
</div>
<div
onMouseDown={handleMouseDown}
className='w-1.5 bg-border/60 hover:bg-primary/50 cursor-col-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整宽度'
title={rt('drag_to_resize_b5154511')}
>
<div className='h-8 w-1 rounded bg-muted-foreground/30' />
</div>
<div className='flex-1 min-w-[200px] flex flex-col overflow-hidden bg-muted/10'>
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
<span className='font-semibold'>实时预览</span>
<span className='font-semibold'>{rt('live_preview_a76fdaad')}</span>
<div className='flex items-center gap-2'>
<span
className={footerHint === null ? 'hidden' : 'hidden sm:inline'}
@@ -139,7 +147,7 @@ export function HtmlEditorWorkspace({
>
<Link href='/error-pages/preview'>
<Expand className='size-3' />
预览
{rt('preview_13d61fea')}
</Link>
</Button>
) : null}
@@ -9,9 +9,19 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { localeLabels, locales, type AppLocale } from '@/i18n/config';
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'],
};
type LanguageSwitcherProps = {
variant?: 'icon' | 'full';
@@ -38,7 +48,7 @@ export function LanguageSwitcher({
if (item !== locale) switchLocale(item);
}}
>
{localeLabels[item]}
{nativeNames[item]}
</Button>
))}
</div>
@@ -55,7 +65,7 @@ export function LanguageSwitcher({
'size-9 text-muted-foreground hover:text-foreground',
className,
)}
aria-label={localeLabels[locale]}
aria-label={nativeNames[locale]}
>
<Languages className='size-[18px]' />
</Button>
@@ -69,7 +79,7 @@ export function LanguageSwitcher({
}}
className={item === locale ? 'bg-accent' : undefined}
>
{localeLabels[item]}
{nativeNames[item]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -40,6 +40,8 @@ export function AuthSourceModal({
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const rt = useTranslations('runtime');
const t = useTranslations('settings.authSourceModal');
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -108,7 +110,7 @@ export function AuthSourceModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='例如: github'
placeholder={rt('for_example_github_1061e2e4')}
/>
</div>
<div className='space-y-2'>
@@ -118,11 +120,11 @@ export function AuthSourceModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, display_name: e.target.value }))
}
placeholder='例如: GitHub 登录'
placeholder={rt('for_example_github_login_1e0c113c')}
/>
</div>
<div className='space-y-2'>
<Label>Client ID</Label>
<Label>{rt('ui_clientId')}</Label>
<Input
value={form.client_id}
onChange={(e) =>
@@ -131,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) =>
@@ -154,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) =>
@@ -169,7 +171,7 @@ export function AuthSourceModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, icon_url: e.target.value }))
}
placeholder='https://... 或留空'
placeholder={rt('https_or_leave_empty_a0d86d9b')}
/>
</div>
<div className='flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10'>
@@ -60,6 +60,8 @@ export function OperationTab({
configs,
systemConfigsQuery,
}: OperationTabProps) {
const rt = useTranslations('runtime');
const queryClient = useQueryClient();
const t = useTranslations('settings.operation');
@@ -108,7 +110,9 @@ export function OperationTab({
}
const config = businessConfigs[field.key];
if (!config) {
throw new Error(`缺少配置项: ${field.key}`);
throw new Error(
rt('missing_configuration_value0_31b43fa6', { value0: field.key }),
);
}
await services.adminSystemConfig.updateSystemConfig(field.key, {
value: String(num),
@@ -131,7 +135,9 @@ export function OperationTab({
mutationFn: async (newValue: string) => {
const config = configs['file_access_whitelist'];
if (!config) {
throw new Error('缺少配置项: file_access_whitelist');
throw new Error(
rt('missing_configuration_file_access_whitelist_6dd80936'),
);
}
await services.adminSystemConfig.updateSystemConfig(
'file_access_whitelist',
@@ -207,6 +207,8 @@ interface OtherTabProps {
}
export function OtherTab({ configs }: OtherTabProps) {
const rt = useTranslations('runtime');
const queryClient = useQueryClient();
const t = useTranslations('settings.other');
const tNav = useTranslations('layout.nav');
@@ -237,7 +239,9 @@ export function OtherTab({ configs }: OtherTabProps) {
value: JSON.stringify(newConfig),
description:
currentCfg?.description ||
'目录显示配置(JSON 字符串,格式为 {url: enabled})',
rt.raw(
'directory_visibility_configuration_json_string_format_url_8e3c0671',
),
},
);
},
@@ -336,7 +340,7 @@ export function OtherTab({ configs }: OtherTabProps) {
<div className='text-xs text-muted-foreground leading-relaxed'>
<span className='font-semibold text-foreground'>
{t('securityTipTitle')}
</span>
</span>{' '}
{t('securityTipDesc')}
</div>
</div>
@@ -101,6 +101,8 @@ interface SecurityTabProps {
}
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const rt = useTranslations('runtime');
const queryClient = useQueryClient();
const t = useTranslations('settings.security');
const tCommon = useTranslations('common');
@@ -154,7 +156,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
mutationFn: async (value: string) => {
const config = configs['login_session_ttl_hours'];
if (!config) {
throw new Error('缺少配置项: login_session_ttl_hours');
throw new Error(
rt('missing_configuration_login_session_ttl_hours_d6f433c2'),
);
}
await services.adminSystemConfig.updateSystemConfig(
'login_session_ttl_hours',
@@ -203,7 +207,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`);
throw new Error(
rt('missing_configuration_value0_31b43fa6', { value0: key }),
);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? 'true' : 'false',
@@ -39,6 +39,8 @@ interface SystemTabProps {
}
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const rt = useTranslations('runtime');
const queryClient = useQueryClient();
const t = useTranslations('settings.system');
const [serverAddress, setServerAddress] = useState('');
@@ -75,7 +77,9 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`);
throw new Error(
rt('missing_configuration_value0_31b43fa6', { value0: key }),
);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? 'true' : 'false',
@@ -101,11 +105,13 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
await Promise.all([
services.adminSystemConfig.updateSystemConfig('server_address', {
value: serverAddress,
description: currentAddrCfg?.description || '服务器地址',
description:
currentAddrCfg?.description || rt('server_address_d3716cc5'),
}),
services.adminSystemConfig.updateSystemConfig('site_name', {
value: siteName,
description: currentSiteCfg?.description || '系统平台的展示名称',
description:
currentSiteCfg?.description || rt('platform_display_name_4b656edf'),
}),
]);
},
@@ -249,7 +255,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='text'
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder='例如: OpenFlare'
placeholder={rt('for_example_openflare_28e07341')}
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
@@ -281,7 +287,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='url'
value={serverAddress}
onChange={(e) => setServerAddress(e.target.value)}
placeholder='例如: https://example.com'
placeholder={rt('for_example_https_example_com_9cf31711')}
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
@@ -313,7 +319,9 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
<Switch
aria-label='允许搜索引擎抓取与收录'
aria-label={rt(
'allow_search_engines_to_crawl_and_index_59eff8b5',
)}
checked={indexingEnabled}
disabled={
updateConfigMutation.isPending || systemConfigsQuery.isPending
@@ -383,7 +391,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='text'
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder='例如: smtp.example.com'
placeholder={rt('for_example_smtp_example_com_834569b4')}
className='bg-card border-dashed text-xs'
/>
</div>
@@ -397,7 +405,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='number'
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder='例如: 587 或 465'
placeholder={rt('for_example_587_or_465_1bd015a5')}
className='bg-card border-dashed text-xs'
/>
</div>
@@ -414,7 +422,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
type='text'
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
placeholder='例如: sender@example.com'
placeholder={rt('for_example_sender_example_com_1795f7c6')}
className='bg-card border-dashed text-xs'
/>
</div>
@@ -498,7 +506,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
required
value={smtpTestTo}
onChange={(e) => setSmtpTestTo(e.target.value)}
placeholder='例如: receiver@example.com'
placeholder={rt('for_example_receiver_example_com_ade0ff8b')}
className='bg-card border-dashed text-xs'
disabled={testSmtpMutation.isPending}
/>
@@ -36,9 +36,13 @@ import {
import services from '@/lib/services';
import type { Template } from '@/lib/services/admin/types';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { localizeTemplateMetadata } from '@/i18n/builtin-metadata';
export function TemplatesManager() {
const rt = useTranslations('runtime');
const locale = useLocale();
const queryClient = useQueryClient();
const t = useTranslations('settings.templates');
const [modalOpen, setModalOpen] = useState(false);
@@ -191,7 +195,13 @@ export function TemplatesManager() {
<div className='space-y-1.5'>
<div className='flex items-center gap-2 flex-wrap'>
<span className='font-semibold text-sm text-foreground'>
{tmpl.name}
{localizeTemplateMetadata(
tmpl.key,
tmpl.is_system,
'name',
tmpl.name,
locale,
)}
</span>
<span
className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
@@ -215,7 +225,13 @@ export function TemplatesManager() {
</div>
{tmpl.description && (
<p className='text-xs text-muted-foreground/80 leading-relaxed max-w-xl'>
{tmpl.description}
{localizeTemplateMetadata(
tmpl.key,
tmpl.is_system,
'description',
tmpl.description,
locale,
)}
</p>
)}
</div>
@@ -226,6 +242,7 @@ export function TemplatesManager() {
size='icon'
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
onClick={() => handleOpenEdit(tmpl)}
aria-label={t('editTemplate')}
>
<Pencil className='size-4' />
</Button>
@@ -238,6 +255,7 @@ export function TemplatesManager() {
tmpl.is_system || deleteTemplateMutation.isPending
}
onClick={() => setDeleteTarget(tmpl)}
aria-label={t('deleteTemplateTitle')}
>
<Trash2 className='size-4' />
</Button>
@@ -286,7 +304,7 @@ export function TemplatesManager() {
required
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder='例如: login_email'
placeholder={rt('for_example_login_email_c7212e1f')}
className='bg-card border-dashed text-xs'
disabled={!!selectedTemplate}
/>
@@ -303,9 +321,17 @@ export function TemplatesManager() {
id='tmpl_name'
type='text'
required
value={name}
value={localizeTemplateMetadata(
key,
!!selectedTemplate?.is_system,
'name',
name,
locale,
)}
onChange={(e) => setName(e.target.value)}
placeholder='例如: 登录验证码邮件'
placeholder={rt(
'for_example_login_verification_email_9d28f166',
)}
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
@@ -340,7 +366,9 @@ export function TemplatesManager() {
type='text'
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder='例如: OpenFlare 登录验证码'
placeholder={rt(
'for_example_openflare_login_verification_code_0925e8d4',
)}
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
@@ -359,9 +387,17 @@ export function TemplatesManager() {
<Input
id='tmpl_description'
type='text'
value={description}
value={localizeTemplateMetadata(
key,
!!selectedTemplate?.is_system,
'description',
description,
locale,
)}
onChange={(e) => setDescription(e.target.value)}
placeholder='例如: 包含变量:{{.Code}},5分钟内有效'
placeholder={rt.raw(
'for_example_your_code_is_code_valid_44cf273f',
)}
className='bg-card border-dashed text-xs'
/>
</div>
@@ -375,7 +411,9 @@ export function TemplatesManager() {
required
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>'
placeholder={rt.raw(
'h3_message_title_h3_p_message_content_20c91d04',
)}
rows={8}
className='bg-card border-dashed text-xs font-mono'
/>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -60,7 +61,7 @@ function getFileIcon(mimeType: string, className = 'size-10') {
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN', {
return new Date(dateStr).toLocaleString(getFormattingLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
+5 -1
View File
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import {
Card,
CardContent,
@@ -28,6 +31,7 @@ export function RankCard({
color = '#3b82f6',
valueFormatter,
}: RankCardProps) {
const t = useTranslations('dataComponents');
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
@@ -43,7 +47,7 @@ export function RankCard({
items={items}
color={color}
valueFormatter={valueFormatter}
emptyMessage={`暂无 ${title} 数据`}
emptyMessage={t('noDataForTitle', { title })}
/>
</CardContent>
</Card>
+6 -3
View File
@@ -1,6 +1,8 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
@@ -24,16 +26,17 @@ const defaultFormatter = (value: number) => {
if (value >= 1000) {
return `${Number((value / 1000).toFixed(2))}k`;
}
return value.toLocaleString('zh-CN');
return value.toLocaleString(getFormattingLocale());
};
export function RankChart({
items,
color,
valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据',
emptyMessage,
className,
}: RankChartProps) {
const t = useTranslations('dataComponents');
const maxValue = useMemo(
() => Math.max(0, ...items.map((item) => item.value)),
[items],
@@ -47,7 +50,7 @@ export function RankChart({
className,
)}
>
{emptyMessage}
{emptyMessage ?? t('noDistribution')}
</div>
);
}
+4 -1
View File
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
@@ -59,6 +61,7 @@ export function TrendChart({
summaryScope = 'last-point',
summaryHint,
}: TrendChartProps) {
const t = useTranslations('dataComponents');
const option = useMemo<EChartsOption>(() => {
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
const maxValue = calculateNiceAxisMax(
@@ -184,7 +187,7 @@ export function TrendChart({
if (labels.length === 0 || series.length === 0) {
return (
<div className='flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground'>
暂无趋势数据
{t('noTrend')}
</div>
);
}
+19 -19
View File
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
@@ -15,16 +18,11 @@ export interface DeveloperSectionProps {
export const DeveloperSection = React.memo(function DeveloperSection({
className,
}: DeveloperSectionProps) {
const rt = useTranslations('runtime');
const [copied, setCopied] = React.useState(false);
const codeContent = `# 注册用户
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
"username": "developer",
"email": "dev@example.com",
"password": "secure_password"
}'`;
const codeContent = rt.raw('register_a_user_curl_x_post_https_4ae682b0');
const onCopy = async () => {
try {
@@ -97,7 +95,9 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
</div>
<pre className='text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto'>
<code className='block'>
<span className='text-green-400'># 快速开始</span>
<span className='text-green-400'>
{rt('quick_start_42fbb067')}
</span>
{'\n'}
<span className='text-purple-400'>curl</span> -X{' '}
<span className='text-yellow-400'>POST</span>{' '}
@@ -130,22 +130,22 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
transition={{ duration: 0.8 }}
>
<h2 className='text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6'>
开发者友好,
{rt('developer_friendly_40c2a4ce')}
<br />
极速集成上手
{rt('quick_to_integrate_18cbee8a')}
</h2>
<p className='text-muted-foreground text-lg leading-relaxed mb-8'>
标准化的 RESTful API 接口,完整的 TypeScript
类型定义,详细的文档和 Swagger
UI。无论使用什么编程语言,只需几行代码,即可快速集成。
{rt(
'standard_restful_apis_complete_typescript_types_detailed_6369a0e2',
)}
</p>
<ul className='space-y-4 mb-8'>
{[
'RESTful API,语义清晰,易于理解',
'OpenAPI / Swagger 接口文档',
'TypeScript 类型定义,开发友好',
'丰富的代码示例和集成指南',
rt('restful_api_with_clear_understandable_semantics_1fa9d6f5'),
rt('openapi_swagger_documentation_ccbcbc84'),
rt('developer_friendly_typescript_type_definitions_910a7909'),
rt('code_samples_and_integration_guides_9e998a5a'),
].map((item, i) => (
<li
key={i}
@@ -170,7 +170,7 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
className='rounded-full text-xs hover:bg-muted-foreground/10'
>
<Book className='w-3 h-3' />
使用文档
{rt('user_guide_c83b6b0a')}
</Button>
</Link>
</div>
+25 -13
View File
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { cn } from '@/lib/utils';
import Link from 'next/link';
@@ -29,6 +32,8 @@ export interface FooterSectionProps {
export const FooterSection = React.memo(function FooterSection({
className,
}: FooterSectionProps) {
const rt = useTranslations('runtime');
return (
<footer
className={cn(
@@ -48,8 +53,7 @@ export const FooterSection = React.memo(function FooterSection({
</span>
</Link>
<p className='text-muted-foreground text-base leading-relaxed max-w-sm'>
开源边缘节点与反向代理管理平台。基于 Go + React 构建,统一管理
OpenResty 节点、网站规则与 WAF 配置。
{rt('an_open_source_platform_for_managing_edge_317d8f34')}
</p>
<div className='flex gap-4 pt-2'>
<SocialLink
@@ -60,35 +64,43 @@ export const FooterSection = React.memo(function FooterSection({
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>产品</h3>
<h3 className='font-semibold text-foreground mb-6'>
{rt('product_aea82737')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/'>仪表盘</FooterLink>
<FooterLink href='/'>{rt('dashboard_0853bf3c')}</FooterLink>
</li>
<li>
<FooterLink href='/settings'>个人设置</FooterLink>
<FooterLink href='/settings'>
{rt('personal_c2952a5f')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>开发</h3>
<h3 className='font-semibold text-foreground mb-6'>
{rt('development_9ec42862')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='https://openflare.fyrn.link/'>
使用文档
{rt('user_guide_c83b6b0a')}
</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/OpenFlare'>
源代码
{rt('source_code_606a4e58')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>社区</h3>
<h3 className='font-semibold text-foreground mb-6'>
{rt('community_5bcd0ddc')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='https://github.com/Rain-kl/OpenFlare/issues'>
@@ -97,7 +109,7 @@ export const FooterSection = React.memo(function FooterSection({
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/OpenFlare/discussions'>
讨论
{rt('discussions_3cef912c')}
</FooterLink>
</li>
</ul>
@@ -105,19 +117,19 @@ 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'
className='hover:text-foreground transition-colors'
>
隐私政策
{rt('privacy_policy_e464b41f')}
</Link>
<Link
href='/docs/terms-of-service'
className='hover:text-foreground transition-colors'
>
服务条款
{rt('terms_of_service_9303687a')}
</Link>
</div>
</div>
+22 -13
View File
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import Link from 'next/link';
import { motion } from 'motion/react';
@@ -15,6 +18,8 @@ export interface HeroSectionProps {
export const HeroSection = React.memo(function HeroSection({
className,
}: HeroSectionProps) {
const rt = useTranslations('runtime');
return (
<section className={cn('w-full', className)}>
<motion.div
@@ -42,7 +47,9 @@ export const HeroSection = React.memo(function HeroSection({
className='text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-[1.1] mb-6 text-foreground'
>
Modern Platform <br />
<span className='bg-clip-text text-primary'>为二次开发而生</span>
<span className='bg-clip-text text-primary'>
{rt('built_for_customization_ff3cf147')}
</span>
</motion.h1>
<motion.p
@@ -56,9 +63,9 @@ export const HeroSection = React.memo(function HeroSection({
}}
className='text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10'
>
通用的、现代化的后台管理系统脚手架
{rt('a_modern_administration_platform_116688ff')}
<br className='hidden md:block' />
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
{rt('ready_to_use_infrastructure_that_is_easy_77776634')}
</motion.p>
<motion.div
@@ -77,7 +84,7 @@ export const HeroSection = React.memo(function HeroSection({
size='lg'
className='w-full rounded-full bg-primary hover:bg-primary/90 font-medium transition-all active:scale-95'
>
立即开始
{rt('get_started_d1f9c450')}
<ArrowRight className='size-4' />
</Button>
</Link>
@@ -93,7 +100,7 @@ export const HeroSection = React.memo(function HeroSection({
size='lg'
className='w-full rounded-full font-medium active:scale-95'
>
了解更多
{rt('learn_more_ca66c2da')}
</Button>
</Link>
</motion.div>
@@ -107,15 +114,15 @@ export const HeroSection = React.memo(function HeroSection({
>
<div className='flex items-center gap-2'>
<Zap className='w-5 h-5 text-yellow-500' />
<span>开箱即用</span>
<span>{rt('ready_to_use_c58cfea2')}</span>
</div>
<div className='flex items-center gap-2'>
<Globe className='w-5 h-5 text-blue-500' />
<span>高度可扩展</span>
<span>{rt('highly_extensible_9dfa6c10')}</span>
</div>
<div className='flex items-center gap-2'>
<Shield className='w-5 h-5 text-green-500' />
<span>工业级基建</span>
<span>{rt('production_infrastructure_08c3a5e6')}</span>
</div>
</motion.div>
</div>
@@ -140,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'>
@@ -154,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' />
@@ -179,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>
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
/**
* 元旦/新年主题装饰组件归档
* 包含灯笼、福字以及烟花效果的集成组件
@@ -95,7 +96,7 @@ export const Lantern = ({ className }: { className?: string }) => (
fontWeight='bold'
fontFamily='serif'
>
福
{runtimeText('good_fortune_b3541887')}
</text>
<path d='M512 660V720' stroke='#FFD700' strokeWidth='20' />
@@ -186,7 +187,7 @@ export const FuCharacter = ({
style={{ letterSpacing: '0px' }}
transform='rotate(130 50 50)'
>
福
{runtimeText('good_fortune_b3541887')}
</text>
</g>
+5 -1
View File
@@ -1,3 +1,4 @@
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { FileTextIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -128,10 +129,13 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = '暂无数据',
message = undefined,
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
const rt = useTranslations('runtime');
message ??= rt('no_data_497c8569');
return (
<div
className={cn(
+17 -6
View File
@@ -1,3 +1,4 @@
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -48,16 +49,23 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = '加载失败',
title = undefined,
message,
error,
onRetry,
retryText = '重试',
retryText = undefined,
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
const rt = useTranslations('runtime');
title ??= rt('load_failed_d1d04482');
retryText ??= rt('retry_b8784c8d');
const errorMessage =
message ||
error?.message ||
rt('an_unknown_error_occurred_please_try_again_79a9f98a');
return (
<div
@@ -85,7 +93,7 @@ export function ErrorDisplay({
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className='mt-6 text-left max-w-2xl w-full'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
查看详细错误信息
{rt('view_error_details_9e7e4e37')}
</summary>
<pre className='mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto'>
{error.stack}
@@ -115,7 +123,10 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || '发生未知错误';
const rt = useTranslations('runtime');
const errorMessage =
message || error?.message || rt('unknown_error_e83a8ca8');
return (
<div
@@ -133,7 +144,7 @@ export function ErrorInline({
size='sm'
className='h-6 px-4 text-xs'
>
重试
{rt('retry_b8784c8d')}
</Button>
)}
</div>

Some files were not shown because too many files have changed in this diff Show More