mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
fix(i18n): use English runtime text and localize system UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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)..."
|
||||
|
||||
@@ -19,6 +19,13 @@ sidebar: false
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
- 将界面默认语言调整为英文,保留已有语言偏好和中文语言资源。
|
||||
- 静态页面以英文预渲染,并在水合界面绘制前应用已保存的语言偏好,避免英文界面刷新时先显示中文再切换。
|
||||
- 将 Server、Agent、任务执行详情、安装脚本及浏览器调试日志的固定描述统一为英文;任务和通知元数据、接口错误、页面标题、表单校验与内置页面模板按中英文语言包展示,历史任务记录在读取时兼容翻译,不回写用户数据或上传统计键。
|
||||
- 增加语言包一致性与运行时中文文案检查,防止页面和终端重新混入未登记的中文固定文案。
|
||||
- 语言选择器固定显示各语言的原生名称,并提供全仓库非 ASCII 文本清单命令,按模块和用途区分语言资源、源码、文档与兼容标识。
|
||||
- 根据实际界面审校英文通知、任务与模板提示,修正文档拼接缺少空格、重复用词和 Webhook JSON 示例转义;系统配置及内置模板的默认元数据按语言展示,保留自定义内容与原始编辑值,并改善窄屏 WAF 标题和通知模板说明的换行。
|
||||
|
||||
- SQLite 文件数据库启动时自动启用 WAL 模式,改善读写并发;数据库导出改用一致性快照,确保包含 WAL 中已提交的数据。
|
||||
|
||||
- 修复 IP 组按规则自动抓取时会收录内网、回环及链路本地等地址的问题;规则测试同步过滤这些地址,历史抓取结果会在下次同步时清理。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 运行时语言与国际化
|
||||
|
||||
## 运维输出与界面文案
|
||||
|
||||
Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。
|
||||
|
||||
前端继续使用 `next-intl`,以 `en` 为默认语言,保留 `zh-CN` 语言资源。当前阶段先整理英文基准文案并移除运行时中文硬编码,中文译文的进一步审校和统一验证安排在后续阶段。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。
|
||||
|
||||
语言选择器始终从各语言自己的资源读取名称:`English` 与 `中文`,不随当前界面语言翻译语言名称。
|
||||
|
||||
不在 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:` 控制前缀保持不变。
|
||||
- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。
|
||||
- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。
|
||||
- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。
|
||||
|
||||
格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。
|
||||
|
||||
## 持久化兼容性
|
||||
|
||||
上传统计原有的六个中文分类名同时是数据库计数器键和 API 分类标识,不能当作标签直接改名。保留这些既有键,前端图表、图例和最大分类说明单独使用语言包标签;无需清空或重建现有统计。
|
||||
|
||||
历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。
|
||||
|
||||
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知中英文默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。
|
||||
|
||||
## 校验与扩展
|
||||
|
||||
在仓库根目录运行 `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。
|
||||
|
||||
新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
|
||||
@@ -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 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>{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>`}</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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,7 +95,7 @@ function statusVariant(status: number) {
|
||||
|
||||
function isClickhouseDisabledError(error: Error) {
|
||||
const errMsg = error.message || '';
|
||||
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
|
||||
return isLogStorageDisabledMessage(errMsg);
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getRuntimeTranslator } from '@/i18n/runtime';
|
||||
|
||||
export function isLogStorageDisabledMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
message.includes('ClickHouse') ||
|
||||
(['en', 'zh-CN'] as const).some((locale) =>
|
||||
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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getFormattingLocale } from '@/i18n/format';
|
||||
import type {
|
||||
ApplyResult,
|
||||
NodeItem,
|
||||
@@ -255,7 +256,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'),
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 +110,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 +133,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,
|
||||
),
|
||||
(['en', 'zh-CN'] as const).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,16 +8,17 @@ export interface GeoOption {
|
||||
|
||||
const countryCodePattern = /^[A-Z]{2}$/;
|
||||
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
|
||||
const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
|
||||
export function createGeoOptions(locale: string) {
|
||||
const countryDisplayNames = new Intl.DisplayNames([locale], {
|
||||
type: 'region',
|
||||
});
|
||||
});
|
||||
|
||||
function localizedCountryName(code: string, fallback: string) {
|
||||
function localizedCountryName(code: string, fallback: string) {
|
||||
const localized = countryDisplayNames.of(code);
|
||||
return !localized || localized === code ? fallback : localized;
|
||||
}
|
||||
}
|
||||
|
||||
export const countryOptions: GeoOption[] = allCountries
|
||||
const countryOptions: GeoOption[] = allCountries
|
||||
.filter(([, code]) => countryCodePattern.test(code))
|
||||
.map(([englishName, code]) => {
|
||||
const label = localizedCountryName(code, englishName);
|
||||
@@ -27,9 +28,9 @@ export const countryOptions: GeoOption[] = allCountries
|
||||
searchText: `${label} ${englishName} ${code}`,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label, 'zh-CN'));
|
||||
.sort((left, right) => left.label.localeCompare(right.label, locale));
|
||||
|
||||
export const regionOptions: GeoOption[] = allCountries.flatMap(
|
||||
const regionOptions: GeoOption[] = allCountries.flatMap(
|
||||
([englishCountryName, countryCode, regions]) => {
|
||||
const countryName = localizedCountryName(countryCode, englishCountryName);
|
||||
return regions.flatMap(([regionName, shortCode]) => {
|
||||
@@ -50,4 +51,7 @@ export const regionOptions: GeoOption[] = allCountries.flatMap(
|
||||
];
|
||||
});
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
return { countryOptions, regionOptions };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
displayNodeTitle(
|
||||
{
|
||||
type: 'ip_match',
|
||||
label: ' 办公室 ',
|
||||
}),
|
||||
},
|
||||
getRuntimeTranslator('en'),
|
||||
),
|
||||
).toBe('办公室');
|
||||
});
|
||||
|
||||
it('falls back to type default when label empty', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
displayNodeTitle(
|
||||
{
|
||||
type: 'block',
|
||||
label: ' ',
|
||||
}),
|
||||
).toBe(NODE_TYPE_LABELS.block);
|
||||
},
|
||||
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,7 +6,12 @@ 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' },
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeTranslator } from '@/i18n/runtime';
|
||||
import { type PolicySection } from './types';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
import {
|
||||
@@ -16,15 +17,18 @@ export const DOCS_LAST_UPDATED = '2026-06-07';
|
||||
* API 文档
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const apiSections: PolicySection[] = [
|
||||
export function createApiSections(rt: RuntimeTranslator): PolicySection[] {
|
||||
return [
|
||||
{
|
||||
value: 'api-specs',
|
||||
title: '1. 接口规范与鉴权说明',
|
||||
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>
|
||||
|
||||
@@ -32,15 +36,19 @@ export const apiSections: PolicySection[] = [
|
||||
id='1-1-response-format'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
|
||||
>
|
||||
1.1 统一响应格式
|
||||
{rt('1_1_standard_response_format_5cddae2f')}
|
||||
</h3>
|
||||
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
|
||||
<p>{rt('all_api_endpoints_use_a_standard_json_87c92fb0')}</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
|
||||
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
<DocsTableHead className='w-[120px]'>
|
||||
{rt('field_49ecd0e3')}
|
||||
</DocsTableHead>
|
||||
<DocsTableHead className='w-[100px]'>
|
||||
{rt('type_ba40014f')}
|
||||
</DocsTableHead>
|
||||
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
@@ -50,21 +58,22 @@ export const apiSections: PolicySection[] = [
|
||||
</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 className='font-mono text-xs'>
|
||||
data
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>any</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
|
||||
{rt('the_response_payload_null_when_the_request_97b8b29c')}
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<p className='mt-2'>成功响应示例:</p>
|
||||
<p className='mt-2'>{rt('successful_response_example_8917e853')}</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -77,12 +86,9 @@ export const apiSections: PolicySection[] = [
|
||||
language='json'
|
||||
/>
|
||||
|
||||
<p className='mt-2'>失败响应示例:</p>
|
||||
<p className='mt-2'>{rt('failed_response_example_eae899e1')}</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "用户密码错误",
|
||||
"data": null
|
||||
}`}
|
||||
code={rt.raw('error_msg_incorrect_password_data_null_b2eccc86')}
|
||||
language='json'
|
||||
/>
|
||||
|
||||
@@ -90,25 +96,26 @@ export const apiSections: PolicySection[] = [
|
||||
id='1-2-authentication'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
|
||||
>
|
||||
1.2 鉴权方式
|
||||
{rt('1_2_authentication_methods_3c5db38a')}
|
||||
</h3>
|
||||
<p>
|
||||
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
|
||||
{rt(
|
||||
'protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c',
|
||||
)}
|
||||
</p>
|
||||
<ul className='list-disc pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
|
||||
Cookie 会话保持登录。
|
||||
<strong>{rt('session_credentials_de72024d')}</strong>{' '}
|
||||
{rt('browser_clients_can_stay_signed_in_using_f316279b')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>AccessToken 令牌:</strong>
|
||||
供后台调用或第三方应用集成使用。客户端生成 API
|
||||
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
|
||||
<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>
|
||||
@@ -126,37 +133,48 @@ export const apiSections: PolicySection[] = [
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
|
||||
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
|
||||
{
|
||||
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: '2. 用户与认证接口',
|
||||
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'
|
||||
>
|
||||
2.1 用户注册
|
||||
{rt('2_1_user_registration_4f332f85')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>POST{' '}
|
||||
<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>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
|
||||
<strong>{rt('description_3e633a20')}</strong>{' '}
|
||||
{rt('register_a_local_account_when_registration_is_938891dc')}
|
||||
</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
|
||||
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
|
||||
<DocsTableHead>类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
<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>
|
||||
@@ -164,25 +182,33 @@ export const apiSections: PolicySection[] = [
|
||||
<DocsTableCell className='font-mono text-xs'>
|
||||
username
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
{rt('username_must_be_unique_and_contain_no_e81c1e5c')}
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className='font-mono text-xs'>
|
||||
password
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
{rt('password_at_least_8_characters_37cdc807')}
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className='font-mono text-xs'>
|
||||
nickname
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>{rt('no_0c70665b')}</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
|
||||
<DocsTableCell>
|
||||
{rt(
|
||||
'nickname_defaults_to_the_username_when_omitted_676aae7e',
|
||||
)}
|
||||
</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
@@ -191,25 +217,31 @@ export const apiSections: PolicySection[] = [
|
||||
id='2-2-login'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
|
||||
>
|
||||
2.2 密码登录
|
||||
{rt('2_2_password_login_271483ba')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>POST{' '}
|
||||
<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>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
|
||||
Session Cookie 会话。
|
||||
<strong>{rt('description_3e633a20')}</strong>{' '}
|
||||
{rt(
|
||||
'authenticate_with_a_username_and_password_establishing_a38ff252',
|
||||
)}
|
||||
</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
|
||||
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
|
||||
<DocsTableHead>类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
<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>
|
||||
@@ -217,17 +249,17 @@ export const apiSections: PolicySection[] = [
|
||||
<DocsTableCell className='font-mono text-xs'>
|
||||
username
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>用户名</DocsTableCell>
|
||||
<DocsTableCell>{rt('username_1a3f0617')}</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className='font-mono text-xs'>
|
||||
password
|
||||
</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>密码</DocsTableCell>
|
||||
<DocsTableCell>{rt('password_a621ab60')}</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
@@ -236,52 +268,52 @@ export const apiSections: PolicySection[] = [
|
||||
id='2-3-logout'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
|
||||
>
|
||||
2.3 退出登录
|
||||
{rt('2_3_sign_out_3252611d')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<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>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
|
||||
<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 获取个人资料
|
||||
{rt('2_4_get_profile_7b96110a')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<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>说明:</strong>获取当前登录账户的基本数据模型(包含
|
||||
ID、角色、昵称等)。
|
||||
<strong>{rt('description_3e633a20')}</strong>{' '}
|
||||
{rt('get_the_signed_in_account_s_basic_b0eb8cfa')}
|
||||
</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: '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: '3. 个人访问令牌 (AccessToken) 接口',
|
||||
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'>
|
||||
AccessToken 管理相关接口均要求通过 Session
|
||||
登录后调用,支持普通用户权限。
|
||||
{rt('access_token_management_requires_a_session_login_42d91543')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -289,46 +321,50 @@ export const apiSections: PolicySection[] = [
|
||||
id='3-1-list-token'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
3.1 获取令牌列表
|
||||
{rt('3_1_list_tokens_9840c227')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<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>说明:</strong>
|
||||
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
|
||||
<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 新建访问令牌
|
||||
{rt('3_2_create_an_access_token_e53bd784')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>POST{' '}
|
||||
<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>参数:</strong>JSON Body{' '}
|
||||
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
|
||||
<strong>{rt('parameters_ba89685c')}</strong> JSON Body{' '}
|
||||
<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>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
|
||||
Token,切勿遗失。
|
||||
<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>(可选,默认{' '}
|
||||
<code className='bg-muted px-1 rounded'>is_admin</code>
|
||||
{rt('optional_default_210b9d8f')}{' '}
|
||||
<code className='bg-muted px-1 rounded'>false</code>
|
||||
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
|
||||
<code className='bg-muted px-1 rounded'>/admin/**</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'>成功返回样例:</p>
|
||||
<p className='mt-2'>{rt('successful_response_example_7783a39c')}</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -351,67 +387,75 @@ export const apiSections: PolicySection[] = [
|
||||
id='3-3-delete-token'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
|
||||
>
|
||||
3.3 撤销/删除令牌
|
||||
{rt('3_3_revoke_delete_a_token_ae510f58')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>DELETE{' '}
|
||||
<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>说明:</strong>通过 ID
|
||||
物理删除对应访问令牌,该令牌将立即失效。
|
||||
<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 轮换令牌密钥
|
||||
{rt('3_4_rotate_a_token_secret_79209983')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>POST{' '}
|
||||
<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>说明:</strong>
|
||||
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
|
||||
Token,令牌名称与 ID 保持一致。
|
||||
<strong>{rt('description_3e633a20')}</strong>{' '}
|
||||
{rt('replace_a_token_s_secret_and_return_d2caca89')}
|
||||
</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: '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: '4. 公共配置与管理接口',
|
||||
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'
|
||||
>
|
||||
4.1 公共系统配置
|
||||
{rt('4_1_public_system_configuration_2be912e4')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>接口:</strong>GET{' '}
|
||||
<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>说明:</strong>
|
||||
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
|
||||
<strong>{rt('description_3e633a20')}</strong>{' '}
|
||||
{rt('get_publicly_visible_configuration_keys_and_values_bc7bff66')}
|
||||
</p>
|
||||
<p className='mt-2'>返回数据结构样例:</p>
|
||||
<p className='mt-2'>{rt('response_example_d3087445')}</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
@@ -431,33 +475,37 @@ export const apiSections: PolicySection[] = [
|
||||
id='4-2-admin-configs'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
|
||||
>
|
||||
4.2 系统配置项 CRUD (管理员)
|
||||
{rt('4_2_system_configuration_crud_administrator_2ce66c8b')}
|
||||
</h3>
|
||||
<p>
|
||||
<strong>说明:</strong>用于在后台对 `system_configs`
|
||||
配置进行动态变更,要求管理员权限会话调用。
|
||||
<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>获取配置列表:</strong>GET{' '}
|
||||
<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>新建配置项:</strong>POST{' '}
|
||||
<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>修改指定配置值:</strong>PUT{' '}
|
||||
<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>删除配置项:</strong>DELETE{' '}
|
||||
<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>
|
||||
@@ -466,8 +514,15 @@ export const apiSections: PolicySection[] = [
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
|
||||
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
|
||||
{
|
||||
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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeTranslator } from '@/i18n/runtime';
|
||||
import { type PolicySection } from './types';
|
||||
import { CodeBlock } from '@/components/ui/code-block';
|
||||
|
||||
@@ -6,33 +7,38 @@ import { CodeBlock } from '@/components/ui/code-block';
|
||||
* 使用指南 (How To Use)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const howToUseSections: PolicySection[] = [
|
||||
export function createHowToUseSections(rt: RuntimeTranslator): PolicySection[] {
|
||||
return [
|
||||
{
|
||||
value: 'quick-start',
|
||||
title: '1. 快速开始',
|
||||
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'>
|
||||
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
|
||||
{rt('a_guide_to_the_general_purpose_full_9cd8c987')}
|
||||
</p>
|
||||
</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) 前端
|
||||
<strong>{rt('architecture_e992b65c')}</strong>{' '}
|
||||
{rt('go_gin_gorm_redis_asynq_backend_react_d5fe02d3')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
|
||||
第三方自定义 OIDC (OAuth2) 认证源绑定
|
||||
<strong>{rt('authentication_d36b345b')}</strong>{' '}
|
||||
{rt(
|
||||
'local_username_password_registration_and_login_plus_583ab638',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
|
||||
Key),用于通过 Http Header 鉴权直接调用系统 API
|
||||
<strong>{rt('access_tokens_b7b856d3')}</strong>{' '}
|
||||
{rt('personal_developer_access_tokens_api_keys_for_4b407149')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
|
||||
全链路 Tracing 追踪
|
||||
<strong>{rt('observability_ea0cc1bf')}</strong>{' '}
|
||||
{rt(
|
||||
'zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -40,28 +46,34 @@ export const howToUseSections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'auth-security',
|
||||
title: '2. 身份认证与安全设置',
|
||||
title: rt('2_authentication_and_security_settings_76967059'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
|
||||
<p>
|
||||
{rt(
|
||||
'the_platform_combines_authentication_methods_to_support_bc9f1772',
|
||||
)}
|
||||
</p>
|
||||
<h3
|
||||
id='2-1-login'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
2.1 常规账号密码认证
|
||||
{rt('2_1_username_and_password_authentication_97184f8f')}
|
||||
</h3>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>自主注册与密码登录:</strong>
|
||||
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
|
||||
<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>系统开关控制:</strong>
|
||||
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
|
||||
<strong>{rt('system_switches_53741557')}</strong>{' '}
|
||||
{rt(
|
||||
'administrators_can_disable_password_registration_or_login_a2648e62',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -69,56 +81,61 @@ export const howToUseSections: PolicySection[] = [
|
||||
id='2-2-oidc'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
2.2 第三方 OIDC 认证源
|
||||
{rt('2_2_third_party_oidc_providers_f14083c8')}
|
||||
</h3>
|
||||
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
|
||||
<p>{rt('users_can_link_external_accounts_from_their_c08d3f5a')}</p>
|
||||
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
|
||||
<li>
|
||||
进入 <strong>设置 / 个人资料</strong> 页面。
|
||||
{rt('open_4540d74d')}
|
||||
<strong>{rt('settings_profile_32835b0d')}</strong>{' '}
|
||||
{rt('message_a173cce8')}
|
||||
</li>
|
||||
<li>
|
||||
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
|
||||
认证源直接触发 OAuth2 绑定流。
|
||||
</li>
|
||||
<li>
|
||||
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
|
||||
{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: '2.1 常规账号密码认证' },
|
||||
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
|
||||
{
|
||||
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: '3. 个人访问令牌 (AccessToken) 接口对接',
|
||||
title: rt('3_integrating_personal_access_tokens_af2647b5'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
为便于开发者或第三方工具直接调用系统
|
||||
API,平台提供个人访问令牌管理功能。
|
||||
{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'
|
||||
>
|
||||
3.1 令牌生成与存储规范
|
||||
{rt('3_1_token_generation_and_storage_2b78c681')}
|
||||
</h3>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
|
||||
(形如{' '}
|
||||
<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>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
|
||||
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
|
||||
<strong>{rt('secure_hash_storage_1ce392ff')}</strong>{' '}
|
||||
{rt('only_a_sha_256_hash_of_the_dbf434b1')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -126,15 +143,12 @@ export const howToUseSections: PolicySection[] = [
|
||||
id='3-2-usage'
|
||||
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
|
||||
>
|
||||
3.2 携带 Header 进行 API 调用
|
||||
{rt('3_2_calling_apis_with_headers_fccd7b0d')}
|
||||
</h3>
|
||||
<p>
|
||||
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
|
||||
Http 请求头携带方式之一:
|
||||
</p>
|
||||
<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'>
|
||||
方式一:Authorization Bearer 头
|
||||
{rt('method_1_authorization_bearer_header_2a9c2a6c')}
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
@@ -145,7 +159,7 @@ Authorization: Bearer at_628d022b7a95e26b...`}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='font-semibold text-xs text-muted-foreground'>
|
||||
方式二:X-Access-Token 自定义头
|
||||
{rt('method_2_custom_x_access_token_header_4548169f')}
|
||||
</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
@@ -157,49 +171,53 @@ X-Access-Token: at_628d022b7a95e26b...`}
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
|
||||
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
|
||||
{
|
||||
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: '4. 动态系统配置项',
|
||||
title: rt('4_dynamic_system_configuration_b3bf8c49'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
|
||||
</p>
|
||||
<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>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
|
||||
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
|
||||
<strong>{rt('cached_reads_35828322')}</strong>{' '}
|
||||
{rt('configuration_is_read_from_the_database_through_3e9f6a34')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>核心系统配置项说明:</strong>
|
||||
<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>
|
||||
:普通用户创建令牌的最大数限制 (默认5)
|
||||
{rt('maximum_tokens_per_regular_user_default_5_5dd6d9e7')}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -209,21 +227,24 @@ X-Access-Token: at_628d022b7a95e26b...`}
|
||||
},
|
||||
{
|
||||
value: 'worker-scheduler',
|
||||
title: '5. 异步任务与定时调度',
|
||||
title: rt('5_background_tasks_and_scheduling_7142ce96'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
|
||||
服务解耦复杂计算或高延迟IO:
|
||||
{rt('cobra_provides_separate_commands_so_worker_services_1bbae2df')}
|
||||
</p>
|
||||
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
|
||||
<li>
|
||||
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
|
||||
表达式配置,定时将待执行任务推送到 Redis 队列中。
|
||||
<strong>
|
||||
{rt('scheduled_dispatch_scheduler_command_e79062ad')}
|
||||
</strong>{' '}
|
||||
{rt('enqueues_tasks_in_redis_according_to_their_694e5985')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
|
||||
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
|
||||
<strong>
|
||||
{rt('workers_with_queue_priorities_worker_command_faa970b1')}
|
||||
</strong>{' '}
|
||||
{rt('asynq_consumes_tasks_by_priority_and_executes_69d82251')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -231,24 +252,28 @@ X-Access-Token: at_628d022b7a95e26b...`}
|
||||
},
|
||||
{
|
||||
value: 'tracing-metrics',
|
||||
title: '6. 链路追踪与结构化日志',
|
||||
title: rt('6_distributed_tracing_and_structured_logs_e8971cad'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
|
||||
<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>OpenTelemetry Tracing:</strong>自动传递 Tracing
|
||||
上下文,所有经由 Gin 中间件、外部请求或 GORM
|
||||
数据库的事务操作都将带有全局唯一的
|
||||
Span,用于排查链路耗时或调用异常。
|
||||
<strong>OpenTelemetry Tracing:</strong>{' '}
|
||||
{rt(
|
||||
'tracing_context_is_propagated_automatically_gin_requests_0f549cce',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zap 结构化日志:</strong>
|
||||
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
|
||||
等日志收集分析工具无缝对接。
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeTranslator } from '@/i18n/runtime';
|
||||
import { type PolicySection } from './types';
|
||||
|
||||
/**
|
||||
@@ -5,37 +6,34 @@ import { type PolicySection } from './types';
|
||||
* 隐私政策 (PRIVACY POLICY)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const privacySections: PolicySection[] = [
|
||||
export function createPrivacySections(rt: RuntimeTranslator): PolicySection[] {
|
||||
return [
|
||||
{
|
||||
value: 'collection-details',
|
||||
title: '1. 详细信息收集说明',
|
||||
title: rt('1_information_we_collect_af0db091'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:
|
||||
</p>
|
||||
<p>{rt('we_collect_only_the_information_necessary_to_72f6e528')}</p>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<span className='font-semibold text-foreground'>
|
||||
1.1 身份鉴权信息:
|
||||
{rt('1_1_authentication_information_60199ee9')}
|
||||
</span>
|
||||
<p className='mt-1 text-muted-foreground'>
|
||||
当您通过本地账号注册或绑定第三方 OIDC
|
||||
认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方
|
||||
OpenID 标识符。
|
||||
{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'>
|
||||
1.2 服务与接口日志信息:
|
||||
{rt('1_2_service_and_api_logs_46c9a04d')}
|
||||
</span>
|
||||
<p className='mt-1 text-muted-foreground'>
|
||||
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
|
||||
IP 地址、访问日期和时间、个人访问令牌 API
|
||||
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
|
||||
{rt(
|
||||
'for_system_security_and_auditing_we_automatically_89737fdd',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,23 +42,24 @@ export const privacySections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'storage-security',
|
||||
title: '2. 数据存储与安全保护',
|
||||
title: rt('2_data_storage_and_security_7cc5cdef'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
|
||||
<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>存储安全:</strong>
|
||||
用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
|
||||
<strong>{rt('storage_security_a33cfbef')}</strong>{' '}
|
||||
{rt('user_data_is_stored_in_a_dedicated_132af7bb')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>加密技术:</strong>
|
||||
敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API
|
||||
数据传输链路强制使用 SSL/TLS 进行安全加密。
|
||||
<strong>{rt('cryptography_9a15a88b')}</strong>{' '}
|
||||
{rt(
|
||||
'sensitive_password_and_personal_access_token_hashes_9f724cd8',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>访问控制:</strong>我们实行严格的最小权限原则(Least
|
||||
Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
|
||||
<strong>{rt('access_control_3471eafa')}</strong>{' '}
|
||||
{rt('we_apply_the_principle_of_least_privilege_79328c28')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -68,77 +67,84 @@ export const privacySections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'usage-rules',
|
||||
title: '3. 信息使用规范',
|
||||
title: rt('3_how_we_use_information_41b4d6ad'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>我们收集的信息将仅用于以下目的:</p>
|
||||
<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>身份识别:</strong>
|
||||
用于确认您的注册身份,展示您的个人中心及设置页面数据。
|
||||
<strong>{rt('identification_481b07f4')}</strong>{' '}
|
||||
{rt('to_identify_your_registered_account_and_display_8c559094')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API
|
||||
鉴权指令。
|
||||
<strong>{rt('service_functions_c5834f20')}</strong>{' '}
|
||||
{rt(
|
||||
'to_identify_and_process_api_authentication_requests_d48a36a2',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>安全风控:</strong>利用 IP
|
||||
及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
|
||||
<strong>{rt('security_e7132437')}</strong>{' '}
|
||||
{rt('to_analyze_ip_addresses_and_activity_logs_5210836a')}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
|
||||
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
|
||||
<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: '4. 信息共享与对外披露',
|
||||
title: rt('4_information_sharing_and_disclosure_c034d295'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>4.1 共享原则:</strong>
|
||||
除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
|
||||
<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>事先获得您的明确授权或同意;</li>
|
||||
<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>4.2 转让与公开披露:</strong>
|
||||
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
|
||||
<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: '5. 您的权利与数据管理',
|
||||
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'>
|
||||
5.1 查阅与管理权:
|
||||
{rt('5_1_access_and_management_854d0793')}
|
||||
</span>
|
||||
<p className='mt-1 text-muted-foreground'>
|
||||
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的
|
||||
AccessToken (API 密钥)。
|
||||
{rt('you_may_sign_in_at_any_time_8727c5be')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-semibold text-foreground'>
|
||||
5.2 删除与注销权:
|
||||
{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>
|
||||
@@ -147,14 +153,15 @@ export const privacySections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'policy-update',
|
||||
title: '6. 政策更新与通知',
|
||||
title: rt('6_policy_updates_and_notices_444076f5'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
|
||||
<p>{rt('this_privacy_policy_may_change_as_the_2a270464')}</p>
|
||||
<p>
|
||||
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
|
||||
{rt('we_will_prominently_announce_material_changes_for_210fbd3b')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RuntimeTranslator } from '@/i18n/runtime';
|
||||
import { type PolicySection } from './types';
|
||||
|
||||
export const TERMS_LAST_UPDATED = '2026-06-07';
|
||||
@@ -7,38 +8,37 @@ export const TERMS_LAST_UPDATED = '2026-06-07';
|
||||
* 服务条款 (TERMS OF SERVICE)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const termsSections: PolicySection[] = [
|
||||
export function createTermsSections(rt: RuntimeTranslator): PolicySection[] {
|
||||
return [
|
||||
{
|
||||
value: 'contract-establishment',
|
||||
title: '1. 缔约申明与服务综述',
|
||||
title: rt('1_agreement_and_service_overview_511c6782'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>1.1 缔约主体:</strong>
|
||||
本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
|
||||
<strong>{rt('1_1_parties_d2bf79cd')}</strong>{' '}
|
||||
{rt('these_terms_of_service_the_agreement_govern_1e6754fc')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>1.2 审慎阅读:</strong>
|
||||
本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC
|
||||
接入、API Key
|
||||
令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
|
||||
<strong>{rt('1_2_read_carefully_dfde346e')}</strong>{' '}
|
||||
{rt('this_system_is_a_general_purpose_full_f1d395fe')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>1.3 协议构成:</strong>
|
||||
本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
|
||||
<strong>{rt('1_3_agreement_contents_f05cebc0')}</strong>{' '}
|
||||
{rt('the_agreement_includes_these_terms_and_any_43e244b6')}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'service-definition',
|
||||
title: '2. 服务定义与性质界定',
|
||||
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>
|
||||
关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
|
||||
<strong>{rt('your_responsibility_527cbdbf')}</strong>{' '}
|
||||
{rt('the_entity_deploying_and_operating_a_customized_047c6a8f')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -46,27 +46,26 @@ export const termsSections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'account-specifications',
|
||||
title: '3. 账号注册与使用规范',
|
||||
title: rt('3_registration_and_account_use_cfab1555'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>3.1 账号体系:</strong>
|
||||
用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方
|
||||
OIDC 认证源进行登录关联。
|
||||
<strong>{rt('3_1_accounts_2631c114')}</strong>{' '}
|
||||
{rt('users_may_register_through_the_frontend_or_e527ef52')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>3.2 密码及令牌安全责任:</strong>
|
||||
<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>密码安全:</strong>您应妥善保管您账户的登录密码。
|
||||
<strong>{rt('password_security_7a29e44e')}</strong>{' '}
|
||||
{rt('keep_your_account_password_secure_eb9adf10')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>API Token 安全:</strong>个人生成的 AccessToken (API Key)
|
||||
代表您账户的完整调用权限。
|
||||
<strong>{rt('api_token_security_34af066f')}</strong>{' '}
|
||||
{rt('a_personal_access_token_api_key_represents_6215b3b0')}{' '}
|
||||
<strong>
|
||||
因您保管不善导致 Token
|
||||
泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
|
||||
{rt('you_are_responsible_for_data_loss_or_0c9acc88')}
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -75,43 +74,52 @@ export const termsSections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'user-conduct',
|
||||
title: '4. 用户行为准则(负面清单)',
|
||||
title: rt('4_prohibited_user_conduct_bd5628b6'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
|
||||
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
|
||||
{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>危害国家安全:</strong>
|
||||
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
|
||||
<strong>{rt('endangering_national_security_4a160c4e')}</strong>{' '}
|
||||
{rt(
|
||||
'opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
|
||||
攻击服务、服务器爆破等其他非法信息服务平台;
|
||||
<strong>{rt('illegal_information_services_c97748bb')}</strong>{' '}
|
||||
{rt('hacking_tools_ddos_attack_services_server_brute_686746c4')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>黄赌毒关联:</strong>
|
||||
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
|
||||
<strong>{rt('pornography_gambling_and_drugs_fdb44a54')}</strong>{' '}
|
||||
{rt(
|
||||
'creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>侵犯知识产权:</strong>
|
||||
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
|
||||
<strong>
|
||||
{rt('intellectual_property_infringement_cc10b950')}
|
||||
</strong>{' '}
|
||||
{rt('selling_pirated_software_or_media_illegal_game_3f0f4159')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>其他违法信息:</strong>
|
||||
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
|
||||
<strong>{rt('other_unlawful_information_4085c17c')}</strong>{' '}
|
||||
{rt(
|
||||
'spreading_rumors_promoting_cults_or_superstition_insulting_a062981a',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
<strong>处理规则:</strong>
|
||||
一旦发现您违反上述规定,平台运维主体有权不经通知
|
||||
<strong>{rt('enforcement_9c7cfd68')}</strong>{' '}
|
||||
{rt('if_these_rules_are_violated_the_platform_86df3b18')}
|
||||
<strong>
|
||||
立即永久封禁您的账号、拦截所有 AccessToken
|
||||
请求,并依法向公安机关、网安部门移交相关线索。
|
||||
{rt('permanently_suspend_your_account_block_all_access_b9f04a42')}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,41 +127,57 @@ export const termsSections: PolicySection[] = [
|
||||
},
|
||||
{
|
||||
value: 'liability-limitation',
|
||||
title: '5. 免责声明与不可抗力',
|
||||
title: rt('5_disclaimer_and_force_majeure_be649f43'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>5.1 基础免责:</strong>
|
||||
本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
|
||||
<strong>{rt('5_1_general_disclaimer_30d01f0d')}</strong>{' '}
|
||||
{rt('this_development_platform_is_provided_as_is_23bc2b55')}
|
||||
</p>
|
||||
<p>
|
||||
<strong>5.2 技术服务中断:</strong>
|
||||
对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
|
||||
<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>自然灾害(台风、地震、海啸、洪水等);</li>
|
||||
<li>政府行为、网络安全法律法规调整或行政命令;</li>
|
||||
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
|
||||
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
|
||||
<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: '6. 法律适用与争议解决',
|
||||
title: rt('6_governing_law_and_disputes_b9663c86'),
|
||||
content: (
|
||||
<div className='space-y-4 text-sm leading-relaxed'>
|
||||
<p>
|
||||
<strong>6.1 法律适用:</strong>
|
||||
本协议的订立、执行、解释及争议的解决均适用
|
||||
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
|
||||
<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>6.2 争议解决:</strong>
|
||||
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
|
||||
<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>
|
||||
|
||||
@@ -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,17 @@ 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 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,
|
||||
'zh-CN': zhCNNames.languageNames['zh-CN'],
|
||||
};
|
||||
|
||||
type LanguageSwitcherProps = {
|
||||
variant?: 'icon' | 'full';
|
||||
@@ -38,7 +46,7 @@ export function LanguageSwitcher({
|
||||
if (item !== locale) switchLocale(item);
|
||||
}}
|
||||
>
|
||||
{localeLabels[item]}
|
||||
{nativeNames[item]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -55,7 +63,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 +77,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,7 +120,7 @@ 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'>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -111,13 +123,13 @@ export const FooterSection = React.memo(function FooterSection({
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -87,12 +88,16 @@ interface LoadingStateProps extends React.ComponentProps<'div'> {
|
||||
* 用于统一显示加载中的状态
|
||||
*/
|
||||
export function LoadingState({
|
||||
title = '加载中',
|
||||
description = '正在获取活动数据...',
|
||||
title = undefined,
|
||||
description = undefined,
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
iconSize = 'md',
|
||||
}: LoadingStateProps) {
|
||||
const rt = useTranslations('runtime');
|
||||
title ??= rt('loading_d04fcbda');
|
||||
description ??= rt('fetching_data_9ac5e428');
|
||||
|
||||
const iconSizes = { sm: 'size-8', md: 'size-10', lg: 'size-14' };
|
||||
const iconInnerSizes = { sm: 'size-4', md: 'size-5', lg: 'size-7' };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { AbstractIntlMessages } from 'next-intl';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -25,20 +25,17 @@ export function AppIntlProvider({
|
||||
messages: ssrMessages,
|
||||
children,
|
||||
}: Props) {
|
||||
// Static export (NEXT_STANDALONE_EXPORT) pre-renders every page at build time
|
||||
// with the default locale, so the SSR locale is stale at runtime. Re-resolve
|
||||
// from the NEXT_LOCALE cookie / browser language on the client. The first
|
||||
// render stays identical to SSR to avoid a hydration mismatch, then flips —
|
||||
// the brief flash matches the theme-hydration pattern (suppressHydrationWarning
|
||||
// is already set on <html>/<body>).
|
||||
// Static exports start in the default English locale. Keep hydration aligned
|
||||
// with that HTML, then apply an explicit preference before the hydrated UI
|
||||
// paints instead of switching languages in a passive effect after paint.
|
||||
const [clientLocale, setClientLocale] = useState<AppLocale | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const resolved = resolveClientLocale();
|
||||
if (resolved !== ssrLocale) {
|
||||
setClientLocale(resolved);
|
||||
document.documentElement.lang = resolved;
|
||||
}
|
||||
document.documentElement.lang = resolved;
|
||||
}, [ssrLocale]);
|
||||
|
||||
const locale = clientLocale ?? ssrLocale;
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { ConfigService } from '@/lib/services/config';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function SiteTitleUpdater() {
|
||||
const t = useTranslations('providers.titleUpdater');
|
||||
const pathname = usePathname();
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ['public-config'],
|
||||
@@ -18,17 +20,17 @@ export function SiteTitleUpdater() {
|
||||
// Determine the page suffix based on path
|
||||
let suffix = '';
|
||||
if (pathname === '/login') {
|
||||
suffix = ' - 登录';
|
||||
suffix = ` - ${t('login')}`;
|
||||
} else if (pathname === '/register') {
|
||||
suffix = ' - 注册';
|
||||
suffix = ` - ${t('register')}`;
|
||||
} else if (pathname.startsWith('/admin')) {
|
||||
suffix = ' - 后台管理';
|
||||
suffix = ` - ${t('admin')}`;
|
||||
} else if (pathname === '/') {
|
||||
suffix = ' - 总览';
|
||||
suffix = ` - ${t('dashboard')}`;
|
||||
}
|
||||
|
||||
document.title = `${siteName}${suffix}`;
|
||||
}, [publicConfigQuery.data?.site_name, pathname]);
|
||||
}, [publicConfigQuery.data?.site_name, pathname, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { enUS, zhCN } from 'date-fns/locale';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
@@ -19,14 +21,18 @@ function Calendar({
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
locale: localeOverride,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
const uiLocale = useLocale();
|
||||
const locale = localeOverride ?? (uiLocale === 'zh-CN' ? zhCN : enUS);
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
locale={locale}
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
@@ -37,7 +43,7 @@ function Calendar({
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
date.toLocaleString(locale.code ?? uiLocale, { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
@@ -25,13 +26,16 @@ function normalizeTag(raw: string) {
|
||||
function TagsInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '输入后按 Enter 添加',
|
||||
placeholder = undefined,
|
||||
disabled = false,
|
||||
className,
|
||||
id,
|
||||
validateTag,
|
||||
'aria-invalid': ariaInvalid,
|
||||
}: TagsInputProps) {
|
||||
const rt = useTranslations('runtime');
|
||||
placeholder ??= rt('type_and_press_enter_to_add_e30b7b5f');
|
||||
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -128,7 +132,7 @@ function TagsInput({
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted rounded-sm p-0.5 outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
aria-label={`移除 ${tag}`}
|
||||
aria-label={rt('remove_value0_30c2158a', { value0: tag })}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -44,6 +45,7 @@ const AdminContext = createContext<AdminContextState | null>(null);
|
||||
* @param {React.ReactNode} children - Admin Provider 的子元素
|
||||
*/
|
||||
export function AdminProvider({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations('contexts.admin');
|
||||
const queryClient = useQueryClient();
|
||||
const [systemConfigs, setSystemConfigs] = useState<SystemConfig[]>([]);
|
||||
const [systemConfigsLoading, setSystemConfigsLoading] = useState(false);
|
||||
@@ -78,14 +80,15 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorObject = handleContextError(error, '加载系统配置失败', {
|
||||
const errorObject = handleContextError(error, t('loadConfigFailed'), {
|
||||
logError: true,
|
||||
logMessage: 'Failed to load system configuration',
|
||||
});
|
||||
setSystemConfigsError(errorObject);
|
||||
setSystemConfigsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
/** 更新系统配置 */
|
||||
@@ -96,11 +99,13 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
|
||||
await refetchSystemConfigs(lastConfigTypeRef.current);
|
||||
} catch (error) {
|
||||
handleContextError(error, '更新系统配置失败');
|
||||
handleContextError(error, t('updateConfigFailed'), {
|
||||
logMessage: 'Failed to update system configuration',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[queryClient, refetchSystemConfigs],
|
||||
[queryClient, refetchSystemConfigs, t],
|
||||
);
|
||||
|
||||
const value: AdminContextState = {
|
||||
@@ -129,7 +134,7 @@ export function useAdmin() {
|
||||
const context = useContext(AdminContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAdmin 必须在 AdminProvider 内部使用');
|
||||
throw new Error('useAdmin must be used inside AdminProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -69,6 +70,7 @@ export function AdminUsersProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useTranslations('contexts.adminUsers');
|
||||
// State
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -237,7 +239,10 @@ export function AdminUsersProvider({
|
||||
is_active: !user.is_active,
|
||||
});
|
||||
toast.success(
|
||||
`已${!user.is_active ? '启用' : '禁用'}用户 ${user.username}`,
|
||||
t('userStatusChanged', {
|
||||
username: user.username,
|
||||
action: t(!user.is_active ? 'enabled' : 'disabled'),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Revert on error
|
||||
@@ -246,7 +251,7 @@ export function AdminUsersProvider({
|
||||
u.id === user.id ? { ...u, is_active: originalStatus } : u,
|
||||
),
|
||||
);
|
||||
toast.error('更新状态失败');
|
||||
toast.error(t('updateStatusFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,10 +266,10 @@ export function AdminUsersProvider({
|
||||
setTotal((prev) => prev + 1);
|
||||
// Clear cache because data changed
|
||||
cacheRef.current = {};
|
||||
toast.success(`已成功创建用户 ${newUser.username}`);
|
||||
toast.success(t('userCreated', { username: newUser.username }));
|
||||
return newUser;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '创建用户失败');
|
||||
toast.error(err instanceof Error ? err.message : t('createUserFailed'));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -275,9 +280,9 @@ export function AdminUsersProvider({
|
||||
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
cacheRef.current = {};
|
||||
toast.success(`已删除用户 ${user.username}`);
|
||||
toast.success(t('userDeleted', { username: user.username }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除用户失败');
|
||||
toast.error(err instanceof Error ? err.message : t('deleteUserFailed'));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -287,9 +292,9 @@ export function AdminUsersProvider({
|
||||
await services.adminUser.updateUser(id, req);
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...req } : u)));
|
||||
cacheRef.current = {};
|
||||
toast.success('更新用户信息成功');
|
||||
toast.success(t('updateUserSuccess'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '更新用户信息失败');
|
||||
toast.error(err instanceof Error ? err.message : t('updateUserFailed'));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -45,6 +47,7 @@ const UserContext = createContext<UserContextValue | undefined>(undefined);
|
||||
* ```
|
||||
*/
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations('contexts.user');
|
||||
const [state, setState] = useState<UserState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
@@ -68,10 +71,10 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
setState({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : '获取用户信息失败',
|
||||
error: error instanceof Error ? error.message : t('getUserInfoFailed'),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
/** 重新获取用户信息 */
|
||||
const refetch = useCallback(async () => {
|
||||
@@ -97,14 +100,15 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '登出失败';
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : t('logoutFailed');
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: errorMessage,
|
||||
}));
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
/** 组件挂载时获取用户信息(登录/注册页跳过,避免无意义请求) */
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import english from '@/messages/fragments/builtin-metadata.en.json';
|
||||
import chinese from '@/messages/fragments/builtin-metadata.zh-CN.json';
|
||||
import {
|
||||
localizeConfigDescription,
|
||||
localizeTemplateMetadata,
|
||||
} from './builtin-metadata';
|
||||
|
||||
describe('built-in metadata presentation', () => {
|
||||
it('localizes known descriptions in both directions without changing stored data', () => {
|
||||
const original = {
|
||||
key: 'site_name',
|
||||
description: chinese.builtinMetadata.configDescriptions.site_name,
|
||||
value: 'My site',
|
||||
};
|
||||
expect(
|
||||
localizeConfigDescription(original.key, original.description, 'en'),
|
||||
).toBe(english.builtinMetadata.configDescriptions.site_name);
|
||||
expect(
|
||||
localizeConfigDescription(original.key, 'Platform display name', 'zh-CN'),
|
||||
).toBe(original.description);
|
||||
expect(original.description).toBe(
|
||||
chinese.builtinMetadata.configDescriptions.site_name,
|
||||
);
|
||||
expect(original.value).toBe('My site');
|
||||
});
|
||||
|
||||
it('preserves custom descriptions, edited values and unknown configuration keys', () => {
|
||||
expect(
|
||||
localizeConfigDescription('site_name', 'Custom description', 'zh-CN'),
|
||||
).toBe('Custom description');
|
||||
expect(localizeConfigDescription('site_name', '', 'en')).toBe('');
|
||||
expect(
|
||||
localizeConfigDescription(
|
||||
'unknown',
|
||||
chinese.builtinMetadata.configDescriptions.site_name,
|
||||
'en',
|
||||
),
|
||||
).toBe(chinese.builtinMetadata.configDescriptions.site_name);
|
||||
});
|
||||
|
||||
it('only localizes the exact defaults of built-in templates', () => {
|
||||
const original = chinese.builtinMetadata.templates.login_email;
|
||||
expect(
|
||||
localizeTemplateMetadata(
|
||||
'login_email',
|
||||
true,
|
||||
'name',
|
||||
original.name,
|
||||
'en',
|
||||
),
|
||||
).toBe(english.builtinMetadata.templates.login_email.name);
|
||||
expect(
|
||||
localizeTemplateMetadata(
|
||||
'login_email',
|
||||
true,
|
||||
'description',
|
||||
original.description,
|
||||
'en',
|
||||
),
|
||||
).toBe(english.builtinMetadata.templates.login_email.description);
|
||||
expect(
|
||||
localizeTemplateMetadata(
|
||||
'login_email',
|
||||
false,
|
||||
'name',
|
||||
original.name,
|
||||
'en',
|
||||
),
|
||||
).toBe(original.name);
|
||||
expect(
|
||||
localizeTemplateMetadata(
|
||||
'login_email',
|
||||
true,
|
||||
'name',
|
||||
'My verification email',
|
||||
'zh-CN',
|
||||
),
|
||||
).toBe('My verification email');
|
||||
expect(
|
||||
localizeTemplateMetadata('unknown', true, 'name', original.name, 'en'),
|
||||
).toBe(original.name);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import english from '@/messages/fragments/builtin-metadata.en.json';
|
||||
import chinese from '@/messages/fragments/builtin-metadata.zh-CN.json';
|
||||
|
||||
function localizedDefault(
|
||||
value: string,
|
||||
en: string | undefined,
|
||||
zh: string | undefined,
|
||||
locale: string,
|
||||
): string {
|
||||
// Only known defaults are translated. Custom and unknown text stays intact.
|
||||
if (!en || !zh || (value !== en && value !== zh)) return value;
|
||||
return locale === 'zh-CN' ? zh : en;
|
||||
}
|
||||
|
||||
export function localizeConfigDescription(
|
||||
key: string,
|
||||
value: string,
|
||||
locale: string,
|
||||
): string {
|
||||
const en = english.builtinMetadata.configDescriptions as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const zh = chinese.builtinMetadata.configDescriptions as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return localizedDefault(value, en[key], zh[key], locale);
|
||||
}
|
||||
|
||||
export function localizeTemplateMetadata(
|
||||
key: string,
|
||||
isSystem: boolean,
|
||||
field: 'name' | 'description',
|
||||
value: string,
|
||||
locale: string,
|
||||
): string {
|
||||
if (!isSystem) return value;
|
||||
const en = english.builtinMetadata.templates as Record<
|
||||
string,
|
||||
{ name: string; description: string }
|
||||
>;
|
||||
const zh = chinese.builtinMetadata.templates as typeof en;
|
||||
return localizedDefault(value, en[key]?.[field], zh[key]?.[field], locale);
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
export const locales = ['zh-CN', 'en'] as const;
|
||||
export const locales = ['en', 'zh-CN'] as const;
|
||||
|
||||
export type AppLocale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: AppLocale = 'zh-CN';
|
||||
export const defaultLocale: AppLocale = 'en';
|
||||
|
||||
export const localeCookieName = 'NEXT_LOCALE';
|
||||
|
||||
export const localeLabels: Record<AppLocale, string> = {
|
||||
'zh-CN': '中文',
|
||||
en: 'English',
|
||||
};
|
||||
|
||||
export function isAppLocale(
|
||||
value: string | undefined | null,
|
||||
): value is AppLocale {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { formatDateTime, getFormattingLocale } from './format';
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.lang = '';
|
||||
});
|
||||
|
||||
describe('locale-aware formatting', () => {
|
||||
it('uses the rendered language for dates and preserves invalid input', () => {
|
||||
document.documentElement.lang = 'en';
|
||||
expect(getFormattingLocale()).toBe('en');
|
||||
expect(formatDateTime('2026-01-15T00:00:00Z')).toContain('01/15/2026');
|
||||
document.documentElement.lang = 'zh-CN';
|
||||
expect(formatDateTime('2026-01-15T00:00:00Z')).toContain('2026/01/15');
|
||||
expect(formatDateTime('自定义文本')).toBe('自定义文本');
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -1,5 +1,13 @@
|
||||
import type { AppLocale } from './config';
|
||||
import { defaultLocale } from './config';
|
||||
import { defaultLocale, normalizeLocale } from './config';
|
||||
|
||||
// Match the language currently rendered by the provider, including the initial
|
||||
// static-export render. This module also remains safe in server contexts.
|
||||
export function getFormattingLocale(): AppLocale {
|
||||
return typeof document === 'undefined'
|
||||
? defaultLocale
|
||||
: normalizeLocale(document.documentElement.lang);
|
||||
}
|
||||
|
||||
const localeToIntl: Record<AppLocale, string> = {
|
||||
'zh-CN': 'zh-CN',
|
||||
@@ -8,7 +16,7 @@ const localeToIntl: Record<AppLocale, string> = {
|
||||
|
||||
export function formatDateTime(
|
||||
dateStr: string | Date,
|
||||
locale: AppLocale = defaultLocale,
|
||||
locale: AppLocale = getFormattingLocale(),
|
||||
timeZone = 'Asia/Shanghai',
|
||||
): string {
|
||||
try {
|
||||
@@ -29,7 +37,7 @@ export function formatDateTime(
|
||||
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale: AppLocale = defaultLocale,
|
||||
locale: AppLocale = getFormattingLocale(),
|
||||
): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeToIntl[locale] ?? locale).format(value);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createTranslator, type AbstractIntlMessages } from 'next-intl';
|
||||
import { NetworkError, isCancelError } from '@/lib/services/core/errors';
|
||||
import { buildTaskPayload } from '@/lib/task-param-utils';
|
||||
import { effectiveOfflinePageHTML } from '@/lib/openflare/offline-page-templates';
|
||||
import { findGraphErrorTarget } from '@/app/(main)/waf/rules/editor/components/editor-behavior';
|
||||
import { isLogStorageDisabledMessage } from '@/app/(main)/admin/logs/components/log-storage-error';
|
||||
import { searchItems } from '@/lib/utils/search-data';
|
||||
import english from '@/messages/en.json';
|
||||
import chinese from '@/messages/zh-CN.json';
|
||||
import { getRuntimeTranslator } from './runtime';
|
||||
|
||||
afterEach(() => {
|
||||
document.cookie = 'NEXT_LOCALE=; Max-Age=0; Path=/';
|
||||
});
|
||||
|
||||
describe('imperative runtime translations', () => {
|
||||
it('recognizes disabled log storage independently of the current language', () => {
|
||||
expect(
|
||||
isLogStorageDisabledMessage(
|
||||
'Log storage is disabled; analytics are unavailable',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isLogStorageDisabledMessage('日志存储未启用,无法查询访问日志'),
|
||||
).toBe(true);
|
||||
expect(isLogStorageDisabledMessage('Unexpected network error')).toBe(false);
|
||||
});
|
||||
it('uses the current locale when creating errors and preserves field values', () => {
|
||||
document.cookie = 'NEXT_LOCALE=en; Path=/';
|
||||
expect(new NetworkError().message).toBe(
|
||||
'Network connection failed; check your network',
|
||||
);
|
||||
const result = buildTaskPayload(
|
||||
[
|
||||
{
|
||||
name: 'value',
|
||||
label: '自定义名称',
|
||||
type: 'number',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
{ value: 'invalid' },
|
||||
);
|
||||
expect(result.error).toBe('自定义名称 must be a valid number');
|
||||
document.cookie = 'NEXT_LOCALE=zh-CN; Path=/';
|
||||
expect(new NetworkError().message).toBe('网络连接失败,请检查您的网络');
|
||||
});
|
||||
|
||||
it('recognizes historical canceled requests independently of the current locale', () => {
|
||||
document.cookie = 'NEXT_LOCALE=en; Path=/';
|
||||
expect(isCancelError(new Error('请求已被取消'))).toBe(true);
|
||||
document.cookie = 'NEXT_LOCALE=zh-CN; Path=/';
|
||||
expect(isCancelError(new Error('Request canceled'))).toBe(true);
|
||||
expect(isCancelError(new Error('Other error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('selects offline HTML at use time without rewriting custom HTML', () => {
|
||||
document.cookie = 'NEXT_LOCALE=en; Path=/';
|
||||
expect(effectiveOfflinePageHTML('')).toContain('lang="en"');
|
||||
document.cookie = 'NEXT_LOCALE=zh-CN; Path=/';
|
||||
expect(effectiveOfflinePageHTML('')).toContain('lang="zh-CN"');
|
||||
const custom = '<p>用户内容</p>';
|
||||
expect(effectiveOfflinePageHTML(custom)).toBe(custom);
|
||||
});
|
||||
|
||||
it('finds graph targets in both current English and historical Chinese errors', () => {
|
||||
for (const message of [
|
||||
'Node node-1 contains invalid IP',
|
||||
'节点 node-1 包含无效 IP',
|
||||
]) {
|
||||
expect(findGraphErrorTarget(new Error(message), ['node-1'], [])).toEqual({
|
||||
kind: 'node',
|
||||
id: 'node-1',
|
||||
});
|
||||
}
|
||||
for (const message of [
|
||||
'Edge edge-1 has invalid source',
|
||||
'边 ID edge-1 的端点无效',
|
||||
]) {
|
||||
expect(findGraphErrorTarget(new Error(message), [], ['edge-1'])).toEqual({
|
||||
kind: 'edge',
|
||||
id: 'edge-1',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Chinese search aliases in the Chinese catalog', () => {
|
||||
const zh = createTranslator({
|
||||
locale: 'zh-CN',
|
||||
messages: chinese as AbstractIntlMessages,
|
||||
namespace: 'layout',
|
||||
});
|
||||
const en = createTranslator({
|
||||
locale: 'en',
|
||||
messages: english as AbstractIntlMessages,
|
||||
namespace: 'layout',
|
||||
});
|
||||
expect(
|
||||
searchItems('日志', true, zh).some((item) => item.url === '/admin/logs'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
searchItems('terminal', true, en).some(
|
||||
(item) => item.url === '/admin/logs',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('formats interpolated messages in both catalogs', () => {
|
||||
for (const locale of ['en', 'zh-CN'] as const) {
|
||||
const t = getRuntimeTranslator(locale);
|
||||
for (const [key, text] of Object.entries(english.runtime)) {
|
||||
if (/\{value\d+\}/.test(text)) {
|
||||
const translated = t(key as keyof typeof english.runtime, {
|
||||
value0: 'CUSTOM',
|
||||
value1: 'VALUE',
|
||||
value2: 42,
|
||||
});
|
||||
expect(translated).toContain('CUSTOM');
|
||||
expect(translated).not.toContain('{value0}');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { createTranslator, type useTranslations } from 'next-intl';
|
||||
import english from '@/messages/fragments/runtime.en.json';
|
||||
import chinese from '@/messages/fragments/runtime.zh-CN.json';
|
||||
import { resolveClientLocale } from './client';
|
||||
import type { AppLocale } from './config';
|
||||
|
||||
export type RuntimeTranslator = ReturnType<typeof useTranslations>;
|
||||
type RuntimeKey = keyof typeof english.runtime;
|
||||
|
||||
const translators = {
|
||||
en: createTranslator({
|
||||
locale: 'en',
|
||||
messages: english,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
'zh-CN': createTranslator({
|
||||
locale: 'zh-CN',
|
||||
messages: chinese,
|
||||
namespace: 'runtime',
|
||||
}),
|
||||
};
|
||||
|
||||
export function getRuntimeTranslator(locale: AppLocale) {
|
||||
return translators[locale];
|
||||
}
|
||||
|
||||
// Imperative browser code (network errors, form utilities) has no React hook
|
||||
// context. Resolve the current preference when creating a message, not at import.
|
||||
export const runtimeText = Object.assign(
|
||||
(key: RuntimeKey, values?: Record<string, string | number>) =>
|
||||
translators[resolveClientLocale()](key, values),
|
||||
{
|
||||
raw: (key: RuntimeKey): string =>
|
||||
translators[resolveClientLocale()].raw(key),
|
||||
},
|
||||
);
|
||||
+21
-14
@@ -1,3 +1,4 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/**
|
||||
* Cap PoW (Proof-of-Work) 人机验证前端实现
|
||||
* 与后端 pkg/cap 算法完全对应
|
||||
@@ -78,7 +79,7 @@ async function solvePuzzle(token, index, size, difficulty) {
|
||||
for (let nonce = 0; nonce < 10_000_000; nonce++) {
|
||||
if ((await sha256Hex(salt + nonce)).startsWith(target)) return nonce;
|
||||
}
|
||||
throw new Error('无法在合理范围内求解第 ' + (index + 1) + ' 个难题');
|
||||
throw new Error('Could not solve puzzle ' + (index + 1) + ' within the allowed search range');
|
||||
}
|
||||
|
||||
// Entry point — receive task from main thread
|
||||
@@ -124,7 +125,11 @@ function solveInWorker(
|
||||
worker.onerror = (err) => {
|
||||
URL.revokeObjectURL(url);
|
||||
worker.terminate();
|
||||
reject(new Error(err.message || 'Worker 执行失败'));
|
||||
reject(
|
||||
new Error(
|
||||
err.message || runtimeText('worker_execution_failed_1877e35b'),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
worker.postMessage({ token, count, size, difficulty });
|
||||
@@ -142,14 +147,14 @@ export async function getCapToken(scope = 'login'): Promise<string> {
|
||||
});
|
||||
const challenge = await readApiData<ChallengeResponse>(
|
||||
challengeRes,
|
||||
'获取人机验证难题失败',
|
||||
runtimeText('failed_to_fetch_the_verification_challenge_95553133'),
|
||||
);
|
||||
const { c: count, s: size, d: difficulty } = challenge.challenge;
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解开始');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` 盐值长度 (size) : ${size}`);
|
||||
console.groupCollapsed('[Cap] Starting verification PoW solver');
|
||||
console.log(` Challenge count: ${count}`);
|
||||
console.log(` Difficulty: ${difficulty}`);
|
||||
console.log(` Salt size: ${size}`);
|
||||
console.groupEnd();
|
||||
|
||||
// 2. 在 Worker 中求解(不阻塞主线程)
|
||||
@@ -162,11 +167,11 @@ export async function getCapToken(scope = 'login'): Promise<string> {
|
||||
);
|
||||
const wallTime = ((performance.now() - t0) / 1000).toFixed(3);
|
||||
|
||||
console.groupCollapsed('[Cap] 人机验证 PoW 求解完成');
|
||||
console.log(` 难题数量 (count) : ${count}`);
|
||||
console.log(` 验证难度 (difficulty): ${difficulty}`);
|
||||
console.log(` Worker 耗时 : ${elapsed}s`);
|
||||
console.log(` 总耗时(含通信) : ${wallTime}s`);
|
||||
console.groupCollapsed('[Cap] Verification PoW solver completed');
|
||||
console.log(` Challenge count: ${count}`);
|
||||
console.log(` Difficulty: ${difficulty}`);
|
||||
console.log(` Worker duration: ${elapsed}s`);
|
||||
console.log(` Total duration (including communication): ${wallTime}s`);
|
||||
console.log(` Solutions :`, solutions);
|
||||
console.groupEnd();
|
||||
|
||||
@@ -178,10 +183,12 @@ export async function getCapToken(scope = 'login'): Promise<string> {
|
||||
});
|
||||
const redeemData = await readApiData<RedeemResponse>(
|
||||
redeemRes,
|
||||
'人机验证失败',
|
||||
runtimeText('verification_failed_d3925ab2'),
|
||||
);
|
||||
if (!redeemData.success || !redeemData.token) {
|
||||
throw new Error(redeemData.error || '人机验证失败');
|
||||
throw new Error(
|
||||
redeemData.error || runtimeText('verification_failed_d3925ab2'),
|
||||
);
|
||||
}
|
||||
|
||||
return redeemData.token;
|
||||
|
||||
@@ -1,170 +1,7 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/** 内置离线页模板:baohaus */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>离线页 | OpenFlare</title>
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
export default function getOfflineHTML() {
|
||||
const html = runtimeText.raw('doctype_html_html_lang_en_head_meta_3e4f5ecb');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #f4f3ef;
|
||||
color: #121212;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.bauhaus-card {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
background: #ffffff;
|
||||
border: 3px solid #121212;
|
||||
box-shadow: 8px 8px 0px #121212;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-accent-bar {
|
||||
height: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.accent-red { flex: 2; background-color: #e63946; }
|
||||
.accent-blue { flex: 3; background-color: #1d3557; }
|
||||
.accent-yellow { flex: 1; background-color: #f1faee; }
|
||||
|
||||
.card-body {
|
||||
padding: 40px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
align-self: flex-start;
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.contact-box {
|
||||
border: 2px solid #121212;
|
||||
background-color: #f9f8f3;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contact-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-line a {
|
||||
color: #e63946;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.contact-line a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-top: 2px solid #121212;
|
||||
padding: 16px 32px;
|
||||
background-color: #faf9f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bauhaus-card">
|
||||
<div class="card-accent-bar">
|
||||
<div class="accent-red"></div>
|
||||
<div class="accent-blue"></div>
|
||||
<div class="accent-yellow"></div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tag">OFFLINE FALLBACK</div>
|
||||
<h1 class="title">离线联系站长</h1>
|
||||
<p class="message">
|
||||
源站网络临时不可达。离线 Service Worker 已拦截请求,您可以使用下方联系方式取得支持。
|
||||
</p>
|
||||
<div class="contact-box">
|
||||
<div class="contact-line">
|
||||
<span>站长邮箱</span>
|
||||
<a href="mailto:admin@example.com">admin@example.com</a>
|
||||
</div>
|
||||
<div class="contact-line">
|
||||
<span>服务监控</span>
|
||||
<a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="brand">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
|
||||
</svg>
|
||||
<span>OpenFlare Service Worker</span>
|
||||
</div>
|
||||
<span>OFFLINE MODE</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default html;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/**
|
||||
* 内置离线页 HTML 模板目录。
|
||||
*/
|
||||
@@ -17,29 +18,40 @@ export type OfflinePageTemplate = {
|
||||
export const OFFLINE_PAGE_TEMPLATES: OfflinePageTemplate[] = [
|
||||
{
|
||||
id: 'minimalist',
|
||||
name: '极简白底',
|
||||
html: minimalistHtml,
|
||||
get name() {
|
||||
return runtimeText('minimal_white_5636e8a9');
|
||||
},
|
||||
get html() {
|
||||
return minimalistHtml();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'line-style',
|
||||
name: '线框拓扑',
|
||||
html: lineStyleHtml,
|
||||
get name() {
|
||||
return runtimeText('line_topology_22ad450c');
|
||||
},
|
||||
get html() {
|
||||
return lineStyleHtml();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'baohaus',
|
||||
name: '包豪斯',
|
||||
html: baohausHtml,
|
||||
get name() {
|
||||
return runtimeText('bauhaus_5cc69781');
|
||||
},
|
||||
get html() {
|
||||
return baohausHtml();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 默认预制模板 */
|
||||
export const DEFAULT_OFFLINE_PAGE_TEMPLATE_ID = 'minimalist';
|
||||
|
||||
export const DEFAULT_OFFLINE_PAGE_HTML =
|
||||
getOfflinePageTemplate(DEFAULT_OFFLINE_PAGE_TEMPLATE_ID)?.html ?? '';
|
||||
|
||||
export function effectiveOfflinePageHTML(html: string): string {
|
||||
return html.trim() === '' ? DEFAULT_OFFLINE_PAGE_HTML : html;
|
||||
return html.trim() === ''
|
||||
? (getOfflinePageTemplate(DEFAULT_OFFLINE_PAGE_TEMPLATE_ID)?.html ?? '')
|
||||
: html;
|
||||
}
|
||||
|
||||
export function getOfflinePageTemplate(
|
||||
|
||||
@@ -1,193 +1,7 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/** 内置离线页模板:line-style */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>离线页 | OpenFlare</title>
|
||||
<style>
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
export default function getOfflineHTML() {
|
||||
const html = runtimeText.raw('doctype_html_html_lang_en_head_meta_42a4f2da');
|
||||
|
||||
:root {
|
||||
--color-bg: #fcfcfc;
|
||||
--color-line: #e0e0e0;
|
||||
--color-line-hover: #1a1a1a;
|
||||
--color-text-main: #1a1a1a;
|
||||
--color-text-sub: #666666;
|
||||
--color-text-muted: #999999;
|
||||
--color-accent: #d4d4d4;
|
||||
--font-geo: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-geo);
|
||||
background-color: var(--color-bg);
|
||||
background-image: radial-gradient(circle, var(--color-accent) 0.8px, transparent 0.8px);
|
||||
background-size: 24px 24px;
|
||||
color: var(--color-text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.line-box {
|
||||
border: 1px solid var(--color-line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 32px;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.line-box:hover {
|
||||
border-color: var(--color-line-hover);
|
||||
box-shadow: 0 0 0 1px var(--color-line-hover);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-sub);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-sub);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-top: 1px dashed var(--color-line);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contact-label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.contact-value a {
|
||||
color: var(--color-text-main);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.contact-value a:hover {
|
||||
border-color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.footer-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<div class="line-box">
|
||||
<div class="status-badge">
|
||||
<span class="status-dot"></span>
|
||||
SERVICE WORKER OFFLINE CACHE
|
||||
</div>
|
||||
<h1 class="title">网络中断或源站不可达</h1>
|
||||
<p class="description">
|
||||
系统已自动启用 Service Worker 兜底保护。如需紧急联系站长,请通过以下渠道:
|
||||
</p>
|
||||
<div class="contact-list">
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Email Support</span>
|
||||
<span class="contact-value"><a href="mailto:admin@example.com">admin@example.com</a></span>
|
||||
</div>
|
||||
<div class="contact-row">
|
||||
<span class="contact-label">Status Page</span>
|
||||
<span class="contact-value"><a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bar">
|
||||
<div class="brand">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
|
||||
</svg>
|
||||
<span>OpenFlare Edge</span>
|
||||
</div>
|
||||
<span>FALLBACK ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export default html;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,128 +1,7 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/** 内置离线页模板:minimalist(默认) */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>离线页 | OpenFlare</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
.container {
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
background-color: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.description {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #6b7280;
|
||||
max-width: 480px;
|
||||
}
|
||||
.contact-card {
|
||||
width: 100%;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
.contact-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contact-item a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
.contact-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="badge">OFFLINE CONTACT</div>
|
||||
<h1 class="title">站点暂不可用,联系站长</h1>
|
||||
<p class="description">
|
||||
当前无法连接至源站。页面已开启 Service Worker 离线保护,您可以通过以下方式与站长取得联系。
|
||||
</p>
|
||||
<div class="contact-card">
|
||||
<div class="contact-item">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
||||
<span>联系邮箱:<a href="mailto:admin@example.com">admin@example.com</a></span>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
<span>服务状态页:<a href="https://status.example.com" target="_blank" rel="noopener">status.example.com</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<svg class="brand-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" />
|
||||
</svg>
|
||||
<span>OpenFlare Protection</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
export default function getOfflineHTML() {
|
||||
const html = runtimeText.raw('doctype_html_html_lang_en_head_meta_505c1308');
|
||||
|
||||
export default html;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 内置错误页模板:baohaus(包豪斯风格) */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/**
|
||||
* 内置源站错误页 HTML 模板目录。
|
||||
* 每个模板对应本目录下一个独立 `.ts` 模块(export default 完整 HTML 字符串)。
|
||||
@@ -21,17 +22,23 @@ export type OriginErrorPageTemplate = {
|
||||
export const ORIGIN_ERROR_PAGE_TEMPLATES: OriginErrorPageTemplate[] = [
|
||||
{
|
||||
id: 'minimalist',
|
||||
name: '极简白底',
|
||||
get name() {
|
||||
return runtimeText('minimal_white_5636e8a9');
|
||||
},
|
||||
html: minimalistHtml,
|
||||
},
|
||||
{
|
||||
id: 'line-style',
|
||||
name: '线框拓扑',
|
||||
get name() {
|
||||
return runtimeText('line_topology_22ad450c');
|
||||
},
|
||||
html: lineStyleHtml,
|
||||
},
|
||||
{
|
||||
id: 'baohaus',
|
||||
name: '包豪斯',
|
||||
get name() {
|
||||
return runtimeText('bauhaus_5cc69781');
|
||||
},
|
||||
html: baohausHtml,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 内置错误页模板:line-style */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 内置错误页模板:minimalist(默认) */
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runtimeText } from '@/i18n/runtime';
|
||||
/** Matches pkg/render/openresty StatusCodeMin / StatusCodeMax. */
|
||||
export const STATUS_CODE_MIN = 400;
|
||||
export const STATUS_CODE_MAX = 599;
|
||||
@@ -14,7 +15,7 @@ export function parseStatusCodeTag(tag: string): {
|
||||
} {
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('状态码标签不能为空');
|
||||
throw new Error(runtimeText('status_code_tag_must_not_be_empty_21235ebf'));
|
||||
}
|
||||
|
||||
let lo: number;
|
||||
@@ -25,22 +26,36 @@ export function parseStatusCodeTag(tag: string): {
|
||||
lo = Number.parseInt(trimmed.slice(0, dash), 10);
|
||||
hi = Number.parseInt(trimmed.slice(dash + 1), 10);
|
||||
if (Number.isNaN(lo) || Number.isNaN(hi)) {
|
||||
throw new Error(`无效状态码区间: ${trimmed}`);
|
||||
throw new Error(
|
||||
runtimeText('invalid_status_code_range_value0_8983e96e', {
|
||||
value0: trimmed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lo = Number.parseInt(trimmed, 10);
|
||||
if (Number.isNaN(lo)) {
|
||||
throw new Error(`无效状态码: ${trimmed}`);
|
||||
throw new Error(
|
||||
runtimeText('invalid_status_code_value0_0f206e48', { value0: trimmed }),
|
||||
);
|
||||
}
|
||||
hi = lo;
|
||||
}
|
||||
|
||||
if (lo > hi) {
|
||||
throw new Error(`状态码区间左右端点反序: ${trimmed}`);
|
||||
throw new Error(
|
||||
runtimeText('status_code_range_endpoints_are_reversed_value0_d92c2784', {
|
||||
value0: trimmed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (lo < STATUS_CODE_MIN || hi > STATUS_CODE_MAX) {
|
||||
throw new Error(
|
||||
`状态码须在 ${STATUS_CODE_MIN}–${STATUS_CODE_MAX}: ${trimmed}`,
|
||||
runtimeText('status_code_must_be_between_value0_value1_c5b04822', {
|
||||
value0: STATUS_CODE_MIN,
|
||||
value1: STATUS_CODE_MAX,
|
||||
value2: trimmed,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,18 +80,22 @@ export function validateStatusCodeTagMessage(tag: string): string | null {
|
||||
parseStatusCodeTag(tag);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : '无效状态码标签';
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: runtimeText('invalid_status_code_tag_d16e85d7');
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a full tag list before save (must be non-empty and expand cleanly). */
|
||||
export function validateStatusCodeTags(tags: string[]): void {
|
||||
if (tags.length === 0) {
|
||||
throw new Error('请至少添加一个状态码标签');
|
||||
throw new Error(runtimeText('add_at_least_one_status_code_tag_b81b2696'));
|
||||
}
|
||||
const codes = expandStatusCodeTags(tags);
|
||||
if (codes.length === 0) {
|
||||
throw new Error('状态码展开结果为空');
|
||||
throw new Error(
|
||||
runtimeText('the_expanded_status_code_list_is_empty_47245f34'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user